Repository navigation
test(benchmark): record the Volta sm_70 baseline and gate sm_70 in CI - #1556
Merged
Merged
Conversation
Phase 0 of epic #1536. Every sub-issue from #1539 to #1545 states its acceptance criteria as a delta against a one-off audit run on a single V100; this turns that audit into a reproducible record with a committed methodology, and adds the minimum build coverage to keep a Volta build from rotting silently. No kernel changes. **The record.** `docs/benchmark_results/volta-sm70-baseline-2026-08-31.md` carries decode throughput, prefill throughput, time to first token, roofline attainment for both, the nsys kernel and CUDA API tables, and the raw rows in `benchmarks/cuda_v100_2026-08-31.csv` in the schema the GB10 sweeps already use. Everything was measured from a build made for the document (`MLX_CUDA_ARCHITECTURES=70 make release-cuda`, 96 cubins all sm_70) rather than copied, and every disagreement with the audit is reported rather than smoothed over. **The verdict this issue exists to reach: an 8-bit checkpoint decodes faster than a 4-bit one on Volta.** 1.886x on a dense controlled pair whose configs differ in exactly two leaves, both `bits`, and 1.13x on a MoE pair. On the dense pair the mechanism is isolated: with identical instance counts in both arms, `qmv` takes 12.8366 s at 4-bit against 5.9911 s at 8-bit, a delta of 57.05 ms per generated token against a measured wall-clock slope delta of 58.45, so `qmv` accounts for 97.6% of the gap. `qmm_naive` moves the other way in the same profile, and that is the control: `qmv` picks its accumulator on bit width at `qmv.cu:191` and sm_70 has no bf16 ALU, while `qmm_naive` accumulates in float regardless through `UniversalFMA<float, Element, Element>`. That confirms the hypothesis in #1539 with its counterfactual measured alongside it. **Methodology, because getting it wrong inverts the answer.** Decode rate is a slope over two `-n` values, never `tokens / wall_time`; both runs must reach the token budget, since a greeting makes every instruct checkpoint here stop after roughly ten tokens and collapse the slope; nsys needs `--cuda-graph-trace=node` or all GPU time lands on `event_signal_kernel`; nsys absolute times count only when they reconcile against an unprofiled wall clock, which the dense pair does at 102% and the MoE 4-bit arm fails at 144%; percentage shares do not compare across runs; and the persistent PTX cache state must be stated, because a cold cache costs 4.8x the wall clock of a warm one. `make bench-model` violates the first of these and reads 2.1x to 9.1x low here, and on the MoE pair it reports the opposite ordering to reality, so the document reports the slope alongside it and says so. **Two corrections to the audit that produced the starting numbers.** MoE decode does not route to `qmm_naive`; the expert path is mlxcel's fused `custom_kernel_moe_gateup` and `custom_kernel_moe_down`, firing 3,570 times each for 30 layers over 119 steps, while `qmm_naive` appears with a prefill-sized 326 instances in both arms. And the audit's ~13 s TTFT does not reproduce: 24.94 s is measured here at a stated prompt length with the model loaded and the cache warm, and a cold cache cannot explain a faster audit number, so the figure is recorded as unreproducible rather than as a target. **Build coverage.** `cuda-sm70-compile` in `ci.yml` runs `cargo check --features cuda --all-targets` at `MLX_CUDA_ARCHITECTURES: "70"` on the existing self-hosted CUDA runner, then asserts with `cuobjdump --list-elf` that the emitted `libmlx.a` holds sm_70 and nothing else, which is what distinguishes "the variable was set" from "nvcc emitted pre-Ampere code". It is path-filtered on a new `cuda_arch` filter over the MLX overlays and the build scripts, so a Rust-only PR pays nothing, and it takes its own target directory because the architecture list is part of the build-script fingerprint. A GPU-backed Volta job was considered and declined, with the reasoning recorded in both the document and the job comment: the realistic failure mode is a compile break, no runner exists, no release artifact targets sm_70, and #1537 already turns a mismatch into a named startup error. **Harness.** `scripts/bench_decode.sh` gains a `*V100*` short-name case so the CSV lands as `cuda_v100_<date>.csv` beside the `cuda_gb10_*` files instead of a 20-character truncation, and `MODELS_DIR` and `BENCHMARKS_DIR` become environment-overridable so `all` mode can sweep a nested `owner/name` store root. Defaults are unchanged, so an unset environment reproduces every historical CSV.
9 tasks done
inureyes
added a commit
that referenced
this pull request
Aug 31, 2026
inureyes
force-pushed
the
test/issue-1538-volta-baseline
branch
from
August 31, 2026 14:39
c472b2e to
c470113
Compare
This was referenced Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 0 of epic #1536. Turns the one-off V100 audit that epic rests on into a reproducible committed record with a stated methodology, and adds the minimum build coverage to stop a Volta build rotting silently. No kernel changes. Every number was measured from a build made for this PR (
MLX_CUDA_ARCHITECTURES=70 make release-cuda, 96 cubins all sm_70,libmlx.a155,355,188 bytes) rather than copied, and every disagreement with the audit is reported rather than smoothed over.The verdict this issue exists to reach
An 8-bit checkpoint decodes faster than a 4-bit one on Volta. 1.886x on a dense controlled pair (
gemma-4-12B-it-{4bit,8bit}, whoseconfig.jsonfiles differ in exactly two leaves, bothbits, with identical parameter counts), and 1.13x on a MoE pair. The 4-bit arm reads 6.17 GB per decode step against the 8-bit arm's 11.65 GB, so the arm moving 1.9x the bytes finishes in 53% of the time; decode on this part is not bandwidth bound.The mechanism is isolated on the dense pair. Both arms launch identical instance counts for every kernel, so the comparison is of time and not of work:
qmvtakes 12.8366 s at 4-bit against 5.9911 s at 8-bit over 39,151 instances in each, a delta of 57.05 ms per generated token against a measured wall-clock slope delta of 58.45 ms, soqmvaccounts for 97.6% of the entire gap.qmm_naivemoves the other way in the same profile (10.0733 s at 4-bit against 11.9215 s at 8-bit over 329 instances each), and that is the control:qmvselects its accumulator on bit width atsrc/lib/mlx-cpp/patches/mlx/backend/cuda/quantized/qmm/qmv.cu:191and sm_70 has no bf16 ALU, whileqmm_naiveaccumulates in float regardless of bit width throughUniversalFMA<float, Element, Element>inmlx/backend/cuda/device/gemm_sm70.cuh. Where the accumulator is bit-width dependent the 4-bit arm loses by 2.14x; where it is not, it wins as bandwidth predicts. That confirms the hypothesis in #1539 with its counterfactual measured in the same run.Both dense profiles reconcile against their unprofiled runs (102.4% and 101.6%), which is what makes those absolute times usable.
Headline numbers
qwen3.8-27B-4bitgemma-4-12B-it-4bitgemma-4-12b-it-8bitgemma-4-26b-a4b-it-4bitgemma-4-26b-a4b-it-8bitPrefill on
qwen3.8-27B-4bitfrom a five-rung ladder at-n 1: 125.07 ms per prompt token, that is 8.00 tok/s marginal and 0.397 TFLOPS, 2.83% of 14 TFLOPS FP32, with a fixed intercept of 18.41 s. Time to first token is 24.94 s at a 54-token prompt with the model loaded and the cache warm.What changed
docs/benchmark_results/volta-sm70-baseline-2026-08-31.md(new, 398 lines). Environment and checkpoint provenance, build provenance including the cubin histogram, the six methodology rules, decode and prefill throughput with roofline attainment for both, the cold-versus-warm PTX cache measurement, the nsys kernel andcuda_api_sumtables, the 4-bit against 8-bit section with the verdict, the three-harness comparison, a full reproduced-or-not table against the audit, the CI decision, the known traps, an empty post-program comparison table keyed to perf(cuda/quant): use float accumulators in qmv below Ampere at bits < 8 #1539 through perf(cuda): CUDA graph instantiation and JIT module load dominate Volta TTFT #1545, and the reproduce commands.benchmarks/cuda_v100_2026-08-31.csv(new). Five rows fromscripts/bench_decode.sh, in the same 15-column schema thecuda_gb10_*sweeps use..github/workflows/ci.yml. Newcuda-sm70-compilejob and a newcuda_archpath filter; the header note on CUDA gating updated to say what the three CUDA jobs now cover.scripts/bench_decode.sh. A*V100*case indetect_hardware_shortso the CSV lands ascuda_v100_<date>.csvbeside the GB10 files instead of a 20-character truncation, andMODELS_DIR/BENCHMARKS_DIRmade environment-overridable soallmode can sweep a nestedowner/namestore root. Defaults unchanged, so an unset environment reproduces every historical CSV byte for byte.Build coverage decision
cuda-sm70-compilerunscargo check --features cuda --all-targetsatMLX_CUDA_ARCHITECTURES: "70"on the existing self-hosted CUDA runner, then asserts withcuobjdump --list-elfthat the emittedlibmlx.acontains sm_70 and nothing else.cargo checkstill runs the build script, so nvcc compiles MLX even though nothing links, and the cubin assertion is what separates "the environment variable was set" from "nvcc actually emitted pre-Ampere code"; it also catches the90aauto-detect fallback silently swallowing the architecture list. Path-filtered oncuda_arch(the MLX overlays, the pinned commit, the two build scripts, the workflow) so a Rust-only PR pays nothing, with its own persistent target directory because the architecture list is part of the build-script fingerprint.A GPU-backed Volta job was considered and declined, and the reasoning is recorded in the job comment and in the document rather than the job being silently omitted: the realistic failure mode this program can introduce is a compile break (a
__CUDA_ARCH__guard not covering cc 7, a CUTLASS type with no pre-Ampere instantiation, a bf16 intrinsic with no sm_70 implementation), no Volta runner exists in this repository's pool, no release artifact targets sm_70, #1537 already turns an architecture mismatch into a named startup error rather than an opaque CUDA load failure, and the one Volta machine that exists is this single-GPU development host where such a job would serialize against the measurements themselves. Adding sm_70 to the release matrix was also considered and declined, since a cold six-architecture build is already about three hours and shipping sm_70 would be a support commitment for a part the current kernels serve badly; revisit once #1539 through #1545 have moved these numbers.Corrections to the audit
qmm_naive. A comment on test(benchmark): Volta (sm_70) baseline record and build coverage #1538 inferred it does, fromM * B >= 8reachingGatherQMM. On this build the expert path in decode is mlxcel's own fusedcustom_kernel_moe_gateup_kernel_cu_*andcustom_kernel_moe_down_kernel_cu_*, 3,570 instances each, which is 30 layers over 119 steps;qmm_naiveappears with a prefill-sized 326 instances in both arms. Theqmvinversion is present in the MoE profile at the same 1.76x the audit found (2.6224 s against 1.4921 s over 28,084 identical instances), but the fused expert kernels move the other way and nearly cancel it, which is why the MoE advantage is 1.13x and not the dense pair's 1.89x.qmm_naiveinstance counts are exactly half the audit's on both models (497 against 994, 329 against 658). A factor of exactly two on two independent models is a counting difference, not a performance one, and the conclusion drawn fromqmm_naivehere is a sign comparison within a single profile, which is unaffected.Methodology the document commits to
Getting any of these wrong changes the answer by more than the effects this epic is trying to measure, and three of them produced a wrong result during this work.
-nvalues at a fixed prompt, nevertokens / wall_time.-nvalues return the same generation and the slope collapses; the first attempt at this record did exactly that and produced everything from a division by zero to 193 ms/token on a model whose real figure is 220. The harness now assertsgenerated_tokens == nbefore computing a slope.MLX_PTX_CACHE_DIRpoints at~/.cache/mlxcel/cuda-ptx/<mlx-commit>, keyed on the pinned commit, so it survives rebuilds. Measured cold against warm on an identical command: 135.13 s against 27.80 s process wall, prefill 76.24 s against 11.24 s, decode 9.95 s against 4.93 s.--cuda-graph-trace=node, or roughly all GPU time lands onevent_signal_kernel.qmm_naiveshare for qwen is 70.4% here and 82.0% in the audit while the per-instance times agree, purely because the totals differ.make bench-modelviolates rule 1 and reads 2.1x to 9.1x low on this host (qwen3.8-27B-4bit1.67 tok/s against a true 4.54). On the MoE pair it is not even sign stable: it reports 4-bit ahead of 8-bit (3.28 against 3.21) while both the slope andscripts/bench_decode.shput 8-bit ahead by 12% and 18%. A Volta record quoting the Makefile harness alone would have published the opposite of this PR's central finding, so the document reports the slope alongside it and says why.scripts/bench_decode.sh, which warms in-process, lands within 2% to 15% of the slope and stays the artifact producer.Test plan
Host: Tesla V100-PCIE-32GB (compute capability 7.0, sm_70), CUDA 12.9.41, driver 575.51.03, x86_64, single exclusive GPU. Every run verified with
nvidia-smi --query-compute-appsempty at its start after one overlap was detected and its results discarded.MLX_CUDA_ARCHITECTURES=70 make release-cudasucceeded (25m30s), andcuobjdump --list-elfon the resultinglibmlx.areports 96 cubins, allsm_70, nothing else.MLXCEL_TRACE_ARCH=1 ./target/release/mlxcel generate ...reportscompute capability 7.0 (sm_70); compiled for [70]; coverage: cubin, so the archive and the runtime agree.generated_tokens == nasserted. Dense spreads 0.09% to 3.28%.qwen3.8-27B-4bitat five prompt lengths (54, 161, 365, 671, 1283 tokens), two repetitions each, fitted by least squares.--cuda-graph-trace=node, each reconciled against its unprofiled counterpart, with the ratio reported including the MoE arm that fails the check.MODELS_DIR=./models/mlx-community ./scripts/bench_decode.sh allproduced the committed CSV;detect_hardware_shortverified to returnv100for this host, matching the committed filename.make bench-modelon all five checkpoints for the harness comparison, followed bymake bench-clean;benchmark_results.logis absent and the tree is clean.bash -n scripts/bench_decode.shand a YAML parse of.github/workflows/ci.ymlconfirming the new job and the newchangesoutput are well formed.python3 validate_body.pyon the document, this body, and the commit message.Deferred to GB10
None of the following was verified, because this machine has exactly one GPU and it is a Tesla V100; no Ampere-or-later device exists here. They belong to the
## GB10 (sm_121) continuationsection of epic #1536, which lists for this issue: "Nothing device-specific; confirm the baseline doc's methodology section still describes the GB10 sweep correctly."cuda-sm70-compilehas never executed. GitHub Actions cannot be run from here. The job'scargo check --features cuda --all-targetsat sm_70 and itscuobjdumpassertion were verified only by the equivalent local build, whose archive the assertion's condition was written against; the job itself, its path filter, its concurrency group and its target-directory step are unexercised. Its first real run will be this PR, on thecuda_archfilter that.github/workflows/ci.ymlmatches.bench_decode.shchange on the GB10 sweep path.MODELS_DIRandBENCHMARKS_DIRdefaults are unchanged and the new*V100*case cannot match a GB10 host, so the GB10allsweep is unaffected by construction, but it was not re-run to confirm byte-identical output.Closes #1538