Skip to content

glm cuda: decode windows' EXL3 experts run on the 16-pair item plan - #2044

Open
BadAd84 wants to merge 2 commits into
ashhart:mainfrom
BadAd84:pr-exl3-items-unconditional
Open

BadAd84 wants to merge 2 commits into
ashhart:mainfrom
BadAd84:pr-exl3-items-unconditional

Conversation

@BadAd84

@BadAd84 BadAd84 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What's slow today

A decode window's routed experts are grouped by tf_exl3_group (a 1024-thread block that scans every expert against every pair) and launched over min(pairs, E) x (n/128) x (mats*sk*ceil(rows/16)) blocks. At the widths a shared verify window reaches, most blocks have nothing to do: at 64 rows and the wide 768-column share that is 55,296 blocks, about 800 of them with work. The group kernel costs 0.054 ms a layer at 24 rows and 0.134 at 64, against 0.005-0.010 for a ranked plan.

Measured on one GB10 with both engines' kernels verbatim over one synthetic EXL3 weight set, per layer at the wide share, every row routed alike (a batching workload):

rows this tree a plan-based launch this change
8 0.206 ms 0.169 0.193
32 0.437 0.247 0.300
64 0.932 0.382 0.506

44 distinct experts a window: 1.033 -> 0.958 ms at 24 rows and 1.221 -> 1.058 at 48 (a plan-based launch: 0.915 / 0.990).

What the change is

The prompt path already groups pairs into items of at most 16 through grouped.Router, and the grouped kernels already read item triples in their standard mode (exl3_grouped.cuh: standard, skip, direct pair indices). Decode only has to use them, so this is plumbing, not a kernel change:

  • exl3_experts.runWith(..., ey, it: ?Items) runs the same single implementation for both paths; items = it != null is the only switch, and run is a one-line delegation to it with null. The plain branch inside is unchanged line for line.
  • the family hands decode exactly what its block already holds - the Router the prompt path uses and the same plan buffer - so decodeItems runs for every non-prompt window.
  • the decode arena is sized for the wider 9-slot layout (decodeBytes, the larger of the two), so no pool changes at GLM's shapes.
  • a supplied plan is refused with error.TileNotBuilt for a non-mcg codebook, a non-zero wts/out, a zero ey, or a null plan array: the item kernels read one codebook a launch and write the caller's slot rows.

Nothing under zig/kernels/ changes.

Proof it's the same bits

tf-cuda-test glm-exl3-items compares the item decode's packed ey slot rows against the plain launch's own packed rows, over distinct windows (1, 2, 3, 8, 17 rows) and lockstep windows (16, 32, 48, 64 rows) at two widths, and again at GLM-5.3-Flash's served size (dims 4096 at widths 768 and 1024), with a plain-twice control and a NaN check: 0 of 66 checks differ on our tree, where a mutant that reads the plan's members one slot off fails exactly the item comparisons and none of the other cases.

What it's worth

Our fork's serve, TP=3 on three GB10s, production's probe, ABBA, every reply digest equal, the pool at boot equal:

streams 1 2 4 6 8
short decode +2.4% +1.7% +3.9% +5.2% +6.5%
forks +1.5% +1.9% +3.6% +1.8% +1.8%

One-stream prose +1.2%, cold and next-turn prompt cells inside their floors. A run priced on main's own base is running now and will be posted with the numbers.

Files

  • zig/src/cuda/exl3_experts.zig (+105/-30): Items, runWith, the layout split, the standard-mode launches, down_combine on ey.
  • zig/src/families/glm/cuda_exl3.zig (+26): decodeItems called for every non-prompt window, decodeBytes.
  • zig/tests/cuda/glm_exl3_items_tests.zig (new, 209) and main.zig (+3): the check.

Checks

fmt, check-native-cuda-host, compile-cuda-tests, test-cuda-host, every sm_121a fatbin through nvcc, and lean reports 0 new against main. glm-exl3-items runs on any GB10.

A decode window's routed experts run on the same item plan the prompt path and
the NVFP4/FP8 MoE path already use, instead of tf_exl3_group plus the maxu x
member-tile grid: the 9-slot picks are grouped by the Router into items of at
most 16 pairs and the grouped GEMVs launch in standard mode on grids
maxItems x (n/128) x (mats*sk), so at 64 rows ~325 blocks do work instead of
~55k and the one-block O(experts x pairs) group scan becomes the ranked plan.
No 9->8 compaction and no copy back: the down epilogue writes ey's routed slot
rows in place through tf_exl3_down_combine.

The grouped kernel and tf_exl3_down_combine already take item triples in
standard mode and a caller-chosen slot-row base, so no kernel change is needed:
the plan reaches the decode as the same Items the prompt path takes, with the
family's Router and the plan buffer the block already holds.

Bit-exact: the glm-exl3-items GPU check compares the item plan's ey against the
plain grouped decode byte for byte over distinct and lockstep (16/32/48/64-row)
windows, and calls the plain launch shape directly so both paths stay covered.
A wrong plan (a members offset off by one, a bad item first/count, the shared
item not skipped) routes a slot to the wrong expert, so ey's bytes differ.

A plan is refused (error.TileNotBuilt) for a non-mcg codebook, a non-zero wts
or out, a zero ey, or a null plan array: the item kernels read one codebook a
launch and write the caller's slot rows.

Files:
- zig/src/cuda/exl3_experts.zig: Items, runWith on the plan (standard mode), and
  fusedBytes/fusedScratch for the 9-slot layout without y.
- zig/src/families/glm/cuda_exl3.zig: the decode branch on decodeItems, and
  scratchBytes sizing a decode arena with decodeBytes, the larger of the plain
  8-slot layout and the 9-slot one, so no pool changes at GLM's shapes.
- zig/tests/cuda/glm_exl3_items_tests.zig: the items decode against the plain
  launch, case by case.
@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @BadAd84, this is a neat change: decode now reuses the item plan and grouped kernels the prompt path already
has, with no kernel touched.

I read it on current main. It applies cleanly, and the lean check is unchanged.

  • run keeps main's launches exactly, so the other EXL3 callers are untouched.
  • On the item path, the shared slot's sentinel is skipped by the router and by every kernel, so its ey row is never
    written.
  • Each routed pair is rotated, summed and scaled exactly as before, and lands where the old copy put it.
  • The arena and the plan buffer already cover the 16-pair plan.

Your 0-of-3,984 test is the right check.

Two small things:

  1. Could the GPU test add one case at GLM's served size (dims 4,096, width 768 or 1,024)?
  2. Please post the serve run on main's base when it finishes.

We don't have a GLM EXL3 checkpoint on our GB10s yet, so your serve run against main's base is the device receipt for now: equal token hashes and decode speed at 1 to 8 streams with
drafts on.

BadAd84 added a commit to BadAd84/TensorFold that referenced this pull request Nov 3, 2026
The staged check only ran dims 512 at widths 256/640. Adds dims 4096 at the two
widths a rank really meets (768 and 1024), one distinct window and one lockstep
window each, so the item plan is exercised at the shape the serve uses (reviewer
ask on ashhart#2044). Dims is threaded through the weights/layer/pack helpers; the small
cases are unchanged and the comparison count grows from 36 to 44.
@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @BadAd84 for adding the served-size case so quickly. Dims 4,096 at widths 768 and 1,024 is the shape a rank actually runs, so that ask is done.

Three small things before it lands:

  1. The new 3-line comment at the top of the shape loop (lines 174-176) adds two lean problems. One line keeps the check at its current count.
  2. lock = rows >= 16 turns the 17-row case into a lockstep window. Could you keep it distinct? It was the only distinct window spanning two tiles.
  3. Please post the serve run on main's base when it finishes, and paste the RESULT glm-exl3-items line from the GB10. The test counts the NaN checks too, so it should print 66 comparisons, not 44 or 3,984.

The engine code is unchanged from the version we read, and we're happy with it.

BadAd84 added a commit to BadAd84/TensorFold that referenced this pull request Nov 3, 2026
…derived

Reviewer on ashhart#2044: lock = rows >= 16 turned the 17-row window lockstep, and it is the
only distinct window that spans two 16-pair items. Both shapes now carry an explicit
case list (base_cases, served_cases) instead of deriving the flag from the row count,
so 17 rows is distinct again and the served-size shape keeps 8 distinct / 64 lockstep.

The RESULT line counts the NaN tallies as well as the byte comparisons, so it prints
66 for this set: 18 base cases x 3 + 4 served cases x 3.
@BadAd84

BadAd84 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, both are fixed.

  1. The comment is one line now. I had already pushed that as 98317c364 just before your review landed, so the three-line block you saw is gone in the current head.
  2. You are right about the 17-row window, and that was my bug: lock = rows >= 16 made it lockstep. Both shapes now carry an explicit case list instead of deriving the flag from the row count, so 17 rows is distinct again, and the served-size shape keeps 8 rows distinct and 64 lockstep. That is 4cda259bc.
  3. The gates on that head: lean_new 424 against 424, 0 new; fmt clean; compile-cuda-tests exits 0.
  4. Your arithmetic on the count is right: the RESULT line includes the NaN tally, so this set prints 66, which is 18 base cases and 4 served cases at three checks each. I will paste the actual line as soon as the GB10 run finishes, with the serve run against main's base in the same message.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @BadAd84, that's all three test points handled: the one-line comment brings lean back to its count, 17 rows is
a distinct window again, and the case lists make the 66 easy to read off.

Two text fixes when you next push: the PR body's "0 of 3,984" should read 0 of 66, and commit 2's "36 to 44" is
now 66 (squashing commits 2-4 would tidy that). You can also drop the "reviewer" notes from the two test comments.

Then it's the serve run on main's base, plus the RESULT glm-exl3-items line from a GB10, and we can land it.

Dims 512 alone only asked the item plan about small matrices. The check now also runs
dims 4096 at the two widths a rank meets, 768 and 1024, one distinct window and one
lockstep window each, with dims threaded through the weights, layer and pack helpers.

Each shape carries an explicit case list, so 17 rows stays distinct: it is the only
distinct window that spans two 16-pair items. The RESULT line counts the NaN tallies
beside the byte comparisons, so this set prints 66 checks: 18 base cases and 4 served
cases at three checks each.
@BadAd84
BadAd84 force-pushed the pr-exl3-items-unconditional branch from 4cda259 to 06fdcc8 Compare November 3, 2026 17:39
@BadAd84

BadAd84 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Squashed commits 2-4 into one, with the count corrected to 66. The only content change is two comment lines that drop the reviewer notes.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @BadAd84, the squash is clean: the code is exactly what we reviewed, and only the two comment lines changed. Commit 2's message now gives the 66 and how it adds up.

One text fix left: the body's "Proof it's the same bits" section still says 0 of 3,984; it should say 0 of 66.

After that, we only need the serve run on main's base and the RESULT glm-exl3-items line from a GB10, and this can land.

@BadAd84

BadAd84 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

RESULT glm-exl3-items: 0 of 66 comparisons differ
PASS glm-exl3-items: 66 comparisons equal the plain decode byte for byte

ai-engine:items2 built from 06fdcc8bd (main f23603801 plus this PR), single GB10, no checkpoint needed. The serve run against main's base is still filling its cells; its table follows.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @BadAd84. The body now says 0 of 66, and the RESULT glm-exl3-items line is exactly what we wanted. The code is what we reviewed. The last thing is the serve run against main's base: equal token hashes, and decode at 1 to 8 streams with drafts on. Once that table is posted, this lands.

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