Repository navigation
perf(expert): the verify window's GPU plan in O(n) instead of O(n^2) - #1181
Closed
ZhongUncle wants to merge 1 commit into
Closed
ZhongUncle wants to merge 1 commit into
ZhongUncle wants to merge 1 commit into
Conversation
expert_pool_dispatch_multi published the window's plan (VRAM groups, the PCIe share, the per-entry kinds) with a pairwise first-occurrence scan and a per-distinct-expert rescan of the whole window: ~5-10K branchy iterations per layer, all of it before P.publish raises the flag the GPU spin-waits on, so every nanosecond was GPU idle time. It is the "plan" figure of the --stats dispatch line. One pass now groups each entry with its expert's first occurrence (open addressing over the routing ids), one pass over the distinct experts decides the tiers (the peer/helper questions are answered once per distinct expert, not once per entry and again per group), one pass emits the groups with per-group cursors. The CPU-miss prefetch dedup uses the same trick instead of std::find. The planner is split out as detail::window_gpu_plan so a test can drive it without a GPU: tests/core/expert_window_plan_test.cpp compares every output (kinds, group pointers/starts/dst/tok, counts, the DMA source list) with a verbatim copy of the O(n^2) planner on 20 targeted corners (the PCIe share's last-m boundary, the staging cap, peer/helper-held experts, out-of-range ids, pcie_mode 0/1/2, per-slot offsets) and 4000 randomized windows - identical in all of them. Measured: - plan alone (Xeon E5-2680 v4, 8x10-entry window, 512 experts, ~70% resident, 200K rounds): 7218 -> 1133 ns per window-layer (6.4x). - engine (RTX 3080 20 GB, IQ2_XS pack, --spec 4, 512 tokens, --stats, two alternating rounds): dispatch plan 0.194/0.135 -> 0.089/0.068 ms/round (-50%); activation-quantize, jobs and run unchanged; decode 96.3 tok/s both ways - the plan was GPU wait inside a window the CPU pool dominates on this rig, so the win shows where the plan is the long pole (cold caches, bigger windows, slower CPUs). Co-Authored-By: Claude Code <noreply@anthropic.com>
fragtion
added a commit
to fragtion/StrataPool
that referenced
this pull request
Oct 6, 2026
…1#1101, Niko1221#1166, Niko1221#1163 (open, on 0.1.40.1) - Niko1221#1102 / Niko1221#1201: the batch host paths skip the per-layer service per window (ar_on), not per process: with a prompt loan or swap in flight an all-resident stage takes the doorbell graph and stalled (the watchdog's "no progress" in several chats). - Niko1221#1185: a window graph that finds no VRAM frees older batch slot layouts' graphs instead of failing. - Niko1221#1101: prefill Stager threads sleep instead of yield-spinning (they competed with the CPU pool). - Niko1221#1166: --host-core sibling / STRATA_HOST_CORE=sibling: the host thread on the first core's other hardware thread, interrupts left alone (hybrid Intel CPUs, both of our PCs). Opt-in. - Niko1221#1163: a conversation parked again keeps at most an eighth of each K/V buffer in reserve (was up to 16 MiB per buffer, 0.4-0.5 GB per conversation). Skipped: Niko1221#1107 (conflicts with the staged walk's stream-ahead; no measurement given), Niko1221#1181 (decode unchanged in its own measurement). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157fjixQD2nRxvCvtXq66DD
Owner
|
Thanks for the careful measurement. The plan time halves but is about 0.1 ms of a 7-10 ms window, so decode does not move (your own numbers), and the planner has changed a lot since your base, so a straight port conflicts in three places. We are not taking it as a speed PR. If you rebase it with the differential test we would review it as a cleanup. |
Owner
|
Thanks for the pull request. We're resetting the tracker for 0.2.0-beta. There are more than 1,300 open issues and pull requests, too many for us to review properly, so we're closing everything that isn't being worked on right now. Closed here does not mean rejected. To reopen it takes about two minutes:
The rules are in CONTRIBUTING.md. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The verify window's GPU plan is built with O(n²) scans, and the GPU spin-waits on flag A while it is built: the plan is "decided and published FIRST so the GPU starts while the CPU works", and flag A rises only after it, at
P.publish. Plan time is therefore GPU idle time on the flag-A critical path, even where its share of the round is too small to show end-to-end (see Limits). The quadratic parts: a pairwise first-occurrence scan (expert_source.cpp:2462), three full re-scans of the window per distinct expert (kind assignment, VRAM and PCIe group entries), and astd::finddedup of the CPU-miss prefetch (expert_source.cpp:2598).This PR rewrites all of it as three linear passes with output-identical results. It deliberately does not change what the plan decides — the tiers, the PCIe share and the group order are exactly the old ones. The case for taking it is structural: no behavior change (pinned by a 4,020-case differential test, no GPU needed), and the quadratic term it removes grows with the window, so a bigger
--specwidens the saving.TL;DR
--stats: the dispatchplanfigure halves in both rig states seen here (four interleaved pairs); decode unchanged, as expected — the ≈0.3–0.4% gain is below the ±0.7% run-to-run spreadWhat changes
The planner is extracted as
detail::window_gpu_plan(the header's existingdetailtest-seam pattern) and rewritten:first_ofscanstd::findBounds are the old ones: the call site already guards
n <= kMaxWindowEntries(128) and falls back to the simple no-plan loop beyond it, so the 256-slot table never exceeds load ½; out-of-range ids group by the same equality the pairwise scan used and stay kind −1, refused later by the pool loop with the same message. Unchanged by construction: group order (routing order of first occurrence), entry order within a group, counts, kinds.Measured results
Measured on:
--spec 4 --spec-min-p 0.5+ the MTP drafter (verify window up to 6 tokens)Standalone planner, ns per window-layer (512 experts, ~70% resident, 1,000 pre-generated windows cycled through, 200,000 rounds per arm):
(Per 48-layer window that is 86 → 38 µs at 3×10 up to 734 → 154 µs at 12×10 of GPU wait. The window grows with
--spec: 6×10 is this config's maximum, 12×10 is near the 128-entry cap. The gap widens with window size because the old cost grows with distinct × n.)Engine A/B —
dispatchline sections in ms per window-round, mean of two replicated pairs per rig state (four interleaved pairs total; raw lines in the appendix):(decode: baseline 95.7–97.1, patched 95.8–96.8 tok/s over four runs each — ranges overlap; durations in the appendix.)
Findings
The new planner's output is identical to the old one's.
tests/core/expert_window_plan_test.cppdrivesdetail::window_gpu_planand a reference copy of the O(n^2) planner with the same inputs and compares every positional field — kinds, groupptr/start/start2/dst/tok, the group counts,nmiss, the DMA source list — on 20 targeted corners (the PCIe share's last-m boundary, the staging cap, peer/helper-held experts, out-of-range ids,pcie_mode0/1/2, per-slot cache offsets) and 4,000 randomized windows: identical in all 4,020. The reference is the replaced block with only the mechanical adaptation to the injected predicates, diff-verified against the base commit; the production predicates are the old inline expressions verbatim.Standalone plan time drops 3.2× at this config's max window, 4.8× near the entry cap. 5,103 → 1,576 ns per window-layer at 6×10 (245 → 76 µs of GPU wait per 48-layer window); the old cost grows with distinct × n, the new one with n — measured across six window sizes from 2.3× at 3×10 to 4.8× at 12×10 (table above; the in-engine figure is finding 3). (6 tokens is the cap at
--spec 4because the default suffix drafter may lengthen a window by 2 —generate.cpp:2091; the log's "rounds of 6" in the appendix confirms it is reached.)In the engine, the dispatch
planfigure halves and nothing else moves. 0.19 → 0.09 and 0.14 → 0.07 ms per window-round, replicated in both states; activation-quantize, jobs and run are unchanged within noise state-for-state (the run section's pair deltas are −0.01 to +0.22 ms).The engine's absolute numbers sit below the micro-benchmark's 48-layer extrapolation for two reasons. The
plansegment times more than the planner —begin_layer, usage accounting, the flag-raisingP.publishand the PCIeP.fetchissue share the segment and are untouched, so the ~0.07 ms that remains is those parts plus the now-small planner. And the A/B windows are smaller than the benchmark's headline arms: the logged lookup counts put the mean window at ≈3.3×10 slow-state and ≈2.4×10 fast-state (308,226 lookups ÷ 195 rounds ÷ 48 layers ≈ 33 and 283,774 ÷ 244 ÷ 48 ≈ 24 entries per window-layer — PCIe-served misses excluded; the draft counts agree: 1 + 458/195 ≈ 3.3 and 1 + 355/244 ≈ 2.4 tokens). The per-layer saving (105 µs/round ÷ 48 ≈ 2.2 µs slow-state, 67 ÷ 48 ≈ 1.4 fast-state) is 1.4–2.2× the benchmark's ≈1.0 µs at 3×10 — the expected direction, since the benchmark runs warm (L3-resident working set, regular branches, fixed residency — see Limits) while the engine is cold and irregular, and plan cost is convex in window size, so a spread of real windows costs more than the mean window. The micro-benchmark is the scaling story; the engine figure is the realistic one.Decode tok/s is unchanged, and that is the expected outcome, not hidden work. Within each pair the two arms produce identical progress lines (greedy sampling; every logged counter matches — same rounds, same acceptance, same expert lookups, see the appendix): 195 rounds in the slow state, 244 in the fast, so a round is 27.3 ms slow-state and 21.8 fast-state. The plan saving (105 µs slow-state, 67 fast-state) is an expected ≈0.4% / ≈0.3% decode gain, below the ±0.7% run-to-run spread of the four runs (95.7–97.1 tok/s). The removed plan time is real dead time on the flag-A critical path; it becomes visible end-to-end where its share is larger (bigger
--specwindows, slower CPUs — see Limits).Validation
--statsdispatch lines on four interleaved baseline/patched pairs, 512 generated tokens after an 873-token prompt (finding 3)ctest -R expert_window_planpasses in ~0.6 s with no GPUctestgreen exceptple_parityandexpert_multi_test, which fail identically on the base commit on this host (missing model files, no AVX512)Limits
src9f4e26be,included4bf5e4b,cmake478c0fb6,CMakeLists.txtcd605177 in both commits (the rewrite touched no C++ or build files)Reproduce
The standalone micro-benchmark is not part of this PR — it is the test's driver with a timing loop; its parameters are in the appendix.
Raw `--stats` lines and the micro-benchmark
Engine A/B, all eight runs — 873-token task prompt, 512 generated tokens, interleaved baseline/patched pairs, slow/fast = the two rig states seen across the four pairs (clock and trajectory both differ between them; supports findings 3 and 4):
(r1 and r2 baseline differ by 0.3 ms of decode duration — 512/5.3171 = 96.294 and 512/5.3174 = 96.288, both 96.29 at two decimals.)
Two things worth knowing about those lines. The section timer is
steady_clockaccumulated in double ms (expert_source.cpp:2676), nanosecond-resolution — not a coarse timer. Sampling is greedy and every run within a state is identical in every logged counter (slow: 195 rounds, drafts accepted 318 of 458, lookups 277,710 of 308,226 with 30,516 refused; fast: 244, 268 of 355, 260,078 of 283,774 with 23,696 — each patched run matches its baseline partner), so the pure host segmentsplanandjobsrepeat to three decimals whilequantizeandrun, containing GPU work, jitter normally. Refusals are timing-dependent yet reproduce exactly within a state — apparently the state's timing margins are wide enough that the same experts miss their window in every replica; between states the refusal pattern shifts the logits, hence the different trajectories and round counts.Standalone planner micro-benchmark — Xeon E5-2680 v4, 512 experts, ~70% resident, 1,000 pre-generated windows cycled through, 200,000 rounds per arm, quiet machine (supports finding 2). Run single-threaded on the otherwise-idle rig with the benchmark core at turbo (~3.2 GHz; frequency polled during a re-run, all six arms reproduced within noise):
Work with Claude Code