Skip to content

perf(engine): vector-form flop forward walk with double chance compaction (v1.10 PR-3, #70)#198

Open
amaster97 wants to merge 2 commits into
mainfrom
feat-v1-10-3-vector-flop-impl
Open

perf(engine): vector-form flop forward walk with double chance compaction (v1.10 PR-3, #70)#198
amaster97 wants to merge 2 commits into
mainfrom
feat-v1-10-3-vector-flop-impl

Conversation

@amaster97

Copy link
Copy Markdown
Owner

Summary

Implements v1.10 PR-3 of the postflop optimization plan: extend PR-2's ChanceTemplate extraction to two-level chance compaction. The flop-rooted RvR's outer chance node (turn deal) now dispatches to a specialized traverse_flop_chance_recursive walker that pre-allocates scratch buffers in RunoutCache instead of allocating fresh vec![0.0; update_hands] per call.

Status: HOLD for user review per task brief (perf gate misses; correctness gates pass strict bit-identity).

Base branch: This PR targets feat-v1-10-2-vector-turn (PR #190) since PR-2's traverse_turn_chance_recursive is the prerequisite. PR-2 needs to merge first.

Key changes

  • ChanceTemplate.chance_depth: u8 — records max nested-chance depth below each template. Computed at extraction time via DFS in BettingTree::max_nested_chance_depth.
  • RunoutCache struct with build / empty / is_active. Pre-allocates outer scratch buffer per VectorDCFR::solve, reused across all iterations. Inactive (no allocation) for turn/river-rooted solves or Standard build mode.
  • traverse_flop_chance_recursive walks turn-card children in DFS order matching the legacy chance arm exactly, but uses runout_cache.flop_values_scratch for the outer accumulator.
  • Dispatch in traverse_recursive_with_parallel: rayon (if enabled + multi-child) → PR-3 flop walker (depth>=1 + active cache) → PR-2 turn walker (any template) → legacy chance loop.
  • traverse_with_infosets (rayon worker entry) constructs its own empty RunoutCache locally since workers run below the flop chance.
  • CFR_VECTOR_FLOP_TEMPLATE=0 env var forces Standard build mode for diff-test baselines (canonical legacy path).

Bit-identity contract

Preserved: arithmetic + DFS order match the legacy chance arm exactly; the only behavioral change is scratch-buffer provenance (pre-allocated pool vs fresh vec!). F4_synth diff test shows byte-identical strategy output (max_diff=0.000e+00) vs canonical at 1e-12 tolerance.

Test plan

  • All 3 fast PR-3 Python diff tests pass at 1e-12 strict bit-identity
  • All 3 new Rust unit tests pass (template_extract_assigns_correct_chance_depths, runout_cache_active_only_on_flop_root, flop_template_extract_bit_identical_to_standard)
  • Rayon compose test passes at 1e-12 byte-identity across all three modes (canonical / PR-3 / PR-3+rayon)
  • All 94 existing cfr_core lib tests still pass
  • All 4 existing tests/test_range_vs_range_rust_diff.py pass
  • 8/9 tests/test_vector_rayon_diff.py pass (1 timeout in unrelated exploit_walk; pre-existing)
  • cargo clippy --all-targets -- -D warnings clean
  • HOLD: F4.1/F4.2/F4.3 8-class flop diff tests marked @pytest.mark.slow — too slow for tight loop, deferred to full PR gate.

Perf measurement (J7o A♦8♥9♦ 40-BB flop, top_k=4 iter=5)

Path Wall n_keys
PR-3 (default) 151.9 s 19,066,428
Canonical (legacy) 265.5 s 19,066,428

Speedup: 1.75x. The fixture was OOM-killed at 5+ min on main per docs/flop_subgame_perf_measurement_2026-05-28.md; PR-3 unblocks completion.

Perf gates NOT met (the HOLD reason)

The headline perf gates from the task brief require PR-1 (arena, separate PR) to land first:

  • top_k=15 iter=100 wall <60s — NOT MET (Decision-arm allocations dominate)
  • top_k=169 iter=100 wall <120s — NOT MET (same reason)
  • Peak RSS <1 GB at top_k=169 — NOT MET (RSS unchanged at ~3 GB; PR-3 saves outer chance accumulator only)

Per the v1.10 plan flop subgame profile (docs/flop_subgame_profile_baseline_2026-05-28.md section 2), the dominant per-iteration allocation traffic is in the Decision arm (dcfr_vector.rs:661 strategy + dcfr_vector.rs:741 action_values, ~99 MB/iter combined). PR-3 only targets the chance-arm outer accumulator (~2 MB/iter). The Decision-arm refactor is PR-1's scope.

DFS-order preservation verified

The bit-identity contract is the load-bearing correctness gate. PR-3 preserves DFS order by:

  1. Iterating flop_chance_children in source index order (matches legacy).
  2. Inner recursion delegates back to traverse_recursive_with_parallel, preserving the entire turn-subtree DFS shape.
  3. Same values[h] += prob * v[h] arithmetic in the same loop nest order as legacy.

The F4_synth fixture (board=2c3d4h, 4 hand classes, 1 bet size, raise_cap=1) is small enough to enumerate analytically — any DFS-order drift produces visible (>1e-6) strategy differences. Observed max_diff=0.000e+00 confirms byte-identical IEEE-754 summation across all 10 iterations.

Files touched

  • crates/cfr_core/src/dcfr_vector.rs (+750/-90)
  • crates/cfr_core/src/exploit.rs (+115/-2)
  • tests/test_v1_10_3_flop_diff.py (rewrite: skip markers removed, real implementations added)

References

  • docs/v1_10_pr3_flop_vector_design.md — design + scaffold (commit 6f5b52b)
  • docs/v1_10_postflop_optimization_plan.md §2 Candidate C
  • docs/flop_subgame_profile_baseline_2026-05-28.md — allocation hot-path measurement
  • docs/w2_3_vector_br_walk_test_fixtures.md — F4_synth fixture spec
  • PR perf(engine): vector-form turn forward walk with river template (v1.10 PR-2, #70) #190 (v1.10 PR-2 turn walker) — prerequisite, must merge first

🤖 Generated with Claude Code

amaster97 and others added 2 commits May 28, 2026 16:13
…0 PR-2, #70)

Lands the FRAMEWORK for vector-form turn forward walk per
docs/v1_10_postflop_optimization_plan.md §2B (Candidate B):
adds chance-template extraction at tree-build time and a
specialized chance-node dispatch hook on VectorDCFR.

Changes
-------
- exploit.rs: add BettingTreeMode enum (Standard / TemplateExtract)
  and BettingTree::build_with_mode. TemplateExtract additionally
  populates BettingTree::chance_templates with one entry per
  FlatNode::Chance whose children share structural identity (verified
  via a per-subtree structure_hash). The FlatNode list itself is
  unchanged — chance_templates is purely out-of-band metadata.
- dcfr_vector.rs: add has_chance_template[] lookup on VectorDCFR
  (one bool per FlatNode index), populated from tree.chance_templates
  in with_init_noise. The chance arm of traverse() now dispatches to
  the new traverse_turn_chance specialized walker when the template
  flag is set, else falls through to the legacy per-branch recursion.
- solve_range_vs_range_postflop_with_hands: choose TemplateExtract
  mode for Turn/Flop starting streets, Standard for River.

Bit-identical gate (load-bearing)
---------------------------------
The traverse_turn_chance walker performs the EXACT same arithmetic
as the legacy chance arm: same DFS order over children, same
`values[i] += prob * v` accumulator update. The IEEE 754 summation
order is preserved. New unit tests verify this:
- template_extract_finds_turn_chance_node — TemplateExtract mode
  populates chance_templates; Standard does not; FlatNode list is
  identical between modes.
- template_extract_bit_identical_to_standard — solve outputs at
  1e-12 strategy tolerance match byte-for-byte between modes.
- river_solve_does_not_template_extract — negative control: river
  solve falls through to legacy path bit-identically.

Existing diff tests (39+ across exploit / range_vs_range / w2_2 /
aa_vs_aa / dcfr / leduc / true_path_b / asymmetric) all pass.
All 70 lib::tests pass (1 ignored, 24 filtered).

Perf framing
------------
PR-2 ships the framework only. Because traverse_turn_chance does the
same arithmetic as the legacy chance arm (the only thing changed is
the dispatch route), measured wall-clock is identical to main within
noise (verified by 3×-replicated quick_bench runs on tiny/med
fixtures). The actual perf win for turn comes when PR-3 (vector
flop) extends template extraction to double-broadcast scratch
buffers across the 45 river-card branches.

No reordering of operations was performed in PR-2 because IEEE 754
summation order is not associative — hoisting the `prob` multiply
out of the inner accumulator loop would break bit-identity for
non-uniform chance probabilities (1/45 is not exactly representable).
PR-3 will introduce template-broadcasting scratch buffers without
changing arithmetic.

Touched files
-------------
- crates/cfr_core/src/exploit.rs   (+166 LOC; BettingTreeMode,
  ChanceTemplate, build_with_mode, extract_chance_templates,
  structure_hash, hash_subtree)
- crates/cfr_core/src/dcfr_vector.rs (+345 LOC; has_chance_template
  field, dispatch logic in chance arm, traverse_turn_chance,
  3 unit tests)
- scripts/v1_10_2_turn_bench.py    (new; perf measurement driver)
- scripts/v1_10_2_quick_bench.py   (new; small-fixture timing
  comparison)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion (v1.10 PR-3, #70)

Implement PR-3 of the v1.10 postflop optimization plan: extend PR-2's
ChanceTemplate extraction to two-level chance compaction. The
flop-rooted RvR's outer chance node (turn deal) now dispatches to a
specialized traverse_flop_chance_recursive walker that pre-allocates
scratch buffers in RunoutCache instead of allocating fresh
vec![0.0; update_hands] per call.

Key changes:
- ChanceTemplate gains chance_depth: u8 recording max nested-chance
  depth below each template. Computed at extraction time via DFS.
- RunoutCache struct with build/empty/is_active. Pre-allocates one
  outer scratch buffer per VectorDCFR::solve call, reused across all
  iterations. Inactive (no allocation) for turn/river-rooted solves
  or Standard build mode.
- traverse_flop_chance_recursive walks turn-card children in DFS
  order matching the legacy chance arm exactly, but uses
  runout_cache.flop_values_scratch for the outer accumulator.
- Dispatch in traverse_recursive_with_parallel: rayon (if enabled +
  multi-child) -> PR-3 flop walker (depth>=1 + active cache) ->
  PR-2 turn walker (any template) -> legacy chance loop.
- traverse_with_infosets (rayon worker entry) constructs its own
  empty RunoutCache locally since workers run below the flop chance.
- CFR_VECTOR_FLOP_TEMPLATE=0 env var forces Standard build mode for
  diff-test baselines (canonical legacy path).

Bit-identity contract preserved: arithmetic + DFS order match the
legacy chance arm exactly; the only behavioral change is scratch-buffer
provenance (pre-allocated pool vs fresh vec!). F4_synth diff test
shows byte-identical strategy output (max_diff=0.000e+00 vs canonical).

Tests:
- 3 new Rust unit tests (template_extract_assigns_correct_chance_depths,
  runout_cache_active_only_on_flop_root,
  flop_template_extract_bit_identical_to_standard).
- 6 new Python diff tests in tests/test_v1_10_3_flop_diff.py
  (skip markers removed, real implementations added). 3 fast tests
  pass at 1e-12 strict bit-identity (F4_synth + turn/river
  non-regression); 3 slow tests (F4.1/F4.2/F4.3 8-class flop) marked
  with @pytest.mark.slow.
- Rayon compose test passes at 1e-12 byte-identity across all three
  modes (canonical / PR-3 / PR-3+rayon).
- All 94 existing cfr_core lib tests still pass.

Perf (J7o flop, top_k=4 iter=5):
- PR-3:       151.9s wall (was OOM>5min on main)
- Canonical: 265.5s wall
- Speedup:    1.75x

The headline perf gates (top_k=15 iter=100 <60s, top_k=169 iter=100
<120s, RSS <1GB) are NOT met by PR-3 alone -- they require PR-1
(arena, separate PR) to refactor the Decision-arm allocations that
dominate per-iteration memory churn. PR-3 addresses the outer
chance-arm allocation pattern; PR-1 will compose with it.

Per task brief: HOLD for user review since perf gate misses; all
correctness gates pass strict bit-identity.

Refs: docs/v1_10_pr3_flop_vector_design.md, docs/v1_10_postflop_optimization_plan.md sec 2 Candidate C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@amaster97 amaster97 changed the base branch from feat-v1-10-2-vector-turn to main May 28, 2026 22:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant