diff --git a/docs/research/julia_port_evaluation.md b/docs/research/julia_port_evaluation.md new file mode 100644 index 0000000..9d003ef --- /dev/null +++ b/docs/research/julia_port_evaluation.md @@ -0,0 +1,108 @@ +# Julia Port Evaluation + +Tracks evidence for and against porting the Deep CFR training pipeline +from Python/Cython to Julia. Plot/game/parity stays Python regardless; +the candidate scope is the training stack (traversal, networks, +inference). + +## Why we are even considering this + +`docs/performance.md` "Option A Bench Result and Structural Ceiling" +established that the current sync-blocking traversal in Python +multiprocessing caps realized batch size at `num_workers`. Escaping +that ceiling requires either restructuring traversal (Option B/C) or +moving to a runtime where threads can carry many concurrent traversals +in one process. Julia is the most credible candidate for the latter +(Mojo too immature, free-threaded CPython requires nogil-cleaning our +existing Cython — see `docs/reports/cost_*` triplet). + +Decision criteria for going forward: + +1. Single-thread compute parity with current Cython (or better). +2. GC behavior under tight CFR-shape recursion is acceptable (low + pause time, low share). +3. Multi-thread scaling on the same CFR-shape pattern is near-linear, + demonstrating the GIL-free promise actually holds. +4. ML stack (Flux.jl + CUDA.jl) covers our needs (MLP forward, AD, + GPU). Our model is small and standard. +5. A real-game-state slice can be ported and compared head-to-head. + +## Evidence so far + +### 2026-05-07 — Safe heuristic single-thread parity (criterion 1) + +Path: `experiments/julia_safe_heuristic/`. + +1,838 snapshots in 157.471 ms median (~85.6 μs/call). Action-sequence +parity vs Python. Same order of magnitude as the Cython port of the +same bot (Cython gives ~2.55× over original Python on a 200-game +eval). + +**Verdict on criterion 1:** Pass for isolated single-call work. + +### 2026-05-07 — CFR-shape recursion toy (criteria 1, 2) + +Path: `experiments/julia_cfr_toy/`. See that directory's README for +the full table. + +Headline: Julia ~1.9× faster than Cython, 0 MB allocation, 0% GC time +on the hot path. Root regret parity ε ≤ 1e-9. + +**Verdict on criterion 1:** Pass. Julia matches or beats Cython on +the CFR-shape pattern. + +**Verdict on criterion 2:** Strong pass. Type-stable code produces +zero heap traffic. The GC concern that was the main argument against +Julia adoption did not materialize here. + +Caveats: Cython 21.3 MB alloc suggests room for tighter typing; +best-effort Cython could narrow the gap. Toy is not a game. + +### 2026-05-07 — CFR-shape multi-thread scaling on the same toy (criterion 3) + +Path: `experiments/julia_cfr_toy/` (`bench_cfr_threaded.jl`). Same +100 iters × 1000 traversals total work split across threads. Thread- +local trees, root regrets reduced at the end. + +| threads | iter ms | speedup | efficiency | +| ---: | ---: | ---: | ---: | +| 1 | 0.219 | 1.00× | 100% | +| 2 | 0.129 | 1.69× | 85% | +| 4 | 0.096 | 2.28× | 57% | +| 8 | 0.090 | 2.44× | 31% | + +**Verdict on criterion 3:** Partial / inconclusive. 8T efficiency is +31% — contention or dispatch overhead dominates at this toy's small +per-thread workload. Julia does improve absolute throughput (2.44× +wall-clock at 8T vs 1T), but not near-linearly. The next scaling test +must increase per-thread work (larger tree or more traversals per +chunk) before a hard conclusion can be drawn. Do not treat this as a +hard ceiling — the toy may simply be too small for 8 threads to amortize +dispatch cost. + +## Open evidence (criteria 3, 4, 5) + +- **Multi-thread scaling with heavier per-thread work.** Increase tree + depth or traversals-per-chunk and re-run the 1/2/4/8 thread sweep. + Current 31% at 8T is likely a toy-size artifact, not a Julia limit. + This remains the decisive test for the porting decision. +- **Flux.jl + CUDA.jl MLP forward at bs={1, 64, 256}.** Compare to + PyTorch numbers in `docs/performance.md`. Criterion 4. Not started. +- **Real-game-state slice port.** Port `play_card` + scoring, run on a + fixed corpus of game states, compare to current Cython. Criterion 5. + Not started. + +## Decision posture + +Strong but not yet sufficient. The three completed benchmarks remove the +main risk (GC under recursion) and confirm compute parity. Multi-thread +scaling shows 2.44× wall-clock at 8T but only 31% efficiency — the toy +workload is likely too small per thread to amortize dispatch costs, so +the result is inconclusive rather than negative. A heavier per-thread +workload must be tested before criterion 3 can be marked pass or fail. + +After criterion 3 is settled, ML-stack and game-state evidence (criteria +4, 5) determine whether to start a serious port plan. + +Do not commit to porting until criterion 3 is conclusively settled with +appropriate per-thread workload. diff --git a/experiments/julia_cfr_toy/.gitignore b/experiments/julia_cfr_toy/.gitignore new file mode 100644 index 0000000..1ed2274 --- /dev/null +++ b/experiments/julia_cfr_toy/.gitignore @@ -0,0 +1,3 @@ +bench_cfr.c +bench_cfr*.so +build/ diff --git a/experiments/julia_cfr_toy/README.md b/experiments/julia_cfr_toy/README.md new file mode 100644 index 0000000..7dfdec0 --- /dev/null +++ b/experiments/julia_cfr_toy/README.md @@ -0,0 +1,79 @@ +# Julia CFR Toy Benchmark + +This experiment compares Julia and Cython on a CFR-shaped hot path: recursive +tree traversal, mutable regret state, branch-heavy legal-action logic, and +counterfactual updates. The toy tree is not a game. + +Run Julia only: + +```bash +tools/julia/current/bin/julia experiments/julia_cfr_toy/bench_cfr.jl +``` + +Run Cython plus Julia/Cython parity and comparison: + +```bash +uv run python experiments/julia_cfr_toy/bench_cfr_runner.py +``` + +Run Julia thread-local scaling: + +```bash +tools/julia/current/bin/julia --threads=8 experiments/julia_cfr_toy/bench_cfr_threaded.jl +``` + +The Python runner builds `bench_cfr.pyx` in place when needed, runs the Julia +benchmark in JSON mode, then aborts if the final root regret vectors differ by +more than `1e-9`. + +Interpretation: + +- `ratio < 1.0`: Julia is faster than Cython for this toy pattern. +- `ratio > 1.0`: Julia is slower than Cython for this toy pattern. +- Julia `gc share` near zero means mutable-state recursion is not creating + meaningful garbage in this benchmark. + +## Results (2026-05-07) + +Single run on the host's Julia 1.11.9 + Cython build. 100 iterations × 1000 traversals. + +| Lang | iter mean (ms) | total (s) | alloc (MB) | gc time (s) | gc share | +| --- | ---: | ---: | ---: | ---: | ---: | +| Julia | 0.21 | 0.02 | 0.0 | 0.00 | 0.0% | +| Cython | 0.41 | 0.04 | 21.3 | n/a | - | +| ratio | 0.53× | - | - | - | - | + +Root regret parity verified to ε ≤ 1e-9. + +**Interpretation:** Julia ~1.9× faster than this Cython implementation on the +CFR-shape hot path (recursive traversal + mutable regret state + +branch-heavy legal-action logic). Zero allocation, zero GC time on the +Julia side — escape analysis eliminates heap traffic when the code is +type-stable. The GC-pause concern that has been the main argument +against Julia adoption is not realized in this pattern. + +**Caveats:** +- Cython 21.3 MB alloc suggests room for a more aggressively typed + implementation (memoryviews end-to-end). A best-effort Cython could + narrow the gap to roughly 1.3×–1.9×. +- Toy is not a game. Real Lost Cities CFR has larger state, replay + buffer interactions, and an existing Cython implementation already + optimized over time. +- Single seed, single run. Variance unmeasured. +## Thread Scaling (2026-05-07) + +Thread-local trees, same 100 iterations × 1000 traversals total work. Each +thread processes its own chunk, then root regrets are reduced. Each threaded +case is checked against a sequential run with the same chunking. + +| threads | iter ms | total s | μs/trav | alloc MB | gc s | gc share | speedup | efficiency | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 0.219 | 0.0219 | 0.219 | 0.000 | 0.000 | 0.0% | 1.00× | 100% | +| 2 | 0.129 | 0.0129 | 0.129 | 0.004 | 0.000 | 0.0% | 1.69× | 85% | +| 4 | 0.096 | 0.0096 | 0.096 | 0.004 | 0.000 | 0.0% | 2.28× | 57% | +| 8 | 0.090 | 0.0090 | 0.090 | 0.004 | 0.000 | 0.0% | 2.44× | 31% | + +**Interpretation:** 8T efficiency 31% — contention/dispatch overhead dominates +at this tiny per-thread workload. Julia still improves absolute throughput, but +this is not near-linear scaling. The next scaling test should increase per-thread +work before treating this as a hard limit. diff --git a/experiments/julia_cfr_toy/bench_cfr.jl b/experiments/julia_cfr_toy/bench_cfr.jl new file mode 100644 index 0000000..6cd09a1 --- /dev/null +++ b/experiments/julia_cfr_toy/bench_cfr.jl @@ -0,0 +1,255 @@ +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 diff --git a/experiments/julia_cfr_toy/bench_cfr.pyx b/experiments/julia_cfr_toy/bench_cfr.pyx new file mode 100644 index 0000000..44ba6d9 --- /dev/null +++ b/experiments/julia_cfr_toy/bench_cfr.pyx @@ -0,0 +1,156 @@ +# 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 = 0x00000000013579bd +cdef double TWO_POW_53 = 9007199254740992.0 + + +cdef inline uint64_t splitmix64(uint64_t x) noexcept nogil: + x = x + 0x9e3779b97f4a7c15 + x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9 + x = (x ^ (x >> 27)) * 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 ^ (node_id * 0xd6e8feb86659fd93) + x = x ^ ((depth + 1) * 0xa5a3564e27f886d9) + x = x ^ ((action + 11) * 0x9e3779b185ebca87) + x = x ^ ((traversal + 17) * 0xc2b2ae3d27d4eb4f) + return splitmix64(x) + + +cdef inline double unit_value(uint64_t key) noexcept nogil: + return (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(), + } diff --git a/experiments/julia_cfr_toy/bench_cfr_runner.py b/experiments/julia_cfr_toy/bench_cfr_runner.py new file mode 100644 index 0000000..a6cecb7 --- /dev/null +++ b/experiments/julia_cfr_toy/bench_cfr_runner.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import gc +import importlib +import json +import os +import subprocess +import sys +import time +import tracemalloc +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent +REPO_ROOT = ROOT.parents[1] +ITERATIONS = 100 +TRAVERSALS_PER_ITER = 1000 +EPSILON = 1e-9 + + +def _build_extension() -> None: + subprocess.run( + [sys.executable, "bench_cfr_setup.py", "build_ext", "--inplace"], + cwd=ROOT, + check=True, + ) + + +def _import_extension(): + sys.path.insert(0, str(ROOT)) + source_mtime = (ROOT / "bench_cfr.pyx").stat().st_mtime + extensions = list(ROOT.glob("bench_cfr*.so")) + if not extensions or max(path.stat().st_mtime for path in extensions) < source_mtime: + _build_extension() + try: + return importlib.import_module("bench_cfr") + except ImportError: + _build_extension() + importlib.invalidate_caches() + return importlib.import_module("bench_cfr") + + +def _julia_executable() -> str: + local = REPO_ROOT / "tools" / "julia" / "current" / "bin" / "julia" + if local.exists(): + return str(local) + return "julia" + + +def _run_julia() -> dict[str, Any]: + proc = subprocess.run( + [_julia_executable(), str(ROOT / "bench_cfr.jl"), "--json"], + cwd=REPO_ROOT, + check=True, + text=True, + capture_output=True, + ) + return json.loads(proc.stdout) + + +def _run_cython() -> dict[str, Any]: + bench_cfr = _import_extension() + gc.collect() + tracemalloc.start() + start = time.perf_counter() + result = bench_cfr.run_benchmark() + total_s = time.perf_counter() - start + current, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + + result["total_s"] = total_s + result["iter_ms"] = total_s * 1000.0 / ITERATIONS + result["traversal_us"] = total_s * 1_000_000.0 / (ITERATIONS * TRAVERSALS_PER_ITER) + result["alloc_mb"] = peak / 1024.0 / 1024.0 + result["gc_time_s"] = None + result["gc_share"] = None + return result + + +def _assert_equivalent(julia: dict[str, Any], cython: dict[str, Any]) -> None: + diffs = [ + abs(float(j_value) - float(c_value)) + for j_value, c_value in zip(julia["root_regret"], cython["root_regret"], strict=True) + ] + max_diff = max(diffs) + if max_diff > EPSILON: + raise SystemExit( + "root regret mismatch: " + f"max_diff={max_diff:.3e} " + f"julia={julia['root_regret']} cython={cython['root_regret']}" + ) + + +def _print_table(julia: dict[str, Any], cython: dict[str, Any]) -> None: + ratio = julia["iter_ms"] / cython["iter_ms"] + gc_share = julia["gc_share"] * 100.0 + print("Lang iter mean (ms) total (s) alloc (MB) gc time (s) gc share") + print( + f"{'Julia':<8} {julia['iter_ms']:14.2f} {julia['total_s']:10.2f} " + f"{julia['alloc_mb']:11.1f} {julia['gc_time_s']:12.2f} {gc_share:8.1f}%" + ) + print( + f"{'Cython':<8} {cython['iter_ms']:14.2f} {cython['total_s']:10.2f} " + f"{cython['alloc_mb']:11.1f} {'n/a':>12} {'-':>8}" + ) + print(f"{'ratio':<8} {ratio:13.2f}x {'-':>10} {'-':>11} {'-':>12} {'-':>8}") + + if ratio < 0.95: + verdict = "Julia faster" + elif ratio > 1.05: + verdict = "Julia slower" + else: + verdict = "rough parity" + gc_note = "GC negligible" if gc_share < 5.0 else "GC visible" + print(f"Julia/Cython iter ratio {ratio:.2f}x — {verdict}, {gc_note} ({gc_share:.1f}% GC)") + + +def main() -> None: + os.environ.setdefault("JULIA_NUM_THREADS", "1") + julia = _run_julia() + cython = _run_cython() + _assert_equivalent(julia, cython) + _print_table(julia, cython) + + +if __name__ == "__main__": + main() diff --git a/experiments/julia_cfr_toy/bench_cfr_setup.py b/experiments/julia_cfr_toy/bench_cfr_setup.py new file mode 100644 index 0000000..778c3af --- /dev/null +++ b/experiments/julia_cfr_toy/bench_cfr_setup.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +from Cython.Build import cythonize +from setuptools import Extension, setup + +ROOT = Path(__file__).resolve().parent + +setup( + name="bench_cfr", + ext_modules=cythonize( + [ + Extension( + "bench_cfr", + [str(ROOT / "bench_cfr.pyx")], + include_dirs=[np.get_include()], + ) + ], + compiler_directives={"language_level": "3"}, + ), +) diff --git a/experiments/julia_cfr_toy/bench_cfr_threaded.jl b/experiments/julia_cfr_toy/bench_cfr_threaded.jl new file mode 100644 index 0000000..9601bbc --- /dev/null +++ b/experiments/julia_cfr_toy/bench_cfr_threaded.jl @@ -0,0 +1,166 @@ +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() diff --git a/ideas.md b/ideas.md index ba2a5b8..b2614cb 100644 --- a/ideas.md +++ b/ideas.md @@ -3,6 +3,7 @@ ## Active Research Threads - [Lost Cities selectivity 가설 / 4-model 분석](docs/research/lost_cities_selectivity.md) +- [Julia 포팅 검토 — 단일/멀티스레드/ML 평가](docs/research/julia_port_evaluation.md) ## 진단 가설들