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>
256 lines
7.5 KiB
Julia
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
|