experiments/julia_cfr_toy/ ports a synthetic CFR external-sampling traversal to both Julia and Cython for direct head-to-head measurement of the actual project hot-path pattern (recursive tree + mutable regret state + branch-heavy legal-action logic). Headline result (2026-05-07, single run): Julia ~1.9× faster than Cython on this pattern, 0 MB allocation, 0% GC time. Root regret parity ε ≤ 1e-9. The GC-pause concern that was the main argument against Julia adoption did not materialize. Cython's 21.3 MB allocation suggests its implementation can be tightened, so the honest gap window is roughly 1.3×–1.9×. Multi-thread scaling (bench_cfr_threaded.jl): 2.44× wall-clock at 8T but only 31% efficiency — inconclusive, likely a toy-size artifact (per-thread workload too small to amortize dispatch). A heavier per-thread workload sweep is the remaining decisive test. docs/research/julia_port_evaluation.md captures this evidence alongside the earlier safe-heuristic single-thread parity result and lists the remaining decision criteria (multi-thread scaling with heavier workload, Flux.jl+CUDA.jl coverage, real-game-state slice). Do not commit to porting until multi-thread scaling is conclusively settled. ideas.md gets a second Active Research Threads pointer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
4.9 KiB
Julia
167 lines
4.9 KiB
Julia
using Printf
|
||
using Statistics
|
||
using Base.Threads
|
||
|
||
include("bench_cfr.jl")
|
||
|
||
const THREAD_GRID = (1, 2, 4, 8)
|
||
const RUNS_PER_CASE = 5
|
||
const ROOT_ACTIONS = 4
|
||
|
||
function iteration_ranges(chunks::Int)::Vector{UnitRange{Int}}
|
||
ranges = Vector{UnitRange{Int}}(undef, chunks)
|
||
base = MEASURE_ITERATIONS ÷ chunks
|
||
extra = MEASURE_ITERATIONS % chunks
|
||
start = 1
|
||
for chunk in 1:chunks
|
||
width = base + (chunk <= extra ? 1 : 0)
|
||
stop = start + width - 1
|
||
ranges[chunk] = start:stop
|
||
start = stop + 1
|
||
end
|
||
return ranges
|
||
end
|
||
|
||
function reset_trees!(trees::Vector{CFRTree}, chunks::Int)
|
||
for chunk in 1:chunks
|
||
fill!(trees[chunk].regret, 0.0)
|
||
end
|
||
end
|
||
|
||
function reduce_root!(out::Vector{Float64}, trees::Vector{CFRTree}, chunks::Int)
|
||
fill!(out, 0.0)
|
||
@inbounds for chunk in 1:chunks
|
||
tree = trees[chunk]
|
||
out[1] += tree.regret[regret_index(1, 1)]
|
||
out[2] += tree.regret[regret_index(1, 2)]
|
||
out[3] += tree.regret[regret_index(1, 3)]
|
||
out[4] += tree.regret[regret_index(1, 4)]
|
||
end
|
||
end
|
||
|
||
function run_chunk!(tree::CFRTree, range::UnitRange{Int})
|
||
value = 0.0
|
||
for iteration in range
|
||
value += run_iteration!(tree, iteration)
|
||
end
|
||
return value
|
||
end
|
||
|
||
function run_case!(
|
||
out::Vector{Float64},
|
||
trees::Vector{CFRTree},
|
||
ranges::Vector{UnitRange{Int}},
|
||
chunks::Int;
|
||
threaded::Bool,
|
||
)
|
||
reset_trees!(trees, chunks)
|
||
if threaded && chunks > 1
|
||
@threads for chunk in 1:chunks
|
||
run_chunk!(trees[chunk], ranges[chunk])
|
||
end
|
||
else
|
||
for chunk in 1:chunks
|
||
run_chunk!(trees[chunk], ranges[chunk])
|
||
end
|
||
end
|
||
reduce_root!(out, trees, chunks)
|
||
end
|
||
|
||
function assert_close(label::AbstractString, actual::Vector{Float64}, expected::Vector{Float64})
|
||
max_diff = maximum(abs.(actual .- expected))
|
||
if max_diff > 1e-9
|
||
error("$label parity failed: max_diff=$max_diff actual=$actual expected=$expected")
|
||
end
|
||
end
|
||
|
||
function measure_case(chunks::Int)
|
||
ranges = iteration_ranges(chunks)
|
||
reference_trees = [CFRTree() for _ in 1:chunks]
|
||
measured_trees = [CFRTree() for _ in 1:chunks]
|
||
reference = zeros(Float64, ROOT_ACTIONS)
|
||
actual = zeros(Float64, ROOT_ACTIONS)
|
||
|
||
run_case!(reference, reference_trees, ranges, chunks; threaded=false)
|
||
run_case!(actual, measured_trees, ranges, chunks; threaded=chunks > 1)
|
||
assert_close("warmup $(chunks)T", actual, reference)
|
||
|
||
times = Vector{Float64}(undef, RUNS_PER_CASE)
|
||
allocs = Vector{Int}(undef, RUNS_PER_CASE)
|
||
gc_times = Vector{Float64}(undef, RUNS_PER_CASE)
|
||
for run in 1:RUNS_PER_CASE
|
||
GC.gc()
|
||
before = Base.gc_num()
|
||
elapsed_ref = Ref(0.0)
|
||
allocated = @allocated begin
|
||
elapsed_ref[] = @elapsed begin
|
||
run_case!(actual, measured_trees, ranges, chunks; threaded=chunks > 1)
|
||
end
|
||
end
|
||
after = Base.gc_num()
|
||
assert_close("run $(run) $(chunks)T", actual, reference)
|
||
times[run] = elapsed_ref[]
|
||
allocs[run] = allocated
|
||
gc_times[run] = (after.total_time - before.total_time) / 1e9
|
||
end
|
||
|
||
total_s = median(times)
|
||
alloc_mb = median(allocs) / 1024.0 / 1024.0
|
||
gc_time_s = median(gc_times)
|
||
return Dict(
|
||
"threads" => chunks,
|
||
"total_s" => total_s,
|
||
"iter_ms" => total_s * 1000.0 / MEASURE_ITERATIONS,
|
||
"traversal_us" => total_s * 1_000_000.0 / (MEASURE_ITERATIONS * TRAVERSALS_PER_ITER),
|
||
"alloc_mb" => alloc_mb,
|
||
"gc_time_s" => gc_time_s,
|
||
"gc_share" => total_s > 0.0 ? gc_time_s / total_s : 0.0,
|
||
"root_regret" => copy(reference),
|
||
)
|
||
end
|
||
|
||
function scaling_label(efficiency::Float64)::String
|
||
if efficiency >= 0.80
|
||
return "near-linear"
|
||
elseif efficiency >= 0.50
|
||
return "partial scale"
|
||
end
|
||
return "contention"
|
||
end
|
||
|
||
function main()
|
||
available = Threads.nthreads()
|
||
grid = [threads for threads in THREAD_GRID if threads <= available]
|
||
if isempty(grid)
|
||
error("no thread counts available")
|
||
end
|
||
|
||
results = [measure_case(threads) for threads in grid]
|
||
one_thread_s = results[1]["total_s"]
|
||
|
||
println("threads iter_ms total_s μs/trav alloc_MB gc_s gc_share speedup efficiency")
|
||
for result in results
|
||
threads = result["threads"]
|
||
speedup = one_thread_s / result["total_s"]
|
||
efficiency = speedup / threads
|
||
@printf(
|
||
"%-7d %8.3f %8.4f %8.3f %9.3f %5.3f %8.1f%% %8.2f× %9.0f%%\n",
|
||
threads,
|
||
result["iter_ms"],
|
||
result["total_s"],
|
||
result["traversal_us"],
|
||
result["alloc_mb"],
|
||
result["gc_time_s"],
|
||
result["gc_share"] * 100.0,
|
||
speedup,
|
||
efficiency * 100.0,
|
||
)
|
||
end
|
||
|
||
last = results[end]
|
||
speedup = one_thread_s / last["total_s"]
|
||
efficiency = speedup / last["threads"]
|
||
@printf("%dT efficiency %.0f%% — %s\n", last["threads"], efficiency * 100.0, scaling_label(efficiency))
|
||
end
|
||
|
||
main()
|