Skip to content

NVFP4 quantize on sm_120 reserves ~3.4 GiB of device memory via a TMA driver syscall #3765

Closed

Description

@xyt20021011xy

Environment: Transformer Engine 2.20.2 (PyPI wheel transformer_engine_cu13), PyTorch 2.12.0+cu130, CUDA 13.2 toolkit,
driver 595.71.05, NVIDIA GeForce RTX 5090 (sm_120, 32 GB).

Symptom

The first NVFP4 quantization in a process permanently takes ~3.3 GiB of device memory outside the PyTorch caching
allocator (about 10% of a 32 GB card):

import torch
from transformer_engine.pytorch.tensor.nvfp4_tensor import NVFP4Quantizer
x = torch.randn(256, 1024, device="cuda", dtype=torch.bfloat16)
free0, total = torch.cuda.mem_get_info()
NVFP4Quantizer(rowwise=True, columnwise=False)(x); torch.cuda.synchronize()
free1, _ = torch.cuda.mem_get_info()
print((free0 - free1) / 2**30)   # ~3.3 GiB; cudaLimitStackSize goes from 1024 to 14608 bytes

FP8 current scaling and MXFP8 quantization do not show it; cuBLAS GEMMs (BF16/FP8/NVFP4) and the PyTorch SDPA
backends do not either. CUDA_MODULE_LOADING=EAGER does not change it (it is not module code).

Cause

  • The launch of quantize_transpose_nvfp4_tuned_1D_kernel raises cudaLimitStackSize from 1024 B to 14608 B, and the
    driver reserves that much local memory for every resident thread (170 SMs x 1536 threads), although the kernel
    itself reports 8 bytes of local memory.
  • The kernel's ELF lists an extern __cuda_syscall_cp_async_bulk_tensor_2d_tile_unicast: the 2D TMA load in
    ptx::cp_async_bulk_tensor_2d_global_to_shared (transformer_engine/common/util/ptx.cuh) uses a shared::cluster
    destination (cp.async.bulk.tensor.2d.shared::cluster.global.tile...). On sm_120 this compiles to
    UTMALDG plus CALL.ABS.NOINC into a driver syscall, and the syscall's stack requirement causes the reservation.
  • A standalone 20-line TMA kernel reproduces it (tma_syscall_repro.cu, below): with a shared::cluster destination
    the launch costs +3.41 GiB; with shared::cta the SASS is a bare UTMALDG, local memory is 0 and nothing is reserved.
    Same result for -arch=sm_120, sm_120a and sm_120f.

Proposed fix

TE already has cp_async_bulk_tensor_2d_global_to_shared_cta (used only by the FP8 block-scaling grouped kernels).
The callers of the 2D shared::cluster helper launch with cluster size 1 and pass CTA-local shared memory and mbarrier
addresses, so switching the helper (or the callers) to the shared::cta destination is semantically identical:

-      "cp.async.bulk.tensor.2d.shared::cluster.global.tile"
+      "cp.async.bulk.tensor.2d.shared::cta.global.tile"

Patch validated

Rebuilt libtransformer_engine.so from v2.20.2 with only this change (sm_120, CUDA 13.2), RTX 5090:

stock wheel patched
device memory outside the PyTorch allocator after NVFP4 quantize 3.92 GiB 0.61 GiB (CUDA context only)
quantized outputs (rowwise; rowwise+columnwise+RHT; stochastic rounding) bit-identical (data, scales, amax)
Qwen3-0.6B-shaped full training step, 4K tokens, NVFP4 recipe 155.4 ms 155.3 ms
same, 16K tokens, no activation recomputation OOM (3/3 runs) 964.3 ms

Impact

On 32 GB consumer Blackwell cards this is the difference between fitting and not fitting full-parameter training of
~1.7B models with fp32 master weights and AdamW (usable memory 27.4 GiB vs ~30.7 GiB).

Standalone reproducer (tma_syscall_repro.cu, no TE dependency)

// REPORT 26.2: on the RTX 5090 (sm_120, driver 595.71, CUDA 13.2) a 2D TMA load written with a shared::cluster destination
// compiles to CALL.ABS.NOINC (a driver syscall) next to UTMALDG; its first launch raises cudaLimitStackSize from 1 KiB to
// 14608 B and the driver reserves local memory for every resident thread of the GPU: +3.41 GiB, kept for the process lifetime.
// The shared::cta form compiles to a bare UTMALDG and reserves nothing. TE 2.20.2 uses the shared::cluster form in
// ptx::cp_async_bulk_tensor_2d_global_to_shared (common/util/ptx.cuh), which every NVFP4 quantize kernel calls.
//   nvcc -O2 -std=c++17 -arch=sm_120a tma_syscall_repro.cu -o r_cluster -lcuda && ./r_cluster
//   nvcc -O2 -std=c++17 -arch=sm_120a -DCTA tma_syscall_repro.cu -o r_cta -lcuda && ./r_cta
//   cuobjdump -sass r_cluster | grep -E "CALL|UTMALDG"
#ifdef CTA
#define TMA_DST "shared::cta"
#else
#define TMA_DST "shared::cluster"
#endif
#include <cuda.h>
#include <cuda_runtime.h>
#include <cudaTypedefs.h>
#include <cstdio>
#include <cstdint>
__global__ void k(const __grid_constant__ CUtensorMap map, float* out) {
  __shared__ alignas(128) float tile[32 * 32];
  __shared__ alignas(8) uint64_t bar;
  uint32_t sb = (uint32_t)__cvta_generic_to_shared(&bar), st = (uint32_t)__cvta_generic_to_shared(tile);
  if (threadIdx.x == 0) {
    asm volatile("mbarrier.init.shared::cta.b64 [%0], 1;" :: "r"(sb));
    asm volatile("fence.proxy.async.shared::cta;");
    asm volatile("mbarrier.arrive.expect_tx.shared::cta.b64 _, [%0], %1;" :: "r"(sb), "r"(32 * 32 * 4));
    asm volatile("cp.async.bulk.tensor.2d." TMA_DST ".global.tile.mbarrier::complete_tx::bytes [%0], [%1, {%2, %3}], [%4];"
                 :: "r"(st), "l"(&map), "r"(0), "r"(0), "r"(sb) : "memory");
    asm volatile("{ .reg .pred p; W: mbarrier.try_wait.parity.shared::cta.b64 p, [%0], 0; @!p bra W; }" :: "r"(sb));
  }
  __syncthreads();
  out[threadIdx.x] = tile[threadIdx.x];
}
int main() {
  float *src, *out; cudaMalloc(&src, 64 * 64 * 4); cudaMalloc(&out, 1024 * 4); cudaMemset(src, 0, 64 * 64 * 4);
  size_t lim0, lim1, f0, f1, t; cudaDeviceGetLimit(&lim0, cudaLimitStackSize); cudaMemGetInfo(&f0, &t);
  PFN_cuTensorMapEncodeTiled_v12000 enc; cudaDriverEntryPointQueryResult q;
  cudaGetDriverEntryPointByVersion("cuTensorMapEncodeTiled", (void**)&enc, 12000, cudaEnableDefault, &q);
  CUtensorMap m; cuuint64_t dims[2] = {64, 64}, strides[1] = {64 * 4}; cuuint32_t box[2] = {32, 32}, es[2] = {1, 1};
  CUresult r = enc(&m, CU_TENSOR_MAP_DATA_TYPE_FLOAT32, 2, src, dims, strides, box, es, CU_TENSOR_MAP_INTERLEAVE_NONE,
                   CU_TENSOR_MAP_SWIZZLE_NONE, CU_TENSOR_MAP_L2_PROMOTION_NONE, CU_TENSOR_MAP_FLOAT_OOB_FILL_NONE);
  cudaFuncAttributes a; cudaFuncGetAttributes(&a, k);
  k<<<1, 1024>>>(m, out); cudaError_t e = cudaDeviceSynchronize();
  cudaDeviceGetLimit(&lim1, cudaLimitStackSize); cudaMemGetInfo(&f1, &t);
  printf("%-15s encode %d launch %s  localSizeBytes %zu  stack limit %zu -> %zu  device memory used +%.2f GiB\n", TMA_DST, r, cudaGetErrorString(e),
         a.localSizeBytes, lim0, lim1, (double)(f0 - f1) / (1 << 30));
}

Output on the RTX 5090:

shared::cluster encode 0 launch no error  localSizeBytes 8  stack limit 1024 -> 14608  device memory used +3.41 GiB
shared::cta     encode 0 launch no error  localSizeBytes 0  stack limit 1024 -> 1024  device memory used +0.00 GiB

Activity

  1. 0z5a commented on Oct 31, 2026

    @0z5a
    Contributor

    I checked which architectures this affects. Compiling your standalone reproducer with CUDA 13.3 and grepping cuobjdump -sass:

    -arch shared::cluster shared::cta
    sm_90a, sm_100a, sm_103a, sm_110a UTMALDG, no CALL.ABS, no __cuda_syscall symbol same
    sm_120a, sm_121a UTMALDG + CALL.ABS, 3 __cuda_syscall symbols UTMALDG only

    For this reproducer, the two forms compile to the same SASS on sm_100a and sm_103a, instruction for instruction. On sm_90a and sm_110a, I only checked that neither form has the syscall. On a B300 (sm_103), both binaries leave cudaLimitStackSize at 1024 and reserve no device memory (+0.00 GiB). I didn't compare SASS of TE's own kernels. By the reproducer, though, the syscall and the reservation are specific to SM12x.

    The helper has more callers than the NVFP4 kernels. Through copy_2d_to_shared / copy_2d_to_sharedx2 / copy_2d_to_sharedx3, it is also used by:

    • FP8 quantize and gated
    • MXFP8 quantize, gated and dequantize
    • the grouped MXFP8 and NVFP4 kernels (the grouped FP8-blockwise kernels already use the _cta helper)
    • the standalone Hadamard transforms (hadamard_transform.cu, group_hadamard_transform.cu, graph_safe_group_hadamard_transform.cu)

    None of these files launches with a cluster dimension. The code under common/ that does use clusters doesn't call this helper:

    • cast/mxfp8/specialized/cast_bidim.cuh
    • the RHT cast-fusion kernels and customized_pipeline.cuh
    • the CUTLASS grouped GEMM
    • Userbuffers

    So the shared::cta destination is equivalent for every caller, not just the NVFP4 ones. I have no SM12x GPU, so I couldn't check which of those other paths actually run on SM12x and hit the reservation there.

    The change needs no newer toolkit either: cp_async_bulk_tensor_2d_global_to_shared_cta already emits the shared::cta form, guarded only by __CUDA_ARCH__ >= 900.

  2. KafKafrnZ commented on Nov 3, 2026

    @KafKafrnZ

    One more SM12x data point, since 0z5a had no SM12x GPU: a different card, OS, driver branch and toolkit, using your standalone repro unchanged.

    RTX 5070 Ti Laptop GPU (sm_120, 46 SMs, 12 GB), Windows 11, driver 617.14, nvcc 13.4.59 + MSVC 14.51. Same output for -arch=sm_120, sm_120a and sm_120f, 3 runs each:

    destination localSizeBytes stack limit device memory used
    shared::cluster 8 1024 -> 14432 +0.92 GiB
    shared::cta 0 1024 -> 1024 +0.00 GiB
    • Driver 617.14 and CUDA 13.4 haven't fixed it. The cluster build still has the CALL.ABS.NOINC syscall, so a newer driver or toolkit is no workaround.
    • The reservation is close to SMs x 1536 threads x stack limit on both cards: 46 x 1536 x 14432 B ≈ 0.95 GiB here (measured 0.92), 170 x 1536 x 14608 B ≈ 3.55 GiB on your 5090 (measured 3.41). That's about 8% of this card and about 11% of the 5090. The stack this driver asks for is 176 B smaller (14432 vs 14608 B).

    Two things from reading main (b021980) that bear on where the fix goes. I haven't run TE itself on this card.

    • Besides the copy_2d_to_shared* wrappers, ptx::cp_async_bulk_tensor_2d_global_to_shared is called directly in cast/core/grouped_tma.cuh, mxfp8/dequantize_mxfp8.cuh, mxfp8/group_dequantize_mxfp8.cuh, mxfp8/group_scaled_swiglu_mxfp8.cuh, mxfp8/specialized/quantize_mxfp8.cuh and the NVFP4 kernels. Changing the helper itself covers all of them; changing only the wrapper callers would miss these.
    • The 1D helper cp_async_bulk_tensor_1d_global_to_shared already emits cp.async.bulk.shared::cta.global (its comment still says shared::cluster). So FP8 rowwise casts through quantize_1D don't hit the syscall, which fits your FP8 result. The FP8 dispatch gate is_supported_by_CC_100() is sm_arch >= 100, so SM12x gets the same TMA kernels there as sm_100.

    (I used an AI assistant, Claude Code, to help run and write this up; the numbers are from my machine.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions