Skip to content

glm cuda: a decode step's select gives each row a 1,024-thread block - #1424

Merged
ashhart merged 3 commits into
ashhart:mainfrom
Bizuayeu:pr/glm-select-wide
Oct 28, 2026
Merged

ashhart merged 3 commits into
ashhart:mainfrom
Bizuayeu:pr/glm-select-wide

Conversation

@Bizuayeu

@Bizuayeu Bizuayeu commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

A decode step's DSA select at long positions now gives each row a 1,024-thread block, so decode over a long context
gets faster; short prompts, the prompt and the select's bytes do not change. On my hardware (three GB10 at TP=3,
GLM-5.3-Flash XSFP4, --parallel 4):

main 1b4f2f979 this head
Decode after a 136k-token prompt (bench_cache.py --tokens 256, mean of six) 57.75 tok/s 58.80 tok/s (+1.8%)
Decode, fibonacci-raw 57.58, 57.55 tok/s 57.58, 57.50 tok/s
A cold 136k-token prompt, ttft_s (mean of six) 87.91 s 87.69 s

With drafts off, a build that switches between the two kernels gave +2.4% after the same prompt (28.06 → 28.74 tok/s,
every wide run faster). Details in the receipt.

GLM-5.3-Flash's DSA select (tf_glm_select_rows) gives each row a 128-thread block, so a decode step's few rows over
a long context read every pool on a handful of SMs. tf_glm_select_rows_wide runs the same radix select with 1,024
threads a row and gives the same bytes; cuda_sparse launches it for at most 48 rows over buckets of 4,096 pools or
more. It is registered with the kernel contract as a replacement of tf_glm_select_rows.

  • Why the bytes cannot change: the block size only sets how many pools a pass reads (8 a thread). The radix digits
    are counts, and the picks leave through an index-order block scan, so every block size gives the same pools in
    the same order. The 128-thread entry point is the same template at 128.
  • Where it launches: one wave of GB10's 48 SMs (48 rows) and buckets from 4,096 pools. Below 4,096 pools the
    128-thread kernel is as fast or faster; past 48 rows the wide form needs a second wave and the gap narrows, so the
    rule keeps the 128-thread kernel there (it stays served, and as select_row<128> it has its own launch record).

Times of one launch on a GB10 (tf-cuda-test glm-ref, which also checks both kernels' bytes and the host's rule at
every row):

rows, position (bucket) wide 128 threads
1, 1,500 (1,024) 0.0097 ms 0.0067 ms
4, 6,000 (2,048) 0.0101 ms 0.0104 ms
1, 12,000 (4,096) 0.0099 ms 0.0135 ms
1, 24,000 (8,192) 0.0113 ms 0.0238 ms
1, 136,000 (65,536) 0.0422 ms 0.1353 ms
32, 136,000 0.0438 ms 0.1352 ms
48, 136,000 0.0453 ms 0.1356 ms
64, 136,000 0.0867 ms 0.1410 ms
128, 136,000 0.1311 ms 0.1547 ms
1, 500,000 (131,072) 0.1270 ms 0.4745 ms

In a profiled decode step at 136k tokens the 128-thread select took 1.44 ms of the 35.3 ms step (about 11 launches),
so the wide form should save about 1 ms a step there.

Receipt

  • Environment: TensorFold 91bd6bef5 against main 1b4f2f979 (head 351ede169 adds only the 128-thread select's
    launch record); three GB10 (DGX Spark class, sm_121, 128 GB unified
    memory) at TP=3 over a switchless RoCE ring; CUDA driver 580.178.04, nvcc 13.3.1 for the served build and nvcc
    13.0 and 13.3 for gate 5; GLM-5.3-Flash XSFP4 (Bizuayeu/GLM-5.3-Flash-XSFP4 at 88736f9). Each build served
    with --parallel 4 --prompt-cache-gib 0, launched in the order main, this head, another branch twice, this head,
    main.
  • Exactness: bench_concurrent.py --alone --serial at levels 1 and 4 (one rep, not the example's three, to fit the
    window): every cell equal alone and serial on both builds. token_sha256 is the same in every launch for both
    decode prompts and all three phases of the 136k prompt. Before the PR, on a measuring build with the same kernel
    behind a switch, a 136,182-token prompt's 600-token reply (drafts off) had the same token_sha256 in all eight
    runs, wide and 128-thread.
  • Decode speed before and after (bench_openai.py --tokens 256 --reps 3 --temperatures 0, medians): main 57.58 and
    57.55 tok/s, this head 57.58 and 57.50 on fibonacci-raw; main 44.83 and 44.77, this head 44.83 and 44.75 on
    gpu-chat-no-think. After the 136k prompt (bench_cache.py --tokens 256, its three phases): main 57.75 tok/s
    mean (57.58 to 57.95, n 6), this head 58.80 (57.80 to 59.21, n 6), +1.8%. The measuring build above (drafts off)
    gave 28.73, 28.71, 28.77 and 28.76 tok/s wide against 28.07, 28.06, 28.05 and 28.06 with the 128-thread kernel
    (+2.4%, alternated, every wide run faster).
  • Prompt speed before and after (bench_cache.py --require-cold --streams 1 on a 136k prompt; all three phases are
    cold at --prompt-cache-gib 0): main 87.91 s mean (87.25 to 88.62, n 6), this head 87.69 s (87.34 to 88.71, n 6).
    A prompt's last chunk can have 48 rows or fewer, so its select can take the wide form.
  • Tests run:
    • kernel-contract on a GB10 (nvcc 13.3, sm_121, the runner at the build's default): SUMMARY 341 kernels: 341 pass gates 1-4, 0 fail, 5 known findings; 94 controls: 94 red as planted, 0 missed; 231 limit problems. The wide
      select passes nonfinite, repeat and shapes ("8 shapes bit-equal to the kernel it replaces") and declares no sums;
      its live limits match the record ("1024 threads launched, the driver allows 1024; 62 registers, 1296 + 0 shared
      bytes (recorded 62, 1296)"). None of the limit problems names it: they are other kernels' live registers against
      their records, and the records against sm_80, 86, 89 and 120.
    • Gate 5 (kernel-contract-limits over fatbins for sm_80, sm_86, sm_89, sm_120 and sm_121): the wide select fits
      on all five with nvcc 13.0 (32, 61, 61, 54 and 61 registers, 1,296 static shared bytes, no local memory; sm_80
      keeps an 8-byte stack frame at 32 registers) and with nvcc 13.3 (62 registers on sm_120 and sm_121). The
      128-thread select fits on all five with nvcc 13.0 and 13.3 alike (45, 54, 54, 48 and 48 registers, 1,072 static
      shared bytes), and with its record both read it as current.
    • tf-cuda-test glm-ref: pass (the 12 select cases above, byte-equal and the host's rule).
    • zig build test-kernel-contract 111/111, zig build compile-cuda-tests 10/10, zig fmt --check, the lean
      check (no new problems against main), zig build test 4,249 of 4,266 passed, 17 skipped, none failed.
    • On 351ede169: zig build test-kernel-contract 111/111, test-glm-contract-host 7/7, compile-cuda-tests
      10/10, zig build test 4,248 of 4,266 passed, 17 skipped, 1 failed (cuda_cost_identity, which fails on main
      the same way on that host's coarse file times), zig fmt --check and the lean check (no new problems against
      main).
  • Not run, and why: Metal and HIP, which this kernel does not reach. GPUs other than GB10 (sm_80, sm_86, sm_89 and
    sm_120 have gate 5's compile records only). zig build test-gradient-serving, which already fails on main at its
    first stale pin (zig/kernels/cuda/contract_controls.cu); its pin for glm_glue_index.cuh was already stale too.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off. (Only the
    select's launch changes.)
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo.
    (Concurrent equals solo in the receipt, and the select's bytes are the 128-thread kernel's.)
  • No precision traded for speed. If the bits change, the description says which sums change and why. (The
    select's bytes are the 128-thread kernel's; the contract checks them.)
  • A new or changed GPU kernel is registered with the kernel contract (docs/kernel-contract.md); each backend's
    SUMMARY line is in the receipt. (CUDA only.)
  • Prompt processing is no slower than the last release. (87.69 s against 87.91 s on the 136k prompt.)
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. (The contract
    entry and the glm-ref cases need the wide kernel; without a GPU the runner is not run.)
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.

@Bizuayeu

Copy link
Copy Markdown
Contributor Author

Updated the description: it now opens with the measured effect on my hardware, prompt and decode before and after against main, taken from the receipt below. No code change.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks, Bizuayeu. The argument holds: with digit counts from per-warp histograms and the picks leaving through an
index-order scan, the block size only changes how many pools a pass reads. The replacement check against the
128-thread kernel and the host rule covers it well.

On main b30d5b9d3 it passes fmt, lean (no new findings), zig build test, test-kernel-contract and the GLM host
steps, and the Linux CUDA cross-compiles build. It also applies cleanly beside #1404 and #1423.

One change, please. tf_glm_select_rows is now select_row<128>, so it's a changed kernel, and it still serves
every prompt chunk and every decode step outside the wide rule. Our rule is a record for every launch of a new or
changed kernel. Please add its launch record (glm_glue tf_glm_select_rows, 128 threads) with gate 5's numbers for
the five SMs under 13.0 and 13.3, so gates 4 and 5 check it live too.

We'll then run kernel-contract and glm-ref on a GB10, and decode after a 136k prompt at TP=2 on NVFP4.

Heads-up: #1449 touches the same contract files, so whichever of the two lands second will need a small rebase.

@Bizuayeu

Copy link
Copy Markdown
Contributor Author

Please add its launch record (glm_glue tf_glm_select_rows, 128 threads) with gate 5's numbers for the five SMs under 13.0 and 13.3, so gates 4 and 5 check it live too.

#1424 (comment)

Added in 351ede1, in glm_cuda_records.zig's cuda set. Gate 5's numbers, from kernel-contract-limits over this tree's fatbins:

registers a thread sm_80 sm_86 sm_89 sm_120 sm_121
nvcc 13.0 45 54 54 48 48
nvcc 13.3 45 54 54 48 48

Static shared bytes are 1,072 and the bound 128 on every SM under both, so 13.3's set needs no entry for it. With the record in, both runs read it as current on all five SMs, and each run's problem count drops by exactly those five lines (13.0: 87 to 82, 13.3: 564 to 559); the rest are other launches' records. On 351ede1: zig build test-kernel-contract 111/111, test-glm-contract-host 7/7, zig fmt --check and the lean check (no new findings).

If #1449 lands first, I'll rebase this one onto it.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks: the 128-thread tf_glm_select_rows now has its launch record at all five SMs, and the byte-equality argument holds. At T=128 the template keeps the old pool stride, integer histograms, four-warp scan and picks; T=1024 changes the work assignment, not a floating-point reduction; and the contract compares wide against narrow and against independently sorted host picks.

The remaining gate is ours: GLM-5.3-Flash NVFP4 at TP=2 on our GB10 pair, main against this branch, with token hashes, concurrency and the prompt's final short chunk (the wide rule reaches it too). It's queued behind a long correctness run on the pair, so expect it later this week.

ashhart pushed a commit that referenced this pull request Oct 27, 2026
… tf_glm_scores and tf_glm_select_rows (TF_GLM_INDEX_CUDA)

The kernels are ported from BadAd84's Python patches 0101 (one-pass top-K pools) and 0105 (pool scores), for #1430.

tf_glm_index_scores keeps tf_glm_scores' arithmetic exactly (the mma chain over the 8 k16 steps from zero, the
relu, the head sum and its shuffle order) and changes only the data movement: a CTA of 8 rows shares each staged
64-pool key tile instead of every row staging its own. tf_glm_index_pools replaces tf_glm_select_rows for a prompt
block: a strided-sample floor, one order-preserving compaction into shared memory, then the same radix select and
ascending write over the candidates, or over the whole row when the candidates miss [512, cap]; the candidate set
holds the 512 best and every tie at the 512th, so the pools are the select's, ties to the lower pool.

TF_GLM_INDEX_CUDA=1 takes both for chunks of 64 or more rows, =scores only the scores; unset, nothing changes.
A build without the glm_index fatbin logs and keeps the existing launches. Decode and #1404/#1424's areas are
untouched; the hook is two branches in selectTokensProfile and an optional field of glue.Set.

Host: tools/glm_index_emulate runs both kernels as host C++ (real barriers, warp exchanges, ldmatrix and mma
models) against plain models, in test-glm-index-host and test-cuda-host. GPU: tf-cuda-test glm-index-check compares
selectTokens with and without the kernels byte for byte at 1k-262k pools, plus tied scores.
@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

#1449 (the GLM prompt indexer) landed on main, and this branch now conflicts with it in docs/kernel-contract.md, zig/src/core/contract/glm_cuda_records.zig and zig/tests/cuda/contract_tests.zig. Both add GLM records and contract kernels in the same places, and main's contract_tests.zig now registers glm_index_k.Scores and contract_glm_pools.Fast. Could you rebase onto main, keeping both sets of records and both registrations?

The engine change itself merges cleanly, so our GB10 pair run (main against this branch, as above) is queued on a merge that keeps main's contract files. The contract run will follow on your rebased head.

@Bizuayeu

Copy link
Copy Markdown
Contributor Author

Could you rebase onto main, keeping both sets of records and both registrations?

#1424 (comment)

Rebased onto main (a1e6d34): the same three commits, now 8108f9f, 40aed7e and 557221b. glm_cuda_records.zig keeps both sets, main's two glm_index launches and this branch's tf_glm_select_rows and tf_glm_select_rows_wide, in cuda and in cuda_133. In contract_tests.zig I kept main's contract_glm_pools.Fast and this branch's glm_index_k.Index, which registers tf_glm_scores itself, so main's separate glm_index_k.Scores lines go; with both, tf_glm_scores would be registered twice, as on #1449. docs/kernel-contract.md names the prompt indexer and the wide select together in both of its sentences.

The engine change didn't conflict: with TF_GLM_INDEX_CUDA=1 a chunk that the indexer's pools take still skips the select (cuda_sparse.zig:93-97), and everything else reaches the same narrow-or-wide rule as before.

On the rebased head, compile-cuda-tests (x86 and -Dtarget=aarch64-linux-gnu), test-kernel-contract, test-glm-contract-host, test-glm-index-host, fmt and lean (428 problems, as on main, none new) pass here.

@ashhart

ashhart commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Our GB10 pair run is done: GLM-5.3-Flash NVFP4 at TP=2 on two GB10s, main against your select change merged onto main (its kernel and engine files are byte-identical to your rebased head), alternating main, this branch, this branch, main, with a 65,536-token window and two streams.

  • Tokens: every cell matches main, 52 of 52, including each stream against its run alone.
  • Speed: prefill at 2k, 8k and 32k and decode at one and two streams match main within noise, and one stream of code decodes 0.5% faster. One of the two runs of this branch was slow in every cell, including prefill, which this change doesn't touch; the other matches main in every cell.

The gain you measured after a 136k-token prompt is beyond this run's window, so it rests on your receipt, which fits the launch timings. What's left on our side is the kernel contract on a GB10 for your rebased head, once the pair finishes its current run; then we land it. Thank you, @Bizuayeu.

tf_glm_select_rows runs a 128-thread block a row. A decode step has a few rows, so over a long context's
bucket four warps a row read every pool while most SMs idle. tf_glm_select_rows_wide runs the same radix
select with 1,024 threads: T only sets how many pools a pass reads (8 a thread), the digits are counts and
the picks leave through an index-order scan, so every T gives the same pools in the same order. cuda_sparse
launches it for at most 48 rows (one wave on GB10's 48 SMs) over buckets of 4,096 pools or more.

glm-ref checks both kernels' bytes and the host's rule from the 1,024- to the 131,072-pool bucket at 1 to
128 rows, and prints both launches' times.
…r byte

The wide select registers as a replacement of the 128-thread kernel (its bytes on the same scores), with a
stated NaN rule checked against the host's picks at gate 1: order keys descending, -0 as +0 and any NaN
above +inf, as the GPU's s + 0 gives one positive NaN. The index scores and the select register through one
owned group, so the runner stays within the source-size limit, as the Gemma tower's do. Gate 5's records
come from kernel-contract-limits over sm_80 to sm_121 fatbins of nvcc 13.0 and 13.3; 13.3 differs on
sm_120 and sm_121.
…ged kernel

It is now select_row<128>, and it still serves every prompt chunk and every decode step outside the wide
rule, so gate 5 checks it as it checks the wide form. The numbers come from kernel-contract-limits over
sm_80 to sm_121 fatbins of this tree: nvcc 13.0 and 13.3 give the same registers and static shared bytes on
all five SMs, so 13.3's set needs no entry for it.
@ashhart
ashhart force-pushed the pr/glm-select-wide branch from 557221b to b8f755d Compare October 28, 2026 14:42
@ashhart
ashhart merged commit ea01ae1 into ashhart:main Oct 28, 2026
8 of 9 checks passed
@ashhart

ashhart commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks @Bizuayeu, this is merged. The kernel contract on a GB10 (sm_121) passes for your rebased head: the wide select is registered as a byte-for-byte replacement of tf_glm_select_rows and passes gates 1-4, all 312 planted controls go red as planted, and the launch limits at all five SMs are unchanged. With the TP=2 pair run from this morning (52 of 52 cells the same tokens as main, speed unchanged up to a 65k window), that's everything. The gain after long prompts rests on your TP=3 receipt.

@ashhart

ashhart commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thanks @Bizuayeu: the 1,024-thread DSA select shipped in TensorFold 1.1.9.

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