Add sm121 (GB10) tier to the 4-bit GEMM dispatch heuristic - #2030
Add sm121 (GB10) tier to the 4-bit GEMM dispatch heuristic
#2030matthewdouglas merged 2 commits into
Conversation
|
Thanks for the PR. In general it looks good. I would expect the custom kernels to do well considering the lower memory bandwidth on GB10 so the result makes sense in general. I'm wondering if you can share your benchmark figures and exact methodology? The other part I've wondered here, is there's an additional layer of dispatch decisions that I was not able to test on the real hardware. Besides deciding to use one of our fused kernels, there's also decisions on the C++ side on which kernel to use: SIMT, or MMA with different configurations. It's possible we're not selecting the best and leaving some performance on the table since this also hasn't been calibrated for GB10 and there are arch-specific decisions in there. But this doesn't really block merging this if we're confident there's no significant performance regressions being introduced with it. |
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
|
Thanks for the quick look. Happy to share everything, and I also went ahead and measured the SIMT/MMA layer you mentioned, since I have the hardware. Methodology and figures first, then that data. MethodologyHarness: for each (shape, M, dtype) cell I time the two paths directly, bypassing dispatch entirely, so the measurement is the kernels rather than the heuristic:
The two are timed interleaved in the same loop (custom, fallback, custom, fallback, ...) so clock drift and thermal state hit both sides equally. 8 warmup iterations, 25 timed iterations per side, per cell. Reported as median with min/max. Setup: nf4, blocksize 64, bf16 and fp16 activations, 14 (N, K) shapes taken from real projection dimensions (Llama2-7B, Llama3-8B, 13B-class, Qwen2.5-1.5B), M swept 1 to 1536. bitsandbytes 0.50.0, torch 2.13.0+cu130, driver 580.142, GB10 (48 SMs). Five independent sessions over two days (four full sweeps plus one confirmation run). Numerical agreement between the two paths: max abs diff 0.0625 across all cells, i.e. bf16 rounding, with relative mean diff ~0. FiguresThe region this PR changes (>= 1 wave, M in [48, 256], both dtypes, 120 cells):
Per-shape medians over that region (bf16):
Why the cap stayed at 256: at M=384 the custom kernel still wins 18 of 20 wave-eligible cells (median 1.34x), and at M=512 only 15 of 20 (median 1.07x). The consistent loser is the smallest tier shape, 3072x3072 at M=384: 0.86x, 0.93x, 0.93x, 0.94x, 0.93x across the five sessions. So there is real headroom above 256, but it is shape-dependent, and I would rather a second GB10 confirm that before widening
The SIMT / MMA layerI measured this too, same rig. Built 1. On this arch the dispatcher picks the slower kernel in 32 of 260 cells (12%). 2. The largest losses are at M=4 on large weights, where stock takes MMA but SIMT wins:
(fp16 rows sit within a few percent of bf16 throughout.) The root cause looks like exactly what you suspected: at M=4 the SIMT route needs 3. The reverse direction exists but is smaller: on 2048x2048 at M=6-8 the 4. Everywhere else, 228 of 260 cells (88%), the current choice is the measured winner, including the whole M>=48 region this PR touches. And to be explicit about the regression question: all 32 mispicked cells sit at M<=16, where this PR changes no routing at all, so they behave identically on main with or without this change. Raw JSONs (all 780 cells, min/med/max per cell) and the harness are available if useful; happy to rerun any cell or grid shape under different conditions, and happy to look at what calibrated sm121 rules would look like as a follow-up if there is appetite. |
|
Sorry, I resolved that thread before actually making the change. Trimmed in b09a98c: the sm121 block now just states the calibration scope, with the history and the cross arch comparison dropped. The SIMT/MMA follow up offer from my earlier comment still stands whenever you want it. The push put the workflow runs back into action_required, so the checks are held. Could you approve the run when you get a chance? |
|
Thanks for the details and the data. I think this is reasonable to do as a good first calibration step as it seems like a strict improvement over what was shipped in v0.50.0. Within the MMA kernel, there's additionally a tile selection mechanism, which can choose from about ~10 different tiling configurations. It's possible there's room left to improve for sm121 within that too, not just from adjusting the MMA-vs-SIMT decision. Would be happy to look at follow-ups here. For one of those a simple change to the |
0b3a226
into
bitsandbytes-foundation:main
|
Thanks for merging it, and glad it reads as a strict improvement over v0.50.0. wide_n_simt for sm121 Agreed this is the concrete next step. It matches what the SIMT/MMA measurement from my last comment showed: 32 of 260 cells mispicked, all at M<=16, and the root cause note there already flagged wide_n_simt (along with highbw_gddr) as one of the clauses that enumerate sm86/sm89/sm120 but stop short of sm121. Looking at the code now, the sm120 branch in wide_n_simt gates on cc_maj == 12 && cc_min == 0 && num_sms >= 48, and GB10 already clears the SM count on its own, so the change on the table is adding a cc_min == 1 case alongside it, not a restructure. I don't want to hand you a diff off just that though. The wider three-tier version of this PR got thrown out for skipping exactly that step, so before I propose anything here I'll enumerate the decisions the sm121 addition changes across archs, dtypes, and shapes, confirm nothing outside wide_n_simt moves, and benchmark the flipped cells specifically since the 260 cells I already have were measured against the stock heuristic, not a modified one. I'll bring it as its own follow-up PR once that's done. Tile selection in the MMA pathNew to me, hadn't looked at that layer before your comment. My SIMT/MMA measurement only covers the family choice, not the tiling once MMA is picked, so I don't have anything to say about it yet. I'll go find where the roughly 10 configs get chosen and treat it as a separate follow-up, same enumerate-and-benchmark approach, rather than bundling it with wide_n_simt. If you already have a pointer to where that selection lives, it would save me some searching, but I can track it down either way. |
|
I went and measured the tile selection layer. Short version: of the three shape clusters GB10 Where it lives
GB10 gets no special casing anywhere in the selector. The only I first walked the selector in pure Python over a 3,380 cell M/N/K grid to find where GB10 lands. All 28 measured cells, grouped by cluster:
MethodI added a Both sides of every comparison below are measured through that same forced path, so the numbers Clocks are not locked on this box ( Two notes for anyone else poking at this. Result 1:
|
| M | N x K | confirmed winner | faster by (bf16 / fp16) | 7-pass recheck |
|---|---|---|---|---|
| 128 | 8192 x 8192 | 64x64-64 |
20.8% / 21.2% | yes |
| 2048 | 4096 x 14336 | 64x64-64 |
15.6% / 19.1% | yes |
| 384 | 5120 x 13824 | stock | stock optimal | yes |
| 512 | 4096 x 4096 | stock | stock optimal | screen only |
| 1024 | 14336 x 4096 | stock | stock optimal | screen only |
The M=384 row is the one I want to flag explicitly. The screening sweep had it as a 14.2% win for
64x64-64 and I had it written up as such. Seven rechecks put the ratio at 0.999, with the
challenger ahead in 2 passes out of 7. It was drift, not a win.
The last two rows are marked screen only because they were never contested, so the recheck never
visited them. The M=512 cell is stable across all its rounds; the M=1024 cell is not, and its
per-round numbers straddle zero, so treat it as "no evidence stock is beaten here" rather than as
a measured null.
Side by side, a cell where the screen held up next to the one where it did not:

So: two wins, three cells where stock is already best, and no monotonic relationship with M
(the wins are at M=128 and M=2048, the nulls at M=384, M=512 and M=1024). A selector can only act
on a rule, and I do not have one here. Reporting it as a non result rather than quoting the two
wins on their own.
Result 3: 32x128-128 has a consistent gap on all 8 cells
This is the one clean positive, and every cell is recheck-confirmed:
| M | N x K | confirmed winner | faster by (bf16 / fp16) |
|---|---|---|---|
| 4 | 14336 x 4096 | 32x64-128 |
35.0% / 34.2% |
| 6 | 11008 x 4096 | 32x64-128 |
15.3% / 18.8% |
| 16 | 14336 x 4096 | 32x64-128 |
34.9% / 38.3% |
| 32 | 8960 x 1536 | 32x64-64 |
78.6% / 79.0% |
Stock picks NT=128 on all four; the measured winner is NT=64 on all four. Consistent across both
dtypes, no counter-example in the set.
On the interaction with #2039: if that lands, the M=4 and M=6 rows stop reaching select_tile()
at all, since wide_n_simt diverts them to SIMT. The M=16 and M=32 rows do not, because
wide_n_simt requires M<=6. The largest gap in the table by a wide margin (M=32, 78.6%) is one of
the retained rows, so the overlap shrinks the affected shape space without removing the effect.
On mechanism, briefly
I did look for one, and I do not have it. Wave quantization plus a tall-K term fit the screening
numbers cleanly, and then the M=384 recheck removed the cell that story depended on. I would
rather say I have no mechanism than quote one that a rerun already contradicted.
What I am asking
Is sm121 tile calibration worth pursuing from your side? I am not proposing a patch. One box, 14
shapes and one clean cluster is not enough for a calibration change, and result 2 is a live
demonstration of why: a rule that looked reasonable on the first pass, and a 14% "win" inside it
that turned out to be measurement noise.
If it is worth pursuing, the obvious next step is a designed sweep to check whether the
32x128-128 gap holds outside the shapes I happened to pick, since that is the only result here
carrying its own weight. If it is not worth it right now, that is a completely fine answer and I
will leave it here.
Raw per cell JSON, the recheck passes, the correctness gate results and the harness are available
if useful.
Environment: GB10 (DGX Spark), sm_121, 48 SMs, driver 580.142, torch 2.12.1+cu130, CUDA 13.0.88.




What this does
Adds an
sm121branch to_gemm_4bit_use_custom_cudaso GB10 (DGX Spark) stops falling through to the sm89 tiers, and updates the two comments that say sm121 is unvalidated.Right now sm121 is explicitly routed to the sm89 fallback with a comment saying "uses sm89 fallback below until validated". This is that validation, for the part of the space I could measure reliably.
Why sm121 needs its own tier
GB10 is unified LPDDR5X, so it has far less bandwidth than the GDDR6X parts sm89 was calibrated on. Dequant plus
F.linearstays expensive much further up the M range, which means the custom kernel keeps winning past the sm89 caps.The change is one branch:
Everything below one wave keeps using the shared tiers, because there the crossover is strongly K-dependent and I did not have clean enough data to justify a separate rule.
Safety
This cannot change behaviour on any other architecture, and that is checkable rather than argued. I enumerated the dispatch decisions of the stock and patched functions over a cross product of 10 architecture profiles (sm75/sm80/sm86/sm89/sm90/sm100/sm120/sm121 plus an unrecognized arch), 3 dtypes, and 23 x 11 x 8 M x N x K shapes:
All 216 changes land on the sm121 profile, at M between 48 and 256. No other arch is touched anywhere in the grid. Since it is exhaustive over the enumerated space rather than sampled, it is a proof of no-regression for those shapes, not evidence of one.
Tests
Run against the released 0.50.0 wheel with only this function transplanted, since the source tree ships no compiled library. The function source is byte-identical between the wheel and main (verified by direct comparison), so the substitution is faithful.
test_functional.py -k 4bittest_linear4bit.py+test_autograd.pyThe one failure is
test_fsdp_state_dict_save_4bit, and it fails on stock too, so it is pre-existing on this machine and not caused by this change.Honest limits
Three things I want to be upfront about, because they bound what this PR claims.
The cap is conservative, not exact. I originally thought M=256 was the crossover. It is not: across five measurement sessions, M=384 still favours the custom kernel on 10 of 14 shapes (cell median about 1.2x) and M=512 on 7 to 8 of 14. Within the at-or-above-one-wave region the only M=384 loser is the smallest tier-A shape, (3072, 3072), at 0.86x to 0.94x across five sessions. So M=256 leaves real headroom, and I chose it deliberately rather than pushing to the measured edge on one shape.
Only the at-or-above-one-wave region is calibrated. I had a wider three-tier version of this patch. I threw it away: enumerating it the same way showed it changing decisions at N=2560 in the tall-K shapes, a region where my own measurements do not support a clean rule (the custom kernel swings from about 3x ahead at low M to 0.75x behind at M=384, across four seeds). Two of its three tiers rested on a single measured shape each. This PR is what survived that.
No real model forward was run. All timings are synthetic stacks of real projection shapes, not an actual HuggingFace model, and the machine had an unrelated job resident throughout, so absolute microseconds are upper bounds. The dispatch decisions above are exact; the performance motivation behind them is measured but not production-validated.
Environment: GB10 (DGX Spark), sm_121, aarch64, driver 580.142, torch 2.13.0+cu130, CUDA 13.0.
Happy to add a regression test for the dispatch table, or to widen the cap if someone with a second GB10 can confirm the M=384 numbers.