Skip to content

epic: NVIDIA Volta (sm_70) inference acceleration program #1536

Open

Description

@inureyes

Summary

Program of work to make the CUDA backend usable on NVIDIA Volta (sm_70). The headline finding from a first-principles audit plus an nsys/measured baseline on a Tesla V100-PCIE-32GB: mlxcel never issues a single tensor-core instruction on Volta. Every quantized GEMM falls into a scalar-FMA CuTe path, and the activation dtype (bf16) is one that Volta has no hardware for at all — no ALU, no tensor core, no cuBLAS GEMM.

This is the pre-Ampere counterpart to #623 (which targeted GB10/sm_121 and treated sm_80 as the floor). #623 explicitly scoped Blackwell; nothing in that program looked below Ampere, and the arch matrix shipped in the release workflow (80;86;89;90a;100;120 for x86_64) starts at sm_80. Volta is reachable today only because build.rs auto-detects the host compute capability.

Baseline (measured)

Host: Tesla V100-PCIE-32GB (sm_70, 900 GB/s HBM2, 14 TFLOPS FP32, 112 TFLOPS FP16 tensor), driver 575.51.03, CUDA 12.9.41, mlxcel 0.6.0, MLX pin 9a79573. Build: make release-cuda with MLX_CUDA_ARCHITECTURES auto-detected to 70 (verified: cuobjdump --list-elf libmlx.a reports 96 cubins, all sm_70).

Model: qwen3.8-27B-4bit (15 GB, affine 4-bit / group 64; all non-quantized tensors BF16; hybrid GDN + full attention every 4 layers, 64 layers, hidden 5120, head_dim 256).

Phase Measured Roofline Attained
Decode 4.2 tok/s (239 ms/tok) ~60 tok/s (900 GB/s / 15 GB) 7%
Prefill ~7.7 tok/s (~600-token prompt in 78.2 s) ~0.41 TFLOPS achieved vs 14 TFLOPS FP32 peak ~3%
TTFT (3-token prompt) ~13 s — —

Decode rate is the slope of two runs (-n 8 → 14.89 s, -n 40/31 generated → 20.39 s); the ~13 s intercept is the fixed first-token cost. Prefill is the delta between a 3-token and a ~600-token prompt at equal -n.

nsys kernel profile (-t cuda,nvtx --cuda-graph-trace=node, 24-token run):

Kernel GPU time Instances Avg
qmm_naive_kernel<..., cutlass::bfloat16_t, integer_subbyte<4>, ..., tile 64x64x64> 82.0% 994 29.3 ms
qmv_kernel<..., cutlass::bfloat16_t, integer_subbyte<4>, ...> 14.7% 11431 457 us
event_signal_kernel 0.4% 1847 74 us
volta_sgemm_64x64_nn (cuBLAS FP32, not tensor core) 0.3% 6192 19 us
naive_grouped_unfold_transpose_nd<__nv_bfloat16> (GDN causal conv im2col) 0.3% 1200 97 us

The decisive datum: qmv moves ~15 GB of weights per decode step in 218 ms = ~70 GB/s, which is 7.7% of the V100's 900 GB/s. Decode on Volta is not bandwidth-bound. It is ALU-bound, which for a 4-bit GEMV is only explicable by the arithmetic itself being wrong for the hardware.

Diagnosis

1. No tensor cores, anywhere. mlx/backend/cuda/device/gemm_sm70.cuh:42-63:

template <bool SM80 = false, typename Element = float, typename CtaTiler>
inline constexpr auto make_tiled_mma(CtaTiler cta_tiler) {
  using Atom = cuda::std::conditional_t<SM80,
      /* SM80_16x8x16_F32F16F16F32_TN / SM80_16x8x16_F32BF16BF16F32_TN */,
      UniversalFMA<float, Element, Element>>;   // <- every cc < 8 lands here

Despite the file name, the non-SM80 branch is UniversalFMA — scalar FP32 FMA on CUDA cores. Both qmm_naive (device/qmm_naive.cuh:63,233) and gather_gemm (gemms/gather_gemm.cu:54) select it via compute_capability_major() >= 8. That is an 8x theoretical gap (14 vs 112 TFLOPS) before accounting for the measured 3%-of-FP32-peak.

2. bf16 is the worst possible dtype on Volta. sm_70 has no bf16 ALU, no bf16 tensor-core variant (cute/arch/mma_sm70.hpp provides only SM70_8x8x4_F16F16F16F16_* and SM70_8x8x4_F32F16F16F32_*), and no cuBLAS bf16 GEMM. src/lib/mlx-cpp/patches-cuda/dtype.cpp deliberately preserves bf16 through type promotion — correct and load-bearing on GB10 ("bf16 is the native compute type for LLM inference"), exactly inverted on Volta. Every __nv_bfloat16 elementwise, norm, and binary kernel in the profile pays emulation.

3. qmv accumulates in bf16 at 4 bits. patches/mlx/backend/cuda/quantized/qmm/qmv.cu:191:

cuda::std::conditional_t<(bits >= 8), float, T> sums[elems_per_thread] = {};

The float-accumulation specialization already exists (qmv.cu:77, :145) and is simply not selected for bits < 8. On sm_70 the T (bf16) branch is emulated per element. This is the 7.7%-of-bandwidth result.

4. The prefill tile is halved on Volta for no reason. quantized/qmm/qmm_naive.cu:18: enough_smem = sm80 && itemsize <= 2 && group_size <= 64 forces tile_n = 64 instead of 128. The 128-wide tile needs ~18-36 KB of shared memory; V100 offers 96 KB per block via cudaFuncAttributeMaxDynamicSharedMemorySize.

5. MoE grouped GEMM claims Turing on a Volta part. patches/mlx/backend/cuda/gemms/grouped_gemm_unaligned.cu:340 maps compute_capability_major() < 8 to cutlass::arch::Sm75, whose m16n8k8 MMA does not exist on sm_70. A MoE checkpoint does generate output today, so this is a verification item rather than a confirmed defect.

6. mlxcel itself is arch-blind. grep -rn compute_capability src/ --include=*.rs returns nothing. Every architecture decision is delegated to MLX's C++ gates; mlxcel has no way to reason about, log, or override them. This is the structural gap underneath items 1-5.

7. Host-side graph construction dominates TTFT. CORRECTED by #1545: the fixed first-token cost is lazy weight materialization, and it is not Volta-specific. This diagnosis was wrong, and wrong in an instructive way. It read cudaGraphInstantiate at 2.14 s / 208 calls and cudaGraphAddKernelNode at 1.95 s / 72,539 calls off cuda_api_sum and concluded graph construction dominated. #1545 measured the phases directly and found weight materialization is 12.08 s of a 15.54 s fixed cost (77.8%): host staging of 15.13 GB at 7.40 s plus a pageable H2D copy at 4.19 s. Graph construction is 6.2% and kernel execution 4.8%. The dominant phase is invisible to cuda_api_sum entirely, because host-side file reads and buffer staging issue no CUDA calls, which is exactly why an API-time profile could not close the gap. There is also no graph thrashing: 196 distinct graphs, saturating well under the 2,000-entry cache, with the large add-node count being a per-decode-token cost rather than a startup one. The verdict is general rather than Volta-specific, settled locally by comparing loaders on the same hardware: the Gemma 4 loader calls eval_all and reaches a first token in 2.4 s, the Qwen 3.5 loader defers and takes 15.4 s, so which phase is charged is a loader property. Tracked outside this epic as #1564.

8. cuDNN SDPA is off below Ampere (scaled_dot_product_attention.cpp:327-330). Immaterial for this model (head_dim 256 exceeds cuDNN's 128 limit regardless) but relevant to long-context serving.

Non-goals

  • Adding sm_70 to the release arch matrix. Volta stays a source-build target unless the throughput work lands and justifies the binary size.
  • Changing any behavior on sm_80 and later. Every item here is gated on cc < 8 and must be a no-op above it, verified by an unchanged GB10 baseline.
  • bf16 numerical parity between Volta and Ampere+. Once activations are f16 on Volta the arithmetic differs by construction; the contract is task quality, not bit-identity across architectures.

Sub-issues

Phase groups below map to execution waves. Explicit depends on annotations carry the real producer/consumer edges and override the phase default, so items with no ordering constraint stay parallel.

Phase 0

Foundation. Gates everything else: five of the six later items need a runtime or build-time answer to "what compute capability is this?", and every item states its acceptance criteria as a delta against the baseline.

Phase 1

Low-risk kernel fixes plus the two independent tracks. All four are mutually independent.

Phase 2

Tensor cores. #1542 gates this on a hardware fact, not a preference: Volta MMA has no bf16 variant, so no tensor-core path exists until activations are f16. #1541 is also an edge because both overlay gemm_sm70.cuh and touch the same launch path.

GB10 (sm_121) continuation

Read this section first if you are picking this epic up on a GB10 host.

Implementation and merge for this epic happen on a Tesla V100-PCIE-32GB (sm_70), which is the only device on the development machine. Every sub-issue's sm_70-side criteria are verified there before merge. The sm_80-and-later non-regression criteria cannot be verified on that machine and are deliberately left open rather than ticked unverified. This section is the handoff.

Each merged sub-issue PR states, in its own body, which of its criteria were verified on sm_70 and which were deferred here. Nothing below has been checked on Ampere-or-later silicon.

What remains to verify on GB10

Issue Deferred criterion
#1537 No behavior change on any existing platform with MLXCEL_TRACE_ARCH unset; capability probe returns Some((12, 1)); the arch-mismatch error does not fire on a matching build
#1538 Nothing device-specific; confirm the baseline doc's methodology section still describes the GB10 sweep correctly
#1539 Greedy output byte-identical on GB10 for a fixed prompt and seed; emitted SASS for the sm_121 pass unchanged (cuobjdump --dump-sass diff), or a documented explanation if the __CUDA_ARCH__ guard perturbs it; GB10 baseline throughput unmoved
#1541 GB10 throughput unmoved; the tiler's sm_80+ selection provably identical for every (itemsize, group_size, m) combination
#1542 GB10 greedy output byte-identical; GB10 baseline throughput unmoved; patches-cuda/dtype.cpp behavior on sm_80+ bit-for-bit unchanged
#1543 GB10 greedy output byte-identical; GB10 baseline throughput unmoved; the new sm_70 branch provably unreachable above cc 7
#1544 GB10 MoE output byte-identical; GB10 MoE throughput unmoved; the == 8 and > 8 dispatch branches untouched
#1545 GB10 throughput and TTFT unmoved by any change made; plus the open question the issue raises — whether the graph-construction cost is Volta-specific or general, which needs a GB10 comparison to answer

How to close it out

  1. Build for the release arch matrix and confirm it still compiles: MLX_CUDA_ARCHITECTURES="90a;100;121" make release-cuda, and separately the x86_64 matrix 80;86;89;90a;100;120.
  2. Run the GB10 baseline sweep and compare against the pre-epic numbers in benchmarks/.
  3. Run cargo test --features cuda on the GB10 host — the sm_121 half of the program-level criterion below.
  4. For the byte-identical checks, generate with a fixed prompt and seed on the merged code and on the pre-epic commit, and diff the token streams.
  5. Tick the matching program-level criteria below, and comment the results on each sub-issue.

If a regression appears, the offending change is by construction reachable above cc 7 and its cc < 8 gate is wrong. File a fix issue referencing this epic and the sub-issue that introduced it, rather than reopening the merged sub-issue.

Acceptance criteria (program level)

  • Volta baseline doc in docs/benchmark_results/ with the measurements above reproduced from a clean build, plus a post-program comparison table.
  • Decode on qwen3.8-27B-4bit reaches >= 25% of the bandwidth roofline (from the measured 7%).
  • Prefill reaches >= 20% of FP32 peak, or >= 10% of tensor-core peak if perf(cuda/quant): qmm_sm70 — Volta tensor-core MMA path for quantized GEMM #1543 lands (from the measured ~3%).
  • No regression on GB10 (sm_121) or the x86_64 release arch matrix: every change gated on cc < 8, verified by re-running the GB10 baseline.
  • cargo test --features cuda green on both an sm_70 and an sm_121 host.

References

Activity

  1. self-assigned this
    on Aug 31, 2026
  2. inureyes commented on Sep 1, 2026

    @inureyes
    MemberAuthor

    Wave 0 and Wave 1 complete: six of eight sub-issues merged

    Run on a Tesla V100-PCIE-32GB (sm_70), CUDA 12.9.41, serially at max_parallel: 1 because the host has one exclusive GPU and every unit's acceptance criteria are timing measurements.

    Issue PR Merge Outcome
    #1537 #1551 c7ef9e4e Landed as specified
    #1538 #1556 07ea6b1f Baseline record, CI gate, six methodology rules
    #1539 #1557 8b4a25cb 1.87x decode, the program's headline result
    #1541 #1559 c2e54939 Hypothesis refuted; three latent defects fixed instead
    #1544 #1562 e5cae856 Premise refuted; retag moves zero device code
    #1545 #1565 41844b5b Findings only; overturned diagnosis 7 in this epic's body

    Not implemented this run, deferred to a separate supervised run: #1542 (design-gated f16 activation policy) and #1543 (Volta tensor-core MMA kernel). Both are status:investigation research spikes, and #1543 is gated on #1542 by a hardware fact rather than a preference.

    Program-level criteria

    Verification on merged HEAD

    All four cross-file contract gates pass at 41844b5b: check_kernel_dtype_keys.py, check_crate_versions.py, check_llama_compat_manifest.py, check_cross_repo_refs.py. The first is directly in this epic's blast radius, since #1559 changed the JIT module name that the module and PTX caches key on. Build provenance re-verified: 96 cubins, all sm_70.

    Three diagnoses in this epic's body were wrong, and the corrections are the durable output

    Diagnosis 7 was wrong and has been corrected in the body. The fixed first-token cost is lazy weight materialization, not graph construction, and it is not Volta-specific: 12.08 s of a 15.54 s cost is host staging plus a pageable H2D copy, against 6.2% for graph construction. The dominant phase issues no CUDA calls at all, which is why an API-time profile could not find it. Tracked outside this epic as #1564.

    Diagnosis 4 was wrong. Widening the qmm_naive tile on Volta's shared-memory headroom loses at every prefill length. Registers are the limiter, not shared memory. What #1559 delivered instead was the three defects the misnamed enough_smem predicate was hiding, including a shape that fails inside the driver on every architecture from Volta onward because nothing calls cuFuncSetAttribute.

    Diagnosis 5 was wrong on mechanism. cutlass::arch::Sm75 on an sm_70 part is harmless, because that arm resolves to OpClassSimt and both tensor-core specializations are gated on kMinComputeCapability >= 80. The retag is correctness-by-construction, verified per-symbol as byte-identical SASS at compute_70, compute_80 and compute_121.

    A constraint the epic did not account for

    CUDA 13 removed Volta support entirely. nvcc there rejects compute_70 before compiling anything. The CUDA CI runners carry 13.0.88 and the release assets are built as cuda13, so building mlxcel for Volta requires a CUDA 12.x toolchain, sm_70 can never enter the release matrix while releases build on CUDA 13, and the cuda-sm70-compile gate added in #1556 probes and skips rather than failing. Documented in the CUDA architecture selection section of docs/installation.md.

    Defects surfaced, all proven pre-existing

    Issue What How it was proven pre-existing
    #1558 Quantized prefill not bitwise reproducible on sm_70, and its determinism test never runs because its checkpoint is absent and it skips rather than fails Revert and rebuild
    #1563 fused_sample_probs differs by 1 ULP at temperature 1.0 Revert and rebuild
    #1564 First-token latency dominated by lazy weight materialization Direct phase measurement
    #1566 The CUDA test binary crashes at the default thread count, SIGFPE or SIGABRT at varying points Built and ran 092d3dd0, the pre-epic commit, which crashes identically

    All four surfaced only because a wider scope finally ran. The CUDA suite has never executed in CI, because the runners cannot build for Volta at all.

    Sub-issue checklist

  3. added and removed on Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:coremlxcel-core: MLX FFI, primitives, KV cache, layersplatform:linuxLinux (CUDA / packaging) specificpriority:mediumMedium prioritystatus:in-progressCurrently being worked ontype:performancePerformance improvements

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions