Skip to content

[Engine] Maxwell: the dense projections' FP16 GEMMs as cublasSgemm on FP32 copies (1.18-1.65x, bitwise) - #3596

Closed
msprengholz wants to merge 4 commits into
Niko1221:mainfrom
msprengholz:feature/maxwell-dense-f32-gyro
Closed

msprengholz wants to merge 4 commits into
Niko1221:mainfrom
msprengholz:feature/maxwell-dense-f32-gyro

Conversation

@msprengholz

@msprengholz msprengholz commented Oct 21, 2026 •

Copy link
Copy Markdown

The problem

Two things cost time on a Maxwell card (compute capability 5.x), and both are measured here on a GTX TITAN X:

  1. The dense projections' FP16-operand GEMMs. They go through cuBLAS's Maxwell kernels
    (maxwell_sgemm_fp16_32x128, _64x64), and there is no tensor-core path for that call on such a card. They are
    the second biggest compute item of a prompt read: 1.93 s of the 9.8 s the GPU spends reading a 1,024-token
    prompt. As on the P40 (compute capability 6.1), the same work as cublasSgemm on FP32 copies of the FP16
    operands is faster - 1.18-1.65x for the big shapes on this card - and bit for bit the same.
  2. The dense weights' MMVQ. The engine has an interleaved small-batch matvec (a warp takes 2 or 4 rows, so the
    weights and the activations are reused across rows) with a table per architecture - a P100, a P40, a V100 - but
    il_arch_ok stopped at compute capability 6, so a Maxwell card never used it. That is where the gain is on a
    card without __dp4a: the kernels are integer-bound, so the reuse pays.

What this changes

  • src/prefill/gemm.cu: the route (STRATA_DENSE_F32=1, opt-in, unchanged as a switch) was gated to compute
    capability 6.x; it now also accepts 5.x, the cards whose cuBLAS has no tensor-core path for this call.
  • src/kernels/cuda/native_mmvq.cu: the interleaved matvec's table for this architecture, read off
    mmvq_il_parity --bench --emit-table on the card (the repo's documented procedure), with its own opt-in switch
    (STRATA_SM50_TABLE=1).
  • The default path is untouched in both cases: without the environment variable, nothing changes.
  • The commit also carries the profile that found them and the tools that made it: an Nsight Systems trace of a real
    run (every kernel with its time and count, the copies, the idle gaps), the tool that turns a window of the trace
    into the tables, the A/B harness that measures a candidate, and the ranked candidate list with every candidate's
    number - the nine that measured slower and were dropped included.
  • docs/OLDER_GPUS.md and docs/GYRO.md: what the profile says, with the numbers, the limits of the card, what was
    not measured, and the ranked next steps in the order the profile gives them.

The first commit of this branch is the previous pull request (the Maxwell support the experimental CUDA 12 build
needs); the changes themselves are the two commits after it.

The profile, in one table (the TITAN X, a real 1,024-token prompt and 201 decoded tokens)

window length GPU busy GPU idle real compute PCIe copies
prompt 27.0 s 9.77 s (36 %) 17.2 s (64 %) 9.8 s 0.3 s
decode 53.0 s 49.2 s (93 %) 3.8 s (7 %) 9.0 s (17 %) 0.29 s

The decode's 40.8 s of "busy" is one thread spinning on the doorbell (wait_flag_ge_kernel) while the host waits for
the expert data off the SSD: the experts are 18 GiB, the card has 12 GiB of VRAM and the PC 15.7 GiB of RAM. The
kernels are 9.0 s: the trellis expert kernels 3.30 s, the dense weights' MMVQ 1.72 s, the GDN hyper-connection
1.01 s, the head and the embedding 0.43 s, the attention 0.11 s, the MTP draft head 0.02 s. Every one of them is
named with its source file and its job in the report, and the four things this card does not have are measured on it
(no __dp4a and no vdp4a; no cp.async - the binary has zero CP.ASYNC, so the staged hyper-connection read is
the "split" variant only, although the start-up message claims otherwise; no tensor cores; 48 KB of shared memory per
block, which the GDN's staged down projection uses 40 KB of, so an sm_50 SM holds two of them where the registers
would allow three).

Validation

Environment: GeForce GTX TITAN X (sm_52, 12,288 MiB, 24 SMs), driver 580.178.04, Linux 6.2 (CachyOS), CUDA 12.9 at
/opt/cuda, host compiler g++-14. Engine: the experimental CUDA 12 build
(-DCMAKE_CUDA_ARCHITECTURES=50 -DSTRATA_EXPERIMENTAL_SM60=ON), engine 0.1.46.1. Model: Gyro-S (TQ1_0) with the
repo's strata-gyro-s.json (MTP on, --spec 4, --expert-cache auto, --resident-budget-gib 6, --kv int8).

Passed

  • gemm_f32_parity --quick and --bench (the repo's own tool, built with this CUDA 12.9): 5,201 chunk sizes bitwise
    equal, and the route 1.18-1.65x faster for the big shapes (qkv 10,240x2,560 at T 7,040: 166.5 -> 120.0 ms; query
    12,288x2,560 at T 7,041: 203.4 -> 149.8; gate 6,144x2,560: 101.0 -> 67.8; out 2,560x6,144: 127.5 -> 77.1), neutral
    from T 560 to 2,048 (0.97-1.06x).
  • mmvq_il_parity --bench --emit-table (the repo's own tool): every case, T 1-8, rows 1/2/4 and the table, bitwise.
  • Through the server, the prompt read: 91.5 -> 95.7 tok/s with STRATA_DENSE_F32=1 (the medians of three repeats, the
    page cache evicted before each run; the two base runs of the same build agree to 0.2 % on that number).
  • With STRATA_SM50_TABLE=1, the engine's own stage timers (STRATA_VERIFY_PROFILE=1): the dense projections'
    stage 8.92 -> 7.76 ms per window (-13 %, better in 3 of 3 repeats), the VRAM expert hits 36.7 -> 34.5 ms (-6 %). The
    request's tok/s does not move, because the GPU is idle 80 % of the time on this card (it waits 190-310 ms per
    window for the experts while it computes about 40 ms) - the change is for the cards where the experts fit.
  • The answers are the same, character for character, in both cases (parity-check.py, the same prompt through the
    server with and without the switch, greedy; parity-answers-dense_f32.json, parity-answers-sm50-table.json).
  • cmake --build build-cuda12 -j 8 - clean.

Failed

  • ctest: ple_parity and platform_memory_test fail in this environment, and they fail the same way without these
    changes: ple_parity wants a Q2_0 model file that is not on this PC, and platform_memory_test wants to lock more
    memory than this shell's ulimit -l (8 MB) allows. The other 66 tests pass.

Not run

  • The HIP and the SYCL engines. The changed lines in src/prefill/gemm.cu are inside
    #if defined(STRATA_EXPERIMENTAL_SM60) (lines 738-794), and that macro is defined only in a CUDA build
    (CMakeLists.txt:216-219); the SYCL build (sycl/CMakeLists.txt:265) compiles the same file without it. The
    interleaved matvec's table in src/kernels/cuda/native_mmvq.cu is CUDA-only source, but that file is in the
    kernel library the HIP build compiles too, and this PC has no ROCm and no Intel toolchain, so I could not build
    them to show it. This pull request is a draft for that reason: if you can build the HIP or the SYCL engine on a
    machine that has the toolchain, that is the check I am missing.
  • The CUDA 13 release engine (only CUDA 12.9 is installed here). The changed lines in gemm.cu are not compiled
    there (the macro is off by default), and the table's switch is off by default too.
  • Any other card. Every number here is this one TITAN X on this PC.

The limits, said plainly

  • On this card the GPU computes 9.0 s of a 53 s decode and waits 40.8 s for the expert data. The kernel work matters
    where the model fits in the card's memory; here the data path is the limit, and the profile says so.
  • Both switches are opt-in. The GEMM route's equality is a property of the cuBLAS build, not of the code: run
    gemm_f32_parity with your toolkit's library first. The matvec table is one card's measurement, so it is opt-in
    too.

…ards

CUDA 13 dropped Maxwell, so those cards need the experimental CUDA 12 engine, and
that engine refused anything below compute capability 6.0.  A GTX TITAN X (sm_52)
is a 12 GB card with enough memory for a small model, and the kernels compile for
it, so the gate was the only thing in the way.

The gate now admits 5.0, setup's card choice admits the Maxwell cards (and still
refuses Kepler, sm_35, for which no engine code exists), and the engine's own
check says 5.0 or newer in the experimental build.  The release CUDA 13 engine is
unchanged: it still needs 7.5.

Measured on a GeForce GTX TITAN X (sm_52, 12 GB, driver 580.178.04, CUDA 12.9):
the engine loads, serves and answers, and the Gyro-S profile in
bench/results/2026-10-21-gyro-titanx is that card.

Validation: tools/test_setup_choices.py (50 tests), tools/test_setup_older_gpus.py
(19) and tools/test_setup_configs.py pass; cmake --build build-cuda12 with
-DCMAKE_CUDA_ARCHITECTURES=50 -DSTRATA_EXPERIMENTAL_SM60=ON is clean.  The HIP,
SYCL and Metal engines are untouched by this change and were not built here (no
ROCm, no Intel toolchain on this PC).
…ies (1.18-1.65x)

On a GeForce GTX TITAN X (sm_52) the dense projections' FP16-operand GEMMs go
through cuBLAS's Maxwell kernels, maxwell_sgemm_fp16_32x128 and _64x64, and they
are the second biggest compute item of a prompt: 1.93 s of the 9.8 s the GPU
spends on a 1,024-token prompt read.  The same route as on the P40 - cublasSgemm
on FP32 copies of the FP16 operands - is faster here, because this card has no
tensor-core path for the FP16 call at all.

The route was gated to compute capability 6.x; it now also accepts 5.x, the
cards whose cuBLAS has no tensor-core path for this call.  It stays opt-in
(STRATA_DENSE_F32=1) and the default path is untouched.

Measured on the TITAN X (sm_52, 12 GB, driver 580.178.04, CUDA 12.9, the
experimental CUDA 12 build, engine 0.1.46.1, Gyro-S with the repo's
strata-gyro-s.json):

  gemm_f32_parity (CUDA 12.9's cuBLAS): 5,201 chunk sizes bitwise; the route is
  1.18-1.65x for the big shapes (qkv 10240x2560 at T 7,040: 166.5 -> 120.0 ms;
  query 12288x2560 at T 7,041: 203.4 -> 149.8; gate 6144x2560: 101.0 -> 67.8;
  out 2560x6144: 127.5 -> 77.1) and neutral from T 560 to 2,048.
  Through the server, the prompt read: 91.5 -> 95.7 tok/s (the medians of three
  repeats, the page cache evicted before each run; the two base runs agree to
  0.2 % on that number).  The answers are the same, character for character.

The profile that found it is in the same commit: an Nsight Systems trace of a
real run (every kernel, its time, its count, the copies, the idle gaps), the
tool that turns a window of the trace into the tables, the A/B harness that
measures a candidate (a warmup request, an evicted page cache, the engine's own
per-window phase split), and the ranked candidate list with every candidate's
number - including the eight that measured slower and were dropped.

The profile's own answer, for the record: on this card the GPU computes 9.0 s
of a 53 s decode and waits 40.8 s for the expert data to come off the SSD (the
experts are 18 GiB, the card has 12 GiB of VRAM and the PC 15.7 GiB of RAM).
The kernels are worth working on where the data fits; here the data is the
limit.  The write-up says so, with the numbers, in docs/OLDER_GPUS.md and
docs/GYRO.md.
…cture

The engine has an interleaved small-batch matvec (a warp takes 2 or 4 rows, so
the weights and the activations are reused across rows) with a table per
architecture - kIlRows_60 for a P100, kIlRows_61 for a P40, kIlRows_70 for a
V100 - but il_arch_ok stopped at compute capability 6, so a Maxwell card never
used it.  That is where the gain is on a card without dp4a: the kernels are
integer-bound, so the reuse pays.

The table is read off mmvq_il_parity --bench --emit-table on a GeForce GTX TITAN
X (sm_52, 12 GB, 24 SMs, CUDA 12.9), the repo's documented procedure, and the
parity tool says every case, T 1-8, rows 1/2/4 and the table, bitwise.  The
switch is opt-in (STRATA_SM50_TABLE=1): the table is one card's measurement.

Measured on the TITAN X, the engine's own stage timers (STRATA_VERIFY_PROFILE=1),
the medians of 3 repeats, the page cache evicted before each run:

  the dense projections' stage   8.92 -> 7.76 ms per window  (-13 %, 3 of 3)
  the VRAM expert hits           36.7 -> 34.5 ms per window  ( -6 %)

The request's tok/s does not move, and the report says why: on this card the GPU
is idle 80 % of the time - it waits 190-310 ms per window for the experts while
it computes about 40 ms.  The change is for the cards where the experts fit and
the compute is the limit.  The answers are the same (parity-answers-sm50-table.json:
the same prompt with and without the switch, greedy, character for character).

The report and the docs grow with it: every hot kernel now named with its source
file and its job, the four things this card does not have measured on it (no
__dp4a and no vdp4a: the four-vmad fallback is the best exact form here, 2.37x
over the byte-wise one; no cp.async: the binary has zero CP.ASYNC, so the staged
hyper-connection read is the "split" variant only, although the start-up message
claims otherwise; no tensor cores: the dense projections' FP16 GEMMs are slow,
which the FP32 route in the previous commit fixes; 48 KB of shared memory per
block: the GDN's staged down projection takes 40 KB of it, so an sm_50 SM holds
two of them where the registers would allow three), and the ranked "what to do
next" list in the order the profile gives it.
The section said which kernel family the 48 KB shared-memory limit bites on
without naming it; the report's table has the names and the sources.
@msprengholz

Copy link
Copy Markdown
Author

Closing this one: the kernel optimizations (the FP32 dense-GEMM route for cc 5.x and the interleaved-matvec table for sm_52) were not asked for. The Maxwell support itself stays in #3595, which is the only part that is needed for the engine to run on a sm_50 card.

@msprengholz
msprengholz deleted the feature/maxwell-dense-f32-gyro branch October 21, 2026 17:17
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