Skip to content

DeepSeek V4.1: the indexer's merge and finish on a threadgroup a row, the same picks; a decode row's indexer 13-34x faster - #2

Open
BobClawblaw wants to merge 1 commit into
akol1:pr-dsv41-bc-familyfrom
BobClawblaw:dsv41-index-select
Open

BobClawblaw wants to merge 1 commit into
akol1:pr-dsv41-bc-familyfrom
BobClawblaw:dsv41-index-select

Conversation

@BobClawblaw

Copy link
Copy Markdown

Following up on ashhart#847: the indexer change from our engine that keeps the bytes and fits your layout. It's against your pr-dsv41-bc-family.

The other one isn't needed. Your layers after the candidate source already score only their candidate blocks (use_candidates), which is what our first indexer commit did.

Where the time goes. In your indexer pass, most of the time is the merge: one thread per row pushes each tile's 4,096 scores through the 512-pick heap (and the 2,048 candidate blocks). Then finish sorts them on that thread too. For a decode row that's almost the whole pass. We also tried tiling dsv41_index_score (byte-equal to it): on an idle GPU it was 1-6% slower, so it isn't in this PR.

The change: merge and finish on a 1,024-thread group per row.

  • Merge: builds 64-bit keys from the held picks and the tile's finite scores. A key is the score as ordered bits, then the position reversed, which is the order dsv41_less gives. Eight 8-bit radix passes find the 512th best key, and every pick at or above it is kept, written in thread order so runs are byte-identical. The candidate blocks are selected the same way, with the newest visible block pinned as before.
  • Finish: a bitonic sort by position, with -1 placeholders last, which is dsv41_sort_positions' order.
  • Why the bytes stay the same: positions are unique, so the 512 (and 2,048) picks kept are exactly the heap's, and the finished heap, candidates and positions are byte-equal.
  • The serial kernels stay as dsv41_index_merge_serial and dsv41_index_finish_serial, as the baseline for the checks below.

Checks (M5 Ultra; this is the only chip we ran):

  • index_select_test.zig (new, in test-deepseek-v41): the whole pass, parallel against serial, with heaps, candidates and positions compared byte for byte. Cases: 1, 3 and 17 rows; 37 to 20,000 keys over one to five tiles; random and tied scores; the candidate source pass, then a pass over its candidates. It fails when the merge keeps one pick too few.
  • test-deepseek-v41: 80/80 in ReleaseFast and Debug, including your top-512 tie and candidate-pinning tests.
  • Kernel contract (metal_dsv41_select.zig):
    • Merge: checked against a host oracle that computes the exact best 512 and 2,048 as sets. A NaN or infinite score is never picked, and a block's maximum may be +inf. It fails at two gates when the merge keeps one pick too few.
    • Finish: bit-equal to the serial finish (replaces).
    • Gate 5: 1,024 threads, with 1,168 and 16,384 threadgroup bytes. The offline M1 and M2 builds take 1,024. Both are added to the coverage list in docs/kernel-contract.md.
    • SUMMARY 91 kernels: 91 pass gates 1-4, 0 fail, 2 known findings; 14 controls: 14 red as planted, 0 missed; 0 limit problems
    • test-kernel-contract: 36/36.
  • Formatting: zig fmt clean; the lean check is unchanged from your head (502).

Timings. The whole indexer pass per layer (score tiles, merges, finish), M5 Ultra, idle GPU, two runs each, 1eccf660 → this:

Rows Keys Before After
1 8,192 52.7-52.8 ms 0.26-0.50 ms
1 131,072 129.9-130.8 ms 3.69-3.81 ms
1 520,000 212.2-213.8 ms 15.63-15.66 ms
1 16,384 (candidates) 11.61-11.66 ms 0.34-0.35 ms
256 8,192 97.1-97.3 ms 11.66-11.71 ms
256 131,072 540.7-541.1 ms 188.48-188.49 ms
256 520,000 1,320.5-1,320.9 ms 750.0-750.4 ms
256 16,384 (candidates) 57.81-57.84 ms 23.53-23.57 ms

Not run: the full model. The family keeps the 2.9 bpw EXL3 pack and the Engram tables resident, about 414 GB, and this Mac has 256 GB. The selections are byte-equal, so the logits can't change, but we haven't measured end-to-end speed. If you run tf-dsv41-run profile or teacher on a larger Mac, the indexer's share is what this should move.

Glad to rework it to fit your next increment.

… the same picks; a decode row's indexer 13-34x faster

dsv41_index_merge took each tile's 4,096 scores into a row's 512-pick heap (and its 2,048 candidate blocks) on one
thread a row, and dsv41_index_finish sorted them by insertion. Both now run a threadgroup of 1,024 threads a row:
- merge: the held picks and the tile's finite scores as 64-bit keys (score as ordered bits, then position
  reversed, the order dsv41_less gives), the 512th best found by eight 8-bit radix passes, and every pick at or
  above it kept, placed in thread order (byte-identical runs). The candidate blocks the same way, the newest
  visible block pinned as before.
- finish: a bitonic sort by position, -1 placeholders last, as dsv41_sort_positions orders them.
Positions are unique, so the 512 (and 2,048) kept are the heap's own; the finished buffers are byte-equal.

The serial kernels stay as dsv41_index_merge_serial and dsv41_index_finish_serial, the baseline for:
- index_select_test.zig: the whole pass, parallel against serial, heaps, candidates and positions byte for byte (1,
  3 and 17 rows; 37 to 20,000 keys over one to five tiles; random and tied scores; the candidate source pass, then a
  pass over its candidates). It fails when the merge keeps one pick too few.
- The kernel contract (metal_dsv41_select.zig): the merge against a host oracle (its picks as sets), the finish
  bit-equal to the serial finish; gates 1-4 pass for both, and gate 5 at 1,024 threads (offline M1 and M2: 1,024).

Whole indexer pass a layer (score tiles, merges, finish), M5 Ultra, idle GPU, two runs each (1eccf66 -> this):
- 1 row: 8,192 keys 52.7-52.8 -> 0.26-0.50 ms; 131,072 keys 129.9-130.8 -> 3.69-3.81 ms; 520,000 keys
  212.2-213.8 -> 15.63-15.66 ms; with candidates (16,384 keys) 11.61-11.66 -> 0.34-0.35 ms.
- 256 rows: 8,192 keys 97.1-97.3 -> 11.66-11.71 ms; 131,072 keys 540.7-541.1 -> 188.48-188.49 ms; 520,000 keys
  1,320.5-1,320.9 -> 750.0-750.4 ms; with candidates 57.81-57.84 -> 23.53-23.57 ms.
Not run: the full model. The family keeps the 2.9 bpw EXL3 pack and the Engram tables resident (about 414 GB),
more than the 256 GB this Mac has.
@akol1
akol1 changed the base branch from pr-dsv41-bc-family to pr-dsv41-bc-family-11 October 19, 2026 19:51
@akol1
akol1 changed the base branch from pr-dsv41-bc-family-11 to pr-dsv41-bc-family October 19, 2026 19:51

@akol1 akol1 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve with nits, all folded in — refreshed for the new family head.

Base note. This PR sits on pr-dsv41-bc-family (1eccf660), which is no longer the family head. The family has been reconciled onto upstream 1.1.1, and in that stack the family's Metal sources moved to zig/src/families/deepseek_v41/metal/. I rebased your commit onto 4f69fea10 and kept it as its own commit (a17207e70, your message, body and authorship, same content). Only relocation: your new test moved to zig/src/families/deepseek_v41/metal/index_select_test.zig and is imported from zig/deepseek_v41_checks.zig in the same place it was before. The SUMMARY 91 kernels line in your body was the old head's full run; I left your body alone.

The branch to take: pr-dsv41-bob-indexer on akol1/TensorFold = your commit a17207e70 plus my review commit 48a64f450 (10 files, +516/−6 against 4f69fea10).

Answered:

  1. Score and clear registration: not added, and I don't think they belong in this change. dsv41_index_score and dsv41_index_clear are unchanged by this PR and were unregistered before it. Registering a launch is more than a row: it needs a launchOf mapping in metal_limits.zig, a spec and an oracle, and registry.zig is exactly at the 600-line file cap right now. It is the same tracked coverage item that would take the rest of the family's relocated kernels, not a gap this PR opens.
  2. The hard-coded 1,024 is gone. metal_dsv41_select.zig now dispatches csa2_index.select_threads through a deepseek_v41_index export on tensorfold, the constant the engine launch itself uses, so the test cannot drift from the launch.
  3. The 16 KB array has its comment. threadgroup Dsv41Pick tmp[2048] is needed at launch, before the row >= p.rows guard, which is why the gate-5 row stays 16,384 bytes for the last partial rowgroup. One comment; no code change.
  4. The finish path's candidate oracle is now compared, not just computed. It asserts the finish leaves the candidate blocks in dsv41_sort_positions order too (row {d}: the candidate blocks out of position order); before, only the merge compared them against the oracle.
  5. Gate 5 and the suite receipt are below.

What I re-ran (Apple M3 Ultra, 80-core GPU, 512 GB unified memory; macOS 26.6.2, Xcode 27.0; Zig 0.17.0; fresh local build caches; family head + your commit + the review commit):

  • zig build test-deepseek-v41 test-kernel-contract --summary all: 52/52 steps succeeded, 144/144 tests passed.
  • zig build test --summary all: 360/360 steps succeeded, 5,038/5,059 tests passed (21 skipped).
  • tf-kernel-contract --only deepseek_v41 --no-limits: SUMMARY 2 kernels: 2 pass gates 1-4, 0 fail, 0 known findings. Gate 5 on this GPU: merge 1,024 threads and 1,168 threadgroup bytes, finish 1,024 threads and 16,384 bytes (this GPU's most 32,768).
  • The limits half of that run, unfiltered: 161 launches, 12 problems — the same 12 this branch's base has (159 launches, 12 problems at 4f69fea10), all flashnext entries with no compiled limit recorded; your two launches are clean. That is why the filtered gate run above passes --no-limits.
  • zig fmt --check clean; git diff --check clean; python3 tools/zig/lean_check.py: 3,396 files and 444 problems, and against the base's 3,394 files and 444 problems that is no new finding (both added files are clean).
  • Not re-measured: the timing table. It stays your M5 Ultra numbers, and I am claiming nothing independent about speed. Not run: the full model — the resident layout fits on this host, but the selections are byte-equal, so the model logits cannot move, and whole-model/residency belongs to the tracker gate (ashhart#847) rather than to a review of this change.

If you would rather keep this PR's head yours: git fetch https://github.com/akol1/TensorFold pr-dsv41-bob-indexer && git reset --hard FETCH_HEAD && git push -f origin dsv41-index-select, and I will retarget this PR at pr-dsv41-bc-family-11 (the current family head) so it shows just the change. Otherwise the 1.1.2 family carry takes pr-dsv41-bob-indexer as it stands, with your commit first and mine second, and your authorship intact.

@EugeneClaw

Copy link
Copy Markdown

This is superseded: you rebased the pair onto 4f69fea10 as pr-dsv41-bob-indexer (a17207e70 + 48a64f450), which is the copy to keep. Closing so the open list matches.

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.

3 participants