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

157 lines
5.4 KiB
Cython

# cython: boundscheck=False, wraparound=False, initializedcheck=False, cdivision=True, language_level=3
import numpy as np
cimport numpy as cnp
ctypedef unsigned long long uint64_t
cdef int MAX_DEPTH = 10
cdef int BRANCHING = 4
cdef int HALF_DEPTH = 5
cdef int TRAVERSALS_PER_ITER = 1000
cdef int MEASURE_ITERATIONS = 100
cdef int NUM_INTERNAL_NODES = 349525
cdef uint64_t SEED = <uint64_t>0x00000000013579bd
cdef double TWO_POW_53 = 9007199254740992.0
cdef inline uint64_t splitmix64(uint64_t x) noexcept nogil:
x = x + <uint64_t>0x9e3779b97f4a7c15
x = (x ^ (x >> 30)) * <uint64_t>0xbf58476d1ce4e5b9
x = (x ^ (x >> 27)) * <uint64_t>0x94d049bb133111eb
return x ^ (x >> 31)
cdef inline uint64_t hash_key(int node_id, int depth, int action, int traversal) noexcept nogil:
cdef uint64_t x = SEED
x = x ^ (<uint64_t>node_id * <uint64_t>0xd6e8feb86659fd93)
x = x ^ (<uint64_t>(depth + 1) * <uint64_t>0xa5a3564e27f886d9)
x = x ^ (<uint64_t>(action + 11) * <uint64_t>0x9e3779b185ebca87)
x = x ^ (<uint64_t>(traversal + 17) * <uint64_t>0xc2b2ae3d27d4eb4f)
return splitmix64(x)
cdef inline double unit_value(uint64_t key) noexcept nogil:
return <double>(key >> 11) / TWO_POW_53
cdef inline double terminal_value(int node_id, int depth, int action, int traversal) noexcept nogil:
return 2.0 * unit_value(hash_key(node_id, depth, action, traversal)) - 1.0
cdef inline bint is_legal(int depth, int action) noexcept nogil:
return depth < HALF_DEPTH or action != BRANCHING
cdef class CFRTree:
cdef cnp.ndarray regret_arr
cdef double[:, ::1] regret
def __cinit__(self):
self.regret_arr = np.zeros((NUM_INTERNAL_NODES, BRANCHING), dtype=np.float64)
self.regret = self.regret_arr
cdef double traverse(self, int node_id, int depth, int traversal) noexcept nogil:
cdef double positive_sum = 0.0
cdef int legal_count = 0
cdef int action
cdef int sampled_action = 1
cdef int child_id
cdef double positive
cdef double uniform
cdef double cumulative = 0.0
cdef double r
cdef double sampled_value
cdef double expected = 0.0
cdef double strategy[4]
cdef double counterfactual[4]
for action in range(1, BRANCHING + 1):
strategy[action - 1] = 0.0
counterfactual[action - 1] = 0.0
if is_legal(depth, action):
legal_count += 1
positive = self.regret[node_id - 1, action - 1]
if positive < 0.0:
positive = 0.0
positive_sum += positive
if positive_sum > 0.0:
for action in range(1, BRANCHING + 1):
if is_legal(depth, action):
positive = self.regret[node_id - 1, action - 1]
if positive < 0.0:
positive = 0.0
strategy[action - 1] = positive / positive_sum
else:
uniform = 1.0 / legal_count
for action in range(1, BRANCHING + 1):
if is_legal(depth, action):
strategy[action - 1] = uniform
r = unit_value(hash_key(node_id, depth, 97, traversal))
for action in range(1, BRANCHING + 1):
cumulative += strategy[action - 1]
if r <= cumulative:
sampled_action = action
break
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 = self.traverse(child_id, depth + 1, traversal)
for action in range(1, BRANCHING + 1):
if is_legal(depth, action):
if action == sampled_action:
counterfactual[action - 1] = sampled_value
else:
counterfactual[action - 1] = terminal_value(node_id, depth, action, traversal)
for action in range(1, BRANCHING + 1):
if is_legal(depth, action):
self.regret[node_id - 1, action - 1] += counterfactual[action - 1] - sampled_value
for action in range(1, BRANCHING + 1):
expected += strategy[action - 1] * counterfactual[action - 1]
return expected
cdef double run_iteration(self, int iteration) noexcept nogil:
cdef int base = (iteration - 1) * TRAVERSALS_PER_ITER
cdef int offset
cdef double value = 0.0
for offset in range(1, TRAVERSALS_PER_ITER + 1):
value += self.traverse(1, 0, base + offset)
return value
cpdef run_iterations(self, int iterations):
cdef int iteration
cdef double total_value = 0.0
with nogil:
for iteration in range(1, iterations + 1):
total_value += self.run_iteration(iteration)
return total_value
cpdef root_regret(self):
return [
float(self.regret[0, 0]),
float(self.regret[0, 1]),
float(self.regret[0, 2]),
float(self.regret[0, 3]),
]
def run_benchmark():
warmup_tree = CFRTree()
warmup_tree.run_iterations(1)
tree = CFRTree()
tree.run_iterations(MEASURE_ITERATIONS)
return {
"lang": "Cython",
"iterations": MEASURE_ITERATIONS,
"traversals_per_iter": TRAVERSALS_PER_ITER,
"root_regret": tree.root_regret(),
}