Files
coorl-lost-cities/experiments/julia_cfr_toy/bench_cfr.jl
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

256 lines
7.5 KiB
Julia

using Printf
const MAX_DEPTH = 10
const BRANCHING = 4
const HALF_DEPTH = MAX_DEPTH ÷ 2
const TRAVERSALS_PER_ITER = 1000
const MEASURE_ITERATIONS = 100
const SEED = UInt64(0x00000000013579bd)
const NUM_INTERNAL_NODES = (BRANCHING^MAX_DEPTH - 1) ÷ (BRANCHING - 1)
const MASK64 = typemax(UInt64)
const TWO_POW_53 = 9007199254740992.0
mutable struct CFRTree
regret::Vector{Float64}
end
function CFRTree()
return CFRTree(zeros(Float64, NUM_INTERNAL_NODES * BRANCHING))
end
@inline function regret_index(node_id::Int, action::Int)::Int
return (node_id - 1) * BRANCHING + action
end
@inline function splitmix64(x::UInt64)::UInt64
x += UInt64(0x9e3779b97f4a7c15)
x = (x (x >> 30)) * UInt64(0xbf58476d1ce4e5b9)
x = (x (x >> 27)) * UInt64(0x94d049bb133111eb)
return x (x >> 31)
end
@inline function hash_key(node_id::Int, depth::Int, action::Int, traversal::Int)::UInt64
x = SEED
x ⊻= UInt64(node_id) * UInt64(0xd6e8feb86659fd93)
x ⊻= UInt64(depth + 1) * UInt64(0xa5a3564e27f886d9)
x ⊻= UInt64(action + 11) * UInt64(0x9e3779b185ebca87)
x ⊻= UInt64(traversal + 17) * UInt64(0xc2b2ae3d27d4eb4f)
return splitmix64(x)
end
@inline function unit_value(key::UInt64)::Float64
bits = key >> 11
return Float64(bits) / TWO_POW_53
end
@inline function terminal_value(node_id::Int, depth::Int, action::Int, traversal::Int)::Float64
return 2.0 * unit_value(hash_key(node_id, depth, action, traversal)) - 1.0
end
@inline function is_legal(depth::Int, action::Int)::Bool
return depth < HALF_DEPTH || action != BRANCHING
end
function traverse!(tree::CFRTree, node_id::Int, depth::Int, traversal::Int)::Float64
positive_sum = 0.0
legal_count = 0
strategy1 = 0.0
strategy2 = 0.0
strategy3 = 0.0
strategy4 = 0.0
cf1 = 0.0
cf2 = 0.0
cf3 = 0.0
cf4 = 0.0
@inbounds for action in 1:BRANCHING
if is_legal(depth, action)
legal_count += 1
positive = max(tree.regret[regret_index(node_id, action)], 0.0)
positive_sum += positive
end
end
if positive_sum > 0.0
@inbounds begin
strategy1 = is_legal(depth, 1) ? max(tree.regret[regret_index(node_id, 1)], 0.0) / positive_sum : 0.0
strategy2 = is_legal(depth, 2) ? max(tree.regret[regret_index(node_id, 2)], 0.0) / positive_sum : 0.0
strategy3 = is_legal(depth, 3) ? max(tree.regret[regret_index(node_id, 3)], 0.0) / positive_sum : 0.0
strategy4 = is_legal(depth, 4) ? max(tree.regret[regret_index(node_id, 4)], 0.0) / positive_sum : 0.0
end
else
uniform = 1.0 / legal_count
strategy1 = is_legal(depth, 1) ? uniform : 0.0
strategy2 = is_legal(depth, 2) ? uniform : 0.0
strategy3 = is_legal(depth, 3) ? uniform : 0.0
strategy4 = is_legal(depth, 4) ? uniform : 0.0
end
r = unit_value(hash_key(node_id, depth, 97, traversal))
cumulative = 0.0
sampled_action = 1
cumulative += strategy1
if r <= cumulative
sampled_action = 1
else
cumulative += strategy2
if r <= cumulative
sampled_action = 2
else
cumulative += strategy3
if r <= cumulative
sampled_action = 3
else
sampled_action = 4
end
end
end
sampled_value = 0.0
child_id = (node_id - 1) * BRANCHING + sampled_action + 1
if depth + 1 >= MAX_DEPTH
sampled_value = terminal_value(node_id, depth, sampled_action, traversal)
else
sampled_value = traverse!(tree, child_id, depth + 1, traversal)
end
cf1 = if !is_legal(depth, 1)
0.0
elseif sampled_action == 1
sampled_value
else
terminal_value(node_id, depth, 1, traversal)
end
cf2 = if !is_legal(depth, 2)
0.0
elseif sampled_action == 2
sampled_value
else
terminal_value(node_id, depth, 2, traversal)
end
cf3 = if !is_legal(depth, 3)
0.0
elseif sampled_action == 3
sampled_value
else
terminal_value(node_id, depth, 3, traversal)
end
cf4 = if !is_legal(depth, 4)
0.0
elseif sampled_action == 4
sampled_value
else
terminal_value(node_id, depth, 4, traversal)
end
@inbounds begin
if is_legal(depth, 1)
tree.regret[regret_index(node_id, 1)] += cf1 - sampled_value
end
if is_legal(depth, 2)
tree.regret[regret_index(node_id, 2)] += cf2 - sampled_value
end
if is_legal(depth, 3)
tree.regret[regret_index(node_id, 3)] += cf3 - sampled_value
end
if is_legal(depth, 4)
tree.regret[regret_index(node_id, 4)] += cf4 - sampled_value
end
end
return strategy1 * cf1 + strategy2 * cf2 + strategy3 * cf3 + strategy4 * cf4
end
function run_iteration!(tree::CFRTree, iteration::Int)
base = (iteration - 1) * TRAVERSALS_PER_ITER
value = 0.0
for offset in 1:TRAVERSALS_PER_ITER
value += traverse!(tree, 1, 0, base + offset)
end
return value
end
function run_benchmark()
warmup_tree = CFRTree()
run_iteration!(warmup_tree, 1)
GC.gc()
tree = CFRTree()
gc_before = Base.gc_num()
elapsed_ref = Ref(0.0)
allocated = @allocated begin
elapsed_ref[] = @elapsed begin
for iteration in 1:MEASURE_ITERATIONS
run_iteration!(tree, iteration)
end
end
end
gc_after = Base.gc_num()
gc_time = (gc_after.total_time - gc_before.total_time) / 1e9
total = elapsed_ref[]
alloc_mb = allocated / 1024.0 / 1024.0
iter_ms = total * 1000.0 / MEASURE_ITERATIONS
traversal_us = total * 1_000_000.0 / (MEASURE_ITERATIONS * TRAVERSALS_PER_ITER)
root_regret = [
tree.regret[regret_index(1, 1)],
tree.regret[regret_index(1, 2)],
tree.regret[regret_index(1, 3)],
tree.regret[regret_index(1, 4)],
]
return Dict(
"lang" => "Julia",
"iterations" => MEASURE_ITERATIONS,
"traversals_per_iter" => TRAVERSALS_PER_ITER,
"total_s" => total,
"iter_ms" => iter_ms,
"traversal_us" => traversal_us,
"alloc_mb" => alloc_mb,
"gc_time_s" => gc_time,
"gc_share" => total > 0.0 ? gc_time / total : 0.0,
"root_regret" => root_regret,
)
end
function print_json(result)
@printf(
"{\"lang\":\"Julia\",\"iterations\":%d,\"traversals_per_iter\":%d,\"total_s\":%.17g,\"iter_ms\":%.17g,\"traversal_us\":%.17g,\"alloc_mb\":%.17g,\"gc_time_s\":%.17g,\"gc_share\":%.17g,\"root_regret\":[%.17g,%.17g,%.17g,%.17g]}\n",
result["iterations"],
result["traversals_per_iter"],
result["total_s"],
result["iter_ms"],
result["traversal_us"],
result["alloc_mb"],
result["gc_time_s"],
result["gc_share"],
result["root_regret"]...,
)
end
function print_table(result)
println("Lang iter mean (ms) total (s) alloc (MB) gc time (s) gc share")
@printf(
"%-8s %14.2f %10.2f %11.1f %12.2f %8.1f%%\n",
result["lang"],
result["iter_ms"],
result["total_s"],
result["alloc_mb"],
result["gc_time_s"],
result["gc_share"] * 100.0,
)
@printf("mean traversal: %.2f μs\n", result["traversal_us"])
@printf("root regret: [%.12f, %.12f, %.12f, %.12f]\n", result["root_regret"]...)
end
function main()
result = run_benchmark()
if "--json" in ARGS
print_json(result)
else
print_table(result)
end
end
if abspath(PROGRAM_FILE) == @__FILE__
main()
end