Files
coorl-lost-cities/experiments/julia_cfr_toy/bench_cfr_threaded.jl
T
coolguyandClaude Opus 4.7 ce9c6f6b93 Add Julia CFR-shape toy benchmark + Julia port evaluation thread
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>
2026-05-07 21:04:15 +09:00

167 lines
4.9 KiB
Julia
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()