Skip to content

Decode threading: split the decode mat-vec across threads (plan D0–D1) - #4

Merged
dansupergameprogrammer merged 15 commits into
mainfrom
claude/project-thread-c8iecr
Sep 30, 2026
Merged

dansupergameprogrammer merged 15 commits into
mainfrom
claude/project-thread-c8iecr

Conversation

@dansupergameprogrammer

@dansupergameprogrammer dansupergameprogrammer commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Steps D0 and D1 of the decode-threading plan (rev 1.2, approved). This is meant to ship as 1.11.0. The version number is not bumped here, and nothing is released.

Before / After

Before: decode mat-vecs run on one thread. A host's sslm_parallel_for hook is used only by the final logits step. Every per-layer projection in decode, and in a prefill that admits one token, runs on the calling thread.

After: a host can opt in by setting bit 1 of sslm_parallel_for.reserved, the new SSLM_PARALLEL_FOR_MATVEC. With the bit set, each one-row (M = 1) projection splits its output rows across the hook's run. That covers each decode layer's five groups (q alone, k + v together, o alone, gate + up together, down alone), and each of the seven projections of a prefill that admits exactly one token. Every row is still one exact integer sum computed on one thread, so the split is exact.

Default: off. Without the bit nothing changes, and a 1.10.0 host behaves exactly as before. The plugin's own default is not set here; the owner sets it after the box measurements (see Still owed).

How

  • Shared split code. A new internal source, src/forward/parallel_split.{h,cpp}, holds the column split, the run-exactly-once runner and a snapshot of the per-sequence saturation counters. The logits step now uses the same split. Its partition is unchanged.
  • When a projection splits. The task count is the smallest of max_tasks, ceil(rows / 64) and floor(weight bytes / 256 KiB). Rows per task are then rounded up to 64, so 256 KiB caps the task count; it is not a floor on each task, and the last task can be smaller (896 × 896 at 4 tasks: 320/320/256 rows, the last 229,376 bytes). A group that gets fewer than two tasks stays serial. Qwen2.5-0.5B with 4 tasks makes 97 run calls per decode token (k + v stays serial).
  • Setters. Both setters accept bit 1 and still refuse every other bit. The GPU setter accepts the bit and ignores it.
  • Retry after a hook failure in decode. If run breaks its exactly-once contract inside a decode layer, the call fails with SSLM_INVALID_ARGUMENT. The sequence rests at the start of that layer with that layer's saturation counts put back, and the next call resumes there.
  • One behaviour change for every host, bit or not. A prefill that fails before admitting a token now leaves the saturation counts as they were before the call.
  • C++ exports. New threaded overloads of RunLayerLoop and RunLayerLoopChunkBatched. The existing symbols stay. No struct, status, C function or save format changes.
  • Tests and CI. The tests read fixtures generated in CI by tools/gen_decode_threading_fixture.py; they are never committed. Every CI leg that runs a suite binary gains a step that generates them. A missing fixture fails the tests rather than skipping them.
  • Bench. tools/decode_threading_bench.cpp is the tool for the box measurements.
  • Where to read more. Details, and the 19 places this build differs from the plan, are in docs/decode-threading-d0d1-progress.md.

Code review (HOLD at 5043ce4) and fixes

The review passed the core threading on Windows MSVC: output identity, retry state, allocation, ABI and seams. On Windows, 133,887 checks with 0 failures, forced SSE2 and AVX2 green, and the WIN32 setter cell passed. It raised four findings, each fixed in its own commit:

  • P1 (ship-blocking), 9677431. tools/build_adapter_dump.bat named parallel_split.cpp without %ENG%. Grepping for the same defect found 26 more MSVC build lists (red-suite scripts under tests/, the t2922 PowerShell runners, the two t2132 diagnostics) that omitted the file entirely and so failed to link. All are fixed. No CI step or checker compiles these scripts.
  • P2 (PD-1), 6671e43. Cell 2.1 encodes the owner's ruling. The hook reads the live counters at the failure. At q and k + v nothing has moved yet, so those cases prove no drift and retry identity and claim no restore. At o, gate + up and down, the checked counters are proved to have risen before the failure, then restored. The 11 mutants that touch 2.1 were re-run: 11 killed.
  • P3, 5025769. docs/api.md, parallel_for.h and the CHANGELOG now say what the split does (above).
  • P4, c5efc72. F-QK is host-dependent. numpy dispatches float64 np.exp on the CPU (its own AVX-512 exp here, the C library's elsewhere), and F-QK's QKC1 table is exact in the float calibration peaks. Only that section moves; F-DEF and fnoclamp have no QKC1 table and match everywhere. Nothing in 1.10.0 changed the generator, and no engine code runs in it. This host at this head still writes 27e82bcb…. 6.3 and 9.1 against v1.9.0 were re-run on the fresh fixtures and on both local F-QK variants (27e82bcb…, 623d3efe…): byte-equal.

Bit-identity evidence

Checked on Linux with GCC 13 and Clang 18, Release, on an AVX-512 host, at the review-fix head. Full record: docs/decode-threading/review-fixes.txt; the move onto 1.10.0 is in docs/decode-threading/rebase-1.10.0.txt.

  • Suites. The auto suite and the SSE2-, AVX2- and AVX-512-forced suites: 0 failures on both compilers. superslm_tests has 123,814 checks (main 121,655), with 2,152 D1 checks after the PD-1 change. The scalar-tier decode-threading binary (2,159 checks) and the per-token allocation binary (84 checks) also pass with 0 failures.
  • Output digests. All ten digest legs (five tiers × two compilers) give GLOBAL f740f833…4ae5acc, which is main's hash. The whole output matches main's byte for byte on every leg.
  • With threading on. Tokens and save blobs with the bit on equal the serial run at max_tasks 1, 2, 4 and 8. The tests also check each row against the scalar reference.
  • Other checks. The fp-free scan passes on both compilers (18 objects, 0 rejected). pytest tests/ci gives 915 passed, 1 skipped, the same as main. The CI-claims, present-tense, GPU-guard parity, linkage, isolation, forward-leaf and site-census checks all pass.
  • Mutation tests. 23 of 23 planted bugs are caught. They ran on the same source before the rebase, and the rebase did not change the source. The 11 that touch cell 2.1 were re-run against the PD-1 cell: 11 of 11 caught.
  • Sanitizers. ASan+UBSan and TSan reported 0 errors on the same source before the rebase.

Indicative cloud speed

Decode tokens/s with the bit on are 1.27–1.57x the rate with it off. This was measured on a shared 4-vCPU cloud Xeon with a synthetic 2-layer artifact at 0.5B width. Indicative only. Not measured on the owner's desktop, and not a plan reading.

Still owed

  • On Windows (the owner's machine): run tools\build_adapter_dump.bat and at least one of the other patched MSVC lists; the WIN32 setter gate and the Windows suites at the new head; optionally the v1.9 probe on the Windows-generated F-QK (31dd0c5b…).
  • A confirming review of the four fixes.
  • D2 box timing (B0–B2) on the owner's desktop with the real model. The 256 KiB minimum-work constant is also settled there.
  • After that, the owner sets the plugin default from B3's off/on numbers.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68


Generated by Claude Code

…[skip ci]

Plan of record: decode threading (one-row matvec through the parallel-for
hook), rev 1.2, step D0 (§3.2, §3.6, §3.7, §6). No behaviour change.

- src/forward/parallel_split.{h,cpp} (internal, no SUPERSLM_API):
  SplitColumns (§3.2's T, rows_per_task and task_count), RunExactlyOnce
  (the logits step's admission array, violation flag and acquire scan,
  generic over a noexcept body), SaturationCounters (snapshot and restore
  of the five per-sequence saturation counters), and
  ParallelForHookValid with the implemented-bit mask, still empty.
- LogitsSiteParallel calls SplitColumns (align 64, no minimum work) and
  RunExactlyOnce. Its partition, statuses and `run` calls are v1.9.0's,
  the one-task call at V <= 64 included (M1).
- Both hook setters (CPU and the WIN32 GPU one) call ParallelForHookValid.
- Cells (tests/test_decode_threading.cpp, in superslm_tests and the forced
  suites): 4.1 (the split's contract over align 32/64, 15 widths, three
  minimum works and max_tasks 1-256), 7.1 (the split against v1.9.0's
  formula for V 1-2,999 and 151,936 at max_tasks 2-256, and through
  LogitsSiteParallel itself: task count, the block task 0 writes, the
  one-task call and the row), and the snapshot round trip.
- The new source is added to CMake, build.bat and the tools/build_*.bat
  recipes; gpu_layer_loop_guards.def is re-cited (+1 line, the include).

GCC 13: superslm_tests 121,658 checks, 0 failures (base 121,651).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…he §8 red suite [skip ci]

The red half of D1, authored before the implementation (plan rev 1.2, §6 D1, §8):

- tools/gen_decode_threading_fixture.py: the §3.9 CI fixtures (F-DEF, F-QK and the
  no-clamp vitality variant), built through the reference pipeline; never committed.
- The minimum-work and layer-site fault seams (src/forward/parallel_split.h) under
  SUPERSLM_ENABLE_MATVEC_TEST_SEAMS, on the test libraries only.
- SSLM_PARALLEL_FOR_MATVEC, GemmThreading, the new RunLayerLoop and
  RunLayerLoopChunkBatched overloads, MatvecGroupParallel and GemmDispatch, wired through
  decode and one-token prefill, with the contract text.
- Stubbed so the suite is red: the implemented-bit mask is still empty, MatvecGroupSplit
  always returns a serial split, and neither retry guard restores anything.
- tests/test_decode_threading_d1.cpp (cells 1.1-1.3, 2.1-2.3, 3.1/3.2, 4.2-4.4, 5.1-5.5,
  6.1-6.3, 7.2, 8.1, 8.2, 11.1), the scalar-tier standalone runner, the 7.4 allocation
  executable, and the WIN32 setter sibling block in cell_setters.cpp.
- gpu_layer_loop_guards.def re-cited (+34); the parity check passes.

Red run (GCC 13, fixtures generated): superslm_tests 123,438 checks, 478 failures, all
in the D1 cells; superslm_decode_threading_alloc 2 failures. Without the fixture
directory the D1 cells fail rather than skip (27 failures).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…uards [skip ci]

Plan rev 1.2 §3.1, §3.6 and §3.7:

- SSLM_PARALLEL_FOR_MATVEC joins the implemented-bit mask; bit 0 is still rejected by
  both setters.
- MatvecGroupSplit applies the §3.1 rule: a hook with max_tasks >= 2, alignment 64 and
  a minimum of MinRowBytesPerTask() weight bytes per task; below two tasks the group
  runs the serial calls of v1.9.0.
- The decode guard restores the five saturation counters to the layer attempt's start
  on ParallelForIncomplete only.
- The prefill guard restores them on any failed call, the exception arm included, iff
  context_length did not move.

Green run (GCC 13): superslm_tests 123,464 checks, 0 failures (the D1 cells 2,044);
superslm_decode_threading_alloc 84 checks, 0 failures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…ch [skip ci]

- CI: every leg that runs a suite binary sets up Python, installs numpy, generates the
  §3.9 fixtures into dt-fixtures and points SUPERSLM_DECODE_THREADING_FIXTURE_DIR at them.
  That is fourteen legs: windows-x64, linux-x64, ASan, macOS, Debug, TSan, the three Linux
  forced tiers, the four Windows forced tiers and branch coverage. linux-x64 also runs the
  7.4 allocation binary, and the SSE2-forced leg runs the scalar-tier decode-threading
  binary (4.5).
- docs/api.md and CHANGELOG [Unreleased]:
  - the opt-in bit and its rule;
  - that setters refuse unimplemented bits;
  - the decode retry behaviour;
  - the prefill saturation-count change, which applies to every host.
- The LogitsSiteParallel and ParallelForIncomplete comments name the shared split and
  the new source of the status.
- tools/decode_threading_bench.cpp (§7 B0/B1), with the verify, time and onetoken
  modes, plus a groups mode that times one layer's five groups at Qwen2.5-0.5B shapes
  with no artifact. It is built as sslm_decode_threading_bench on the production library
  and as sslm_decode_threading_bench_seams for B1's minimum-work sweep.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
The tests/ci pytest against the base found three things the D0/D1 commits
missed:

- check_no_forward_leaf_calls.py: D0 added src/forward/parallel_split.{h,cpp};
  the exact expected-file list now names them.
- geometry_site_census: the four D1 fault seams sat between a GS-12 marker
  and its anchored call, regressing the site. They now sit above the marker,
  and the decode q call is wrapped so the anchor line carries the output
  width again. No behaviour change.
- test_geometry_site_census.py: the GS-12 reorder-plus-revert fixture
  follows the decode q call's new text (ProjectAndFunnelRow(row_pf, ...)),
  as the fixture's own assertion asks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…cts [skip ci]

D0 added src/forward/parallel_split.cpp to SUPERSLM_CORE_SOURCES, so the
superslm target is 18 translation units and 18 archive objects. The
t2296 suite pinned 17 in the manifest cells (red here), the real-build
object count, and the COFF and ELF archive member counts (Windows CI and
an environment-provided archive). Each pin now reads 18, and the
fp-at-k carrier sweep covers positions 1..19 of 22-member archives.

Here, the suite is back to the base's result: the same 4 environmental
failures, 154 passed; with a real ELF archive supplied, the same 5 as the
base. The COFF cells run only on the Windows leg and are not executed here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…e evidence [skip ci]

- Cell 2.1 also lands its four hostile cases on o. The coverage replica
  showed o's failure arm (its fail_layer) taken by no cell; §8 2.1 names
  every other threaded site. A mutant that routes o's failure around the
  decode guard dies on these cases and nothing else.
- docs/decode-threading-d0d1-progress.md: the items, their states, the
  deviations and what the plan got wrong.
- docs/decode-threading/: the red run, the suites on every tier and both
  compilers, the sanitizers, the digests, the mutants, the fp-free scan,
  the Python suites against the base, the fixture premise, 6.3 and 9.1
  against v1.9.0 (with the probe's source), the 9.3 symbol check, the
  coverage replica and the indicative bench.

Final tree: superslm_tests 123,766 checks, 0 failures (GCC 13 and
Clang 18, every tier; the D1 cells 2,108 on each); digests equal to the
base on all five tiers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…1.10.0 release [skip ci]

The D1 contract text was written against an [Unreleased] section that
sat directly above 1.9.0. On the new main, 1.10.0 is released and its
section sits between the two, so the three-way apply put the entry at
the end of 1.10.0. It now opens [Unreleased], and its three references
to the previous release name 1.10.0: its setter refuses a nonzero
reserved field, its logits step alone reads the hook, and its prefill
keeps the counts of a failed call, as 1.9.0's did. The 1.10.0 section
is byte-identical to main's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
… [skip ci]

docs/decode-threading/rebase-1.10.0.txt records what applying the
series onto main at fdeee2f changed (the CHANGELOG placement only), and
the re-run against main on this host, GCC 13 and Clang 18: every suite
at main's count plus 2,115 with 0 failures, the ten digest legs equal
to main's GLOBAL hash, the fp-free scan, pytest tests/ci at main's
915 passed, and the named checkers. It also compares the coverage
replica with the floors main re-pinned after c28f171. The progress
file's base, resume steps and coverage note point at it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…it.cpp [skip ci]

tools/build_adapter_dump.bat named the new source without its %ENG% prefix, so cl looked for
tools\src\forward\parallel_split.cpp and stopped with C1083. It now reads
%ENG%\src\forward\parallel_split.cpp like every other source on that line.

The same grep found a second, larger gap from D0: 26 other build lists (red-suite build and
mutant scripts under tests/, the three t2922 PowerShell runners, and the two disposable t2132
diagnostics under tools/) compile src\forward\forward_sites.cpp without parallel_split.cpp at
all. forward_sites.cpp calls SplitColumns, RunExactlyOnce, MatvecGroupParallel and the rest out
of that file, so each of those links failed with unresolved externals. Each now names the file
next to forward_sites.cpp, in the prefix and quoting style of its own line. A GCC replica of
build_adapter_dump.bat's list links; the same list without parallel_split.cpp leaves 11
undefined references.

No CI step or checker compiles these scripts. check_tools_have_build_recipe.py and
check_tests_have_build_recipe.py only check that a recipe names each tool or suite, and CI runs
neither. check_build_bat_exit_paths.py checks build.bat's exit paths only. The MSVC run of the
scripts is still owed on Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…r, as the code does them [skip ci]

docs/api.md said each decode layer's q, k and v form one group and that a group splits into tasks
of at least 256 KiB each. The code sends q alone (ProjectAndFunnelRow) and groups only k + v, and
SplitColumns uses 256 KiB to cap the task count (floor(N*K / 256 KiB)) before rounding rows per
task up to 64, so the last task can be smaller. At N = K = 896 and max_tasks 4 the split is
320/320/256 rows and the last task streams 229,376 bytes.

The text now names the five decode groups (q; k + v; o; gate + up; down), says a one-token
prefill splits each of its seven projections alone, gives the split rule, and states that 256 KiB
is a cap on the task count and not a per-task floor, with the 896 example. It also says why the
97 run calls hold at 0.5B: k + v (256 rows, 229,376 bytes) stays serial. parallel_for.h's
"tasks of at least a fixed minimum each" and the CHANGELOG's "256 KiB per task" carried the same
per-task claim and are corrected the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
… prove [skip ci]

The promise that every guard cell first proves the checked counters rose in the failed layer
cannot hold at q and k + v: those sites fail before layer 1 can move any counter (progress file,
deviation 3). The owner's seat ruled the review's recommendation (PD-1; review record: Wizard
Claude/Poirot/decode-threading-review-2026-09-30.md, section 3). A site that fails before any
counter can move proves retry identity and no drift. Only the later sites (o, gate + up, down)
prove the counters rose and were restored.

Cell 2.1 now encodes the ruling. The test hook reads the sequence's live saturation counters at
the moment it fails the call (the forward pass is blocked in run then). Each site is tagged with
what it proves:
- q, k + v (no drift): the counters at the failure equal layer 0's, so the site really is early;
  the sequence rests at layer 1 with nothing of layer 1 left; the retry equals the never-failed
  run. No restore is claimed.
- o, gate + up, down (restore): F1 holds on the counters read at the failure (every checked
  counter rose in layer 1, rope_q != rope_k), then the rest-at-layer-1 restore check and the
  retry, unchanged.
- finish: every layer keeps its counts, unchanged.
Every case also asserts the hostile call ran. The file header, the F1 comment and
CheckRestsAtLayer1's message no longer say "restored" where nothing moved. D1 checks go from
2,108 to 2,152 (24 + 20).

The progress file's deviation 3 is rewritten, deviation 18 records the ruling, and "What the
plan got wrong" gains item 8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…xp; record and re-run [skip ci]

The review regenerated the fixtures on Windows (Zen 2, no AVX-512): F-DEF and fnoclamp matched the
record and F-QK did not (31dd0c5b... against the recorded 27e82bcb...).

Root cause: numpy's float64 np.exp is dispatched on the CPU. On an AVX-512 host numpy runs its own
AVX-512F exp; elsewhere it calls the C library's (glibc, or the MSVC CRT on Windows). The results
differ in the last bits. The float calibration calls np.exp in the softmax and SiLU, so the
per-channel post-RoPE K peaks differ in their low bits. _qk_channel_table_from_peaks converts each
binary64 peak exactly into a 31-bit mantissa, its reciprocal and a Q31 ratio. So only F-QK's QKC1
section moves (181 of 16,656 bytes; every other section byte-equal). F-DEF and fnoclamp strip
QK-norm and carry no QKC1 table.

Measured on this host:
- This head reproduces the record (27e82bcb...), and so do the OPENBLAS_CORETYPE variants.
- With numpy's AVX-512 dispatch disabled, F-QK becomes 623d3efe... and F-DEF and fnoclamp do not
  change.
- With np.exp swapped for math.exp, both dispatch settings give 623d3efe....
- tools/ is unchanged by 1.10.0 apart from coverage floors and a release-gate test.
This is not an engine divergence: no engine code runs in the generator. The D1 cells pass on
623d3efe... here, as they did on 31dd0c5b... in the review.

Re-run against v1.9.0 (v190_blob_probe, the d870d27 library against this head's seam library) on
freshly generated F-DEF and both F-QK variants: 6.3 and 9.1 byte-equal on all three. The fixture
record now says the generator is deterministic per host, with the evidence. The generator's
docstring and the progress file (deviation 19, the 6.3/9.1 row, Resume here) say so too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
…nt re-run [skip ci]

docs/decode-threading/review-fixes.txt records the four fixes and the re-run at this head, on
fixtures generated fresh here. Linux, GCC 13 and Clang 18:
- superslm_tests 123,814, SSE2-forced 123,756, AVX2- and AVX-512-forced 123,772 (executed on
  AVX-512), scalar-tier decode threading 2,159, allocation 84: 0 failures, the same on both
  compilers.
- All ten digest legs give GLOBAL f740f833...4ae5acc.
- The fp-free scan passes: 18 objects, 0 rejected.
- pytest tests/ci: 915 passed, 1 skipped. The named checkers are OK.
mutants.txt gains the PD-1 re-run: the 11 mutants whose red set includes 2.1, all killed. By site,
M7 is red at o, gate+up and down only, as PD-1 says it must be. The progress file gains a review
section, and deviation 14 now names the build-list gap P1 closed. The Windows runs are listed as
still owed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
… and D2 B0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfuJLAZ6EFSt7mB85oNm68
@dansupergameprogrammer
dansupergameprogrammer marked this pull request as ready for review September 30, 2026 18:56
@dansupergameprogrammer
dansupergameprogrammer merged commit f4a1b6a into main Sep 30, 2026
78 checks passed
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.

2 participants