NVFP4: decode weight tiles inside the GEMM for small-batch forward and dX (#13840)

## Problem

`nvfp4_linear` (compressed-tensors `nvfp4-pack-quantized` W4A16
checkpoints) dequantizes the full packed weight to a 16 bit copy on
every forward and every input-gradient call, then runs `torch.matmul` on
it. For decode and small batches that temporary dominates: a 4096 x 4096
layer allocates 33.7 MB per call for 16 activation rows.

## Change

`unsloth/kernels/nvfp4.py`:

- Two tiled Triton GEMMs, `X @ W^T` (forward) and `dY @ W` (dX), sharing
one tile decoder: the weight is decoded one K tile at a time inside the
GEMM, never materialized. fp32 accumulation.
- Same rounding as the dense path: each element is the E2M1 value times
(block scale / global scale) in fp32, cast once to the activation dtype.
E2M1 is decoded by placing the 3 magnitude bits as fp16 exponent /
mantissa bits (exact upcast, then an exact power of two rescale); E4M3
scale bytes use the existing `_e4m3_to_float`.
- When the output has few tiles the reduction is split into fp32
partials (capped at 1/8 of the 16 bit weight size) and summed in a fixed
order by a small kernel, so results are deterministic.
- Any strides, masked tails, int64 offsets past 2^31 elements. The
switch sits inside the existing compile-opaque forward / backward ops,
so `torch.compile` behaviour is unchanged.
- Dispatch: the fused path is used only up to a measured activation row
count per GPU (sm80: 64, sm89: 256, sm120: 128). Larger inputs, shapes
whose N or K is not a multiple of 64 (padded tiles lose to dense), and
GPUs without a measured win (T4, H100, B200, sm103), ROCm, XPU and fp32
activations keep the dense path exactly as today.
`UNSLOTH_NVFP4_FUSED=0` forces dense, `=1` forces fused,
`UNSLOTH_NVFP4_FUSED_MAX_ROWS` overrides the row limit; both are read on
the first NVFP4 call. On GPUs without a row limit the added check costs
about 1 us per call before the unchanged dense path.

## Performance

Fused vs dense (dequantize + `torch.matmul`) speedup, Colab, shapes 4096
x 4096, 14336 x 4096, 4096 x 14336, forward and dX, interleaved with a
second dense arm as the A/A (median gap: L4 0.08%, A100 0%, RTX PRO 6000
0.01%):

| GPU | M=1 | 8 | 32 | 64 | 128 | 256 | 512 | 2048 |
|---|---|---|---|---|---|---|---|---|
| L4 (sm89) | 2.9-5.6 | 3.0-5.5 | 2.6-5.6 | 2.0-4.4 | 1.9-3.3 |
1.24-1.84 | 0.88-1.09 | 0.69-0.79 |
| A100 40GB (sm80) | 1.8-2.8 | 1.8-2.7 | 1.6-2.5 | 1.12-1.45 | 0.71-0.94
| 0.41-0.72 | 0.33-0.61 | 0.34-0.45 |
| RTX PRO 6000 (sm120) | 1.9-3.8 | 2.0-3.7 | 1.5-2.5 | 1.24-1.83 |
1.14-1.47 | 0.87-1.07 | 0.76-0.87 | 0.61-0.68 |
| B200 (sm100) | 0.63-1.03 | 0.60-1.03 | 0.58-0.79 | 0.50-0.67 |
0.41-0.81 | 0.42-0.87 | 0.32-0.57 | 0.22-0.26 |
| T4 (sm75) | 0.07-0.75 | 0.07-0.65 | 0.04-0.07 | 0.02-0.03 | 0.01 |
0.01 | < 0.01 | < 0.01 |

Each GPU's row limit is the last row count that won on every shape and
op; B200 and T4 lose everywhere, so they keep the dense path and no
default case is slower than today.

Re-timed after the edge case fixes in this PR (masked padding, int64
offsets, scale bound), same Colab GPUs, 5 interleaved rounds with an A/A
twin: kernel time matches the first version within noise (geomean 0.999
L4, 1.004 A100, 1.001 RTX PRO 6000). Inside the row gate the worst end
to end fused / dense ratio is 0.80 on L4, 0.90 on A100 and 0.82 on RTX
PRO 6000; just past it A100 at 128 rows is 1.06 to 1.53 and RTX PRO 6000
at 256 rows is 0.94 to 1.14, so the limits stand. A per-call host cost
the fixes added (strided offset bound, about 2 us) is skipped for
contiguous operands, back to the first version's per-call time.
Unaligned shapes (4096 x 4112, 4100 x 4096) are correct through the
masked path but up to 2.8x slower than dense in dX, hence the 64
multiple requirement.

Peak memory (4096 x 4096, 16 rows): 2.2 MB forward and 4.3 MB dX,
against 33.7 MB on main.

## Numerics

Against an fp64 oracle (B200, 4096 to 14336 wide, M 1 to 2048) the fused
and dense paths have the same relative error (bf16 1.62e-3 to 1.66e-3,
fp16 2.07e-4 to 2.09e-4 against the rounded weight); 97.7% to 100% of
outputs are bit-identical to dense, the rest differ by about one output
ulp (accumulation order).

## Tests

New `tests/test_nvfp4_fused_gemm.py` (48): all 16 E2M1 codes exact
through both kernels; every finite E4M3 block scale against global
scales 1e-3 and 3.7e4 matching the dense weight bit for bit; NaN scales;
tails (K = 40, 48, 272, 400, 1040, odd M and N); split reduction and its
determinism; strided / transposed activations, 3D inputs, sliced packed
weights, fp32 block scales; fp16 and bf16; kill switch, row limit and
fallbacks; autograd engaging both kernels; a 4-step AdamW LoRA parity
run fused vs dense; `torch.compile` with no graph breaks; peak memory;
offset width from the strided span, operands on two devices, degenerate
operands, extra scale columns, double backward, inf scale in a partial
last group, zero global scale with a partial row tile, column-strided
E4M3 scales, unaligned shapes kept dense, flags read once. On B200
(torch 2.14 / Triton 3.8, two GPUs) 48 passed with
`tests/test_triton_fp_fusion.py` 4 passed. The original 38 fail on main.
With `tests/test_nvfp4_kernel.py`: 65 passed on torch 2.7.1 / Triton 3.3
and 38 passed + 1 skipped (no compressed-tensors) on torch 2.14 / Triton
3.8; Colab torch 2.11 / Triton 3.6: 38 / 38 on L4, A100 and RTX PRO
6000, 23 / 23 on T4 (fp16).

Not covered: H100 and sm103 timing (dense by default there), torch 2.6 /
Triton 3.2. The `fast_lora` training path (`matmul_lora`) still
dequantizes through `fast_dequantize`; moving it over is a separate
change.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-10-15 13:50:23 -07:00 • committed by GitHub
parent 0737d7dc4f
commit fa15e69126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 927 additions and 6 deletions

View file

@ -0,0 +1,472 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team.
import os
import pytest
torch = pytest.importorskip("torch")
pytestmark = [
pytest.mark.gpu,
pytest.mark.skipif(not torch.cuda.is_available(), reason = "needs CUDA"),
pytest.mark.skipif(torch.version.hip is not None, reason = "the fused path is CUDA only"),
]
@pytest.fixture(scope = "module")
def N():
os.environ.setdefault("UNSLOTH_IS_PRESENT", "1")
from unsloth.kernels import nvfp4
return nvfp4
@pytest.fixture(autouse = True)
def live_env(N, monkeypatch):
# nvfp4 reads its flags once per process; these tests flip them between calls.
cached = N._fused_env
monkeypatch.setattr(N, "_fused_env", N._read_fused_env)
return cached
@pytest.fixture
def fused(monkeypatch):
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "1")
def _dtypes():
if torch.cuda.is_available() and torch.cuda.get_device_capability() >= (8, 0):
return [torch.bfloat16, torch.float16]
return [torch.float16]
def _random_nvfp4(
rows,
cols,
global_scale = 7.3,
seed = 0,
):
g = torch.Generator(device = "cpu").manual_seed(seed)
packed = torch.randint(0, 256, (rows, cols // 2), dtype = torch.uint8, generator = g)
scale = (torch.rand(rows, -(-cols // 16), generator = g) * 3 + 0.01).to(torch.float8_e4m3fn)
return packed.cuda(), scale.cuda(), torch.tensor([global_scale], device = "cuda")
def _calls(N):
return dict(N._FUSED_CALLS)
def _dense(N, X, packed, scale, gs, monkeypatch):
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "0")
before = _calls(N)
Y = N._nvfp4_matmul_fwd(X, packed, scale, gs)
assert _calls(N) == before
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "1")
return Y
def _fwd(N, X, packed, scale, gs):
before = N._FUSED_CALLS["fwd"]
Y = N._nvfp4_matmul_fwd(X, packed, scale, gs)
assert N._FUSED_CALLS["fwd"] == before + 1, "fused forward did not run"
return Y
def _dx(N, G, packed, scale, gs):
before = N._FUSED_CALLS["bwd"]
dX = N._nvfp4_matmul_bwd(G, packed, scale, gs)
assert N._FUSED_CALLS["bwd"] == before + 1, "fused dX did not run"
return dX
def _same(a, b):
assert a.shape == b.shape and a.dtype == b.dtype
assert torch.equal(a.isnan(), b.isnan())
assert torch.equal(a.nan_to_num(), b.nan_to_num())
@pytest.mark.parametrize("dtype", _dtypes())
def test_every_e2m1_code_decodes_exactly(N, fused, dtype):
# Identity activations read the decoded weight back exactly through both kernels.
packed = torch.arange(256, dtype = torch.uint8, device = "cuda").reshape(16, 16)
scale = torch.ones(16, 2, device = "cuda").to(torch.float8_e4m3fn)
gs = torch.ones(1, device = "cuda")
W = N.nvfp4_dequantize(packed, scale, gs, dtype)
lut = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
expect = torch.tensor(
[[(-1 if c & 8 else 1) * lut[c & 7] for c in (b & 15, b >> 4)] for b in range(256)]
).reshape(16, 32)
assert torch.equal(W.float().cpu(), expect)
_same(_fwd(N, torch.eye(32, device = "cuda", dtype = dtype), packed, scale, gs), W.t().contiguous())
_same(_dx(N, torch.eye(16, device = "cuda", dtype = dtype), packed, scale, gs), W)
@pytest.mark.parametrize("dtype", _dtypes())
@pytest.mark.parametrize("global_scale", [1.0, 1e-3, 3.7e4, 0.37])
def test_scale_extremes_match_the_dense_weight(N, fused, monkeypatch, dtype, global_scale):
# Every finite E4M3 scale vs tiny/huge global scales: bit-exact with dense (fp16 inf gives NaN on both).
codes = torch.tensor([c for c in range(256) if c & 127 != 127], dtype = torch.uint8)
rows = 32
scale = (
codes[torch.arange(rows * 16) % len(codes)]
.reshape(rows, 16)
.cuda()
.view(torch.float8_e4m3fn)
)
packed = torch.full((rows, 128), 0x7F, dtype = torch.uint8, device = "cuda") # -6.0, 6.0
packed[:, 1::2] = 0x1A # -1.0, 0.5
gs = torch.tensor([global_scale], device = "cuda")
W = N.nvfp4_dequantize(packed, scale, gs, dtype)
finite = bool(W.isfinite().all())
for col in (0, 1, 2, 3, 17, 255):
X = torch.zeros(1, 256, device = "cuda", dtype = dtype)
X[0, col] = 1
Y = _fwd(N, X, packed, scale, gs)
_same(Y, W[:, col][None] if finite else _dense(N, X, packed, scale, gs, monkeypatch))
for row in (0, 5, 31):
G = torch.zeros(1, rows, device = "cuda", dtype = dtype)
G[0, row] = 1
dX = _dx(N, G, packed, scale, gs)
_same(dX, W[row][None] if finite else G @ W)
def test_nan_block_scale_propagates_like_dense(N, fused, monkeypatch):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(64, 128)
raw = scale.view(torch.uint8).clone()
raw[3, 2] = 0x7F
raw[9, 0] = 0xFF
scale = raw.view(torch.float8_e4m3fn)
X = torch.randn(8, 128, device = "cuda", dtype = dtype)
Y = _fwd(N, X, packed, scale, gs)
ref = _dense(N, X, packed, scale, gs, monkeypatch)
assert torch.equal(Y.isnan(), ref.isnan()) and Y.isnan().any()
def _close_to_dense(a, ref):
# Accumulation order differs from cuBLAS, so cancellation-sized outputs can move by an output ulp.
torch.testing.assert_close(a, ref, atol = 2**-7 * ref.abs().max().item(), rtol = 1.6e-2)
def _check(a, ref, exact):
err_a, err_ref = _oracle_errors(a, ref, exact)
assert err_a <= 1.25 * err_ref + 1e-6, (err_a, err_ref)
_close_to_dense(a, ref)
def _oracle_errors(Y, ref, exact):
exact = exact.double()
return (Y.double() - exact).norm().item() / exact.norm().item(), (
ref.double() - exact
).norm().item() / exact.norm().item()
@pytest.mark.parametrize("dtype", _dtypes())
@pytest.mark.parametrize(
"M, N_out, K",
[(1, 96, 48), (5, 80, 40), (16, 64, 256), (37, 130, 400), (129, 257, 272), (300, 64, 1040)],
)
def test_forward_and_dx_match_dense_and_oracle(N, fused, monkeypatch, dtype, M, N_out, K):
packed, scale, gs = _random_nvfp4(N_out, K, global_scale = 0.37, seed = M)
W = N.nvfp4_dequantize(packed, scale, gs, dtype)
X = torch.randn(M, K, device = "cuda", dtype = dtype)
G = torch.randn(M, N_out, device = "cuda", dtype = dtype)
Y = _fwd(N, X, packed, scale, gs)
ref = _dense(N, X, packed, scale, gs, monkeypatch)
err_fused, err_dense = _oracle_errors(Y, ref, X.double() @ W.double().t())
assert err_fused <= 1.25 * err_dense + 1e-6, (err_fused, err_dense)
_close_to_dense(Y, ref)
dX = _dx(N, G, packed, scale, gs)
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "0")
dref = N._nvfp4_matmul_bwd(G, packed, scale, gs)
err_fused, err_dense = _oracle_errors(dX, dref, G.double() @ W.double())
assert err_fused <= 1.25 * err_dense + 1e-6, (err_fused, err_dense)
_close_to_dense(dX, dref)
@pytest.mark.parametrize("dtype", _dtypes())
@pytest.mark.parametrize("M, N_out, K", [(4, 128, 4096), (70, 96, 3000), (3, 4096, 200)])
def test_split_reduction_matches_unsplit(N, fused, monkeypatch, dtype, M, N_out, K):
packed, scale, gs = _random_nvfp4(N_out, K, seed = 7)
X = torch.randn(M, K, device = "cuda", dtype = dtype)
G = torch.randn(M, N_out, device = "cuda", dtype = dtype)
splits = (
N._fused_config(M, N_out, K, X.device, False)[-1],
N._fused_config(M, K, N_out, X.device, True)[-1],
)
assert max(splits) > 1, splits
Y, dX = _fwd(N, X, packed, scale, gs), _dx(N, G, packed, scale, gs)
monkeypatch.setattr(N, "_CONFIG_HOOK", lambda *a: N._fused_config(*a)[:-1] + (1,))
_close_to_dense(Y, _fwd(N, X, packed, scale, gs))
_close_to_dense(dX, _dx(N, G, packed, scale, gs))
W = N.nvfp4_dequantize(packed, scale, gs, dtype)
_close_to_dense(Y, X @ W.t())
_close_to_dense(dX, G @ W)
monkeypatch.undo()
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "1")
assert torch.equal(Y, _fwd(N, X, packed, scale, gs)) and torch.equal(
dX, _dx(N, G, packed, scale, gs)
)
@pytest.mark.parametrize("dtype", _dtypes())
def test_strided_operands(N, fused, monkeypatch, dtype):
big_packed, big_scale, gs = _random_nvfp4(72, 512, seed = 3)
packed, scale = big_packed[:, :96], big_scale[:, :12]
assert not packed.is_contiguous()
X = torch.randn(192, 33, device = "cuda", dtype = dtype).t()
assert X.stride(-1) != 1
W = N.nvfp4_dequantize(packed, scale, gs, dtype).double()
Y = _fwd(N, X, packed, scale, gs)
_check(Y, _dense(N, X, packed, scale, gs, monkeypatch), X.double() @ W.t())
X3 = torch.randn(3, 7, 192, device = "cuda", dtype = dtype)
Y3 = _fwd(N, X3, packed, scale, gs)
assert Y3.shape == (3, 7, 72)
_check(Y3, _dense(N, X3, packed, scale, gs, monkeypatch), X3.double() @ W.t())
G3 = torch.randn(3, 7, 72, device = "cuda", dtype = dtype)
dX3 = _dx(N, G3, packed, scale, gs)
assert dX3.shape == (3, 7, 192)
_check(dX3, G3 @ W.to(dtype), G3.double() @ W)
def test_float32_block_scales(N, fused, monkeypatch):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(48, 160, seed = 4)
scale = scale.float()
X = torch.randn(9, 160, device = "cuda", dtype = dtype)
W = N.nvfp4_dequantize(packed, scale, gs, dtype).double()
_check(
_fwd(N, X, packed, scale, gs),
_dense(N, X, packed, scale, gs, monkeypatch),
X.double() @ W.t(),
)
def test_kill_switch_and_fallbacks(N, monkeypatch):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(64, 128)
X = torch.randn(4, 128, device = "cuda", dtype = dtype)
for env in ("0", "false"):
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", env)
before = _calls(N)
N.nvfp4_linear(X, packed, scale, gs)
assert _calls(N) == before
monkeypatch.delenv("UNSLOTH_NVFP4_FUSED")
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED_MAX_ROWS", "4")
before = N._FUSED_CALLS["fwd"]
N.nvfp4_linear(X, packed, scale, gs)
assert N._FUSED_CALLS["fwd"] == before + 1
before = N._FUSED_CALLS["fwd"]
N.nvfp4_linear(torch.cat([X, X]), packed, scale, gs)
assert N._FUSED_CALLS["fwd"] == before # 8 rows: past the gate, dense
before = _calls(N)
N.nvfp4_linear(X.float(), packed, scale, gs)
assert _calls(N) == before
with pytest.raises(ValueError):
N.nvfp4_linear(X, packed, scale[:, :2], gs)
def test_degenerate_operands_stay_dense(N, fused):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(64, 128)
X = torch.randn(4, 128, device = "cuda", dtype = dtype)
assert N._fused_ok(X, packed, scale, gs, 128)
assert not N._fused_ok(X, packed, scale, gs[:0], 128) # empty global scale
assert not N._fused_ok(X, packed[:0], scale[:0], gs, 128) # zero output features
def test_extra_scale_columns_are_ignored(N, fused, monkeypatch):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(64, 96) # K = 96 leaves a partial last K tile
wide = torch.cat([scale, torch.full_like(scale[:, :2], float("nan"))], dim = 1)
X = torch.randn(4, 96, device = "cuda", dtype = dtype)
before = _calls(N)
Y = N._nvfp4_matmul_fwd(X, packed, wide, gs)
assert _calls(N)["fwd"] == before["fwd"] + 1
assert torch.equal(Y, N._nvfp4_matmul_fwd(X, packed, scale, gs))
G = torch.randn(4, 64, device = "cuda", dtype = dtype)
assert torch.equal(N._nvfp4_matmul_bwd(G, packed, wide, gs), N._nvfp4_matmul_bwd(G, packed, scale, gs))
def test_inf_scale_in_the_last_group_matches_dense(N, fused, monkeypatch):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(64, 104) # the last group is half padding
scale = scale.float()
scale[:, -1] = float("inf")
packed[:, 48:] = 0x22 # E2M1 1.0 in the last group: its weights are +inf, never 0 * inf
X = torch.randn(4, 104, device = "cuda", dtype = dtype).abs() + 0.1
Y = _fwd(N, X, packed, scale, gs)
ref = _dense(N, X, packed, scale, gs, monkeypatch)
assert torch.isinf(ref).all() and not torch.isnan(ref).any()
assert torch.equal(Y, ref)
def test_zero_global_scale_with_a_partial_row_tile_matches_dense(N, fused, monkeypatch):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(72, 128, global_scale = 0.0) # 72 rows: a partial N tile in dX
packed[:] = 0x22
G = torch.rand(4, 72, device = "cuda", dtype = dtype) + 0.1
before = _calls(N)
dX = N._nvfp4_matmul_bwd(G, packed, scale, gs)
assert _calls(N)["bwd"] == before["bwd"] + 1
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "0")
ref = N._nvfp4_matmul_bwd(G, packed, scale, gs)
assert torch.isinf(ref).all() and torch.equal(dX, ref)
def test_column_strided_e4m3_scales(N, fused, monkeypatch):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(64, 128)
strided = torch.stack([scale, scale], dim = -1).reshape(scale.shape[0], -1)[:, ::2]
assert strided.stride(-1) == 2 and torch.equal(strided.view(torch.uint8), scale.view(torch.uint8))
X = torch.randn(4, 128, device = "cuda", dtype = dtype)
assert torch.equal(_fwd(N, X, packed, strided, gs), _fwd(N, X, packed, scale, gs))
def test_double_backward_takes_the_dense_dx(N, fused):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(64, 128)
X = torch.randn(4, 128, device = "cuda", dtype = dtype, requires_grad = True)
out = N.nvfp4_linear(X, packed, scale, gs)
(gX,) = torch.autograd.grad(out.float().square().sum(), X, create_graph = True)
assert gX.requires_grad
(ggX,) = torch.autograd.grad(gX.float().sum(), X)
assert torch.isfinite(ggX).all() and ggX.abs().sum() > 0
def test_row_gate_keeps_unaligned_shapes_dense(N, monkeypatch):
monkeypatch.delenv("UNSLOTH_NVFP4_FUSED", raising = False)
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED_MAX_ROWS", "64")
dtype = _dtypes()[0]
for (n, k), want in (((64, 128), True), ((72, 128), False), ((64, 80), False)):
packed, scale, gs = _random_nvfp4(n, k)
X = torch.randn(4, k, device = "cuda", dtype = dtype)
assert N._fused_ok(X, packed, scale, gs, k) == want, (n, k)
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "1")
assert N._fused_ok(X, packed, scale, gs, k)
monkeypatch.delenv("UNSLOTH_NVFP4_FUSED")
def test_flags_are_read_once_per_process(N, live_env, monkeypatch):
monkeypatch.setattr(N, "_FUSED_ENV", None)
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", " Off ")
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED_MAX_ROWS", "32")
assert live_env() == ("off", 32)
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "1")
assert live_env() == ("off", 32)
def test_offset_width_follows_the_strided_span(N):
small = torch.empty((2, 4), device = "meta")
assert not N._needs_int64(small)
# 8 elements, but row 1 starts 2**31 elements in: int32 offsets would wrap.
assert N._needs_int64(small.as_strided((2, 4), (2**31, 1)))
@pytest.mark.skipif(torch.cuda.device_count() < 2, reason = "needs two GPUs")
def test_operands_on_another_device_stay_dense(N, monkeypatch):
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED_MAX_ROWS", "64")
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(64, 128)
X = torch.randn(4, 128, device = "cuda:1", dtype = dtype)
assert N._fused_ok(X.to("cuda:0"), packed, scale, gs, 128)
assert not N._fused_ok(X, packed, scale, gs, 128)
assert not N._fused_ok(X.to("cuda:0"), packed, scale, gs.to("cuda:1"), 128)
@pytest.mark.parametrize("with_bias", [False, True])
def test_autograd_uses_both_fused_kernels(N, fused, monkeypatch, with_bias):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(192, 128)
X = torch.randn(2, 5, 128, device = "cuda", dtype = dtype, requires_grad = True)
dY = torch.randn(2, 5, 192, device = "cuda", dtype = dtype)
bias = torch.randn(192, device = "cuda", dtype = dtype) if with_bias else None
before = _calls(N)
out = N.nvfp4_linear(X, packed, scale, gs, bias)
out.backward(dY)
assert _calls(N) == {"fwd": before["fwd"] + 1, "bwd": before["bwd"] + 1}
W = N.nvfp4_dequantize(packed, scale, gs, dtype)
Xr = X.detach().clone().requires_grad_(True)
ref = Xr @ W.t() + (bias if with_bias else 0)
ref.backward(dY)
Wd = W.double()
_check(out, ref, Xr.double() @ Wd.t() + (bias.double() if with_bias else 0))
_check(X.grad, Xr.grad, dY.double() @ Wd)
def test_no_full_weight_temporary(N, fused, monkeypatch):
dtype = _dtypes()[0]
rows, cols = 4096, 4096
packed, scale, gs = _random_nvfp4(rows, cols, seed = 5)
dense_bytes = rows * cols * 2
X = torch.randn(16, cols, device = "cuda", dtype = dtype)
G = torch.randn(16, rows, device = "cuda", dtype = dtype)
def peak(fn):
torch.cuda.synchronize()
torch.cuda.reset_peak_memory_stats()
base = torch.cuda.memory_allocated()
fn()
torch.cuda.synchronize()
return torch.cuda.max_memory_allocated() - base
fwd = peak(lambda: N._nvfp4_matmul_fwd(X, packed, scale, gs))
bwd = peak(lambda: N._nvfp4_matmul_bwd(G, packed, scale, gs))
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", "0")
dense = peak(lambda: N._nvfp4_matmul_fwd(X, packed, scale, gs))
assert dense >= dense_bytes
assert fwd < dense_bytes // 4 and bwd < dense_bytes // 4, (fwd, bwd, dense)
def test_lora_training_steps_match_dense(N, monkeypatch):
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(256, 192, global_scale = 37.5, seed = 6)
data = [torch.randn(4, 24, 192, device = "cuda", dtype = dtype) for _ in range(4)]
def train(mode):
monkeypatch.setenv("UNSLOTH_NVFP4_FUSED", mode)
torch.manual_seed(0)
A = torch.nn.Parameter(torch.randn(8, 192, device = "cuda") * 0.02)
B = torch.nn.Parameter(torch.randn(256, 8, device = "cuda") * 0.02)
opt = torch.optim.AdamW([A, B], lr = 1e-3)
before = _calls(N)
losses = []
for X in data:
X = X.clone().requires_grad_(True)
h = N.nvfp4_linear(X, packed, scale, gs) + (X @ A.t().to(dtype)) @ B.t().to(dtype) * 2.0
loss = h.float().square().mean() + X.float().mean()
loss.backward()
opt.step()
opt.zero_grad()
losses.append(loss.item())
engaged = _calls(N)["fwd"] - before["fwd"], _calls(N)["bwd"] - before["bwd"]
return losses, A.detach().float(), B.detach().float(), engaged
lf, Af, Bf, ef = train("1")
ld, Ad, Bd, ed = train("0")
assert ef == (4, 4) and ed == (0, 0)
for a, b in zip(lf, ld):
assert abs(a - b) <= 2e-3 * abs(b)
assert (Af - Ad).norm() / Ad.norm() < 2e-2
assert (Bf - Bd).norm() / Bd.norm() < 2e-2
def test_fullgraph_compile_runs_the_fused_kernels(N, fused):
import torch._dynamo
dtype = _dtypes()[0]
packed, scale, gs = _random_nvfp4(384, 256)
step = lambda X: N.nvfp4_linear(X, packed, scale, gs).float().square().mean()
X = torch.randn(64, 256, device = "cuda", dtype = dtype, requires_grad = True)
step(X).backward()
eager = X.grad.clone()
X.grad = None
torch._dynamo.reset()
assert torch._dynamo.explain(step)(X).graph_break_count == 0
torch._dynamo.reset()
compiled = torch.compile(step, fullgraph = True)
before = _calls(N)
for rows in (16, 48):
compiled(torch.randn(rows, 256, device = "cuda", dtype = dtype, requires_grad = True)).backward()
compiled(X).backward()
assert _calls(N)["fwd"] >= before["fwd"] + 3 and _calls(N)["bwd"] >= before["bwd"] + 3
torch.testing.assert_close(X.grad, eager)

View file

@ -2,6 +2,9 @@
# Copyright 2026-present the Unsloth AI Inc. team.
"""W4A16 NVFP4: packed E2M1 weights (compressed-tensors `nvfp4-pack-quantized`) dequantized to 16 bit on the fly."""
import functools
import os
import torch
import triton
import triton.language as tl
@ -59,8 +62,7 @@ def _e2m1_to_float(code):
@triton.jit
def _e4m3_to_float(b):
# Triton has no float8e4nv before sm_89 (A100, T4), so the group scale arrives as raw bytes: sign, 4 exponent
# bits (bias 7), 3 mantissa bits; exponent 0 is subnormal (m * 2^-9) and 0x7F / 0xFF are NaN, as in torch.
# No float8e4nv before sm_89: decode E4M3 bytes by hand (bias 7, exp 0 subnormal, 0x7F/0xFF NaN, as torch).
exp = (b >> 3) & 15
man = b & 7
normal = (((exp + 120) << 23) | (man << 20)).to(tl.float32, bitcast = True)
@ -102,7 +104,6 @@ def _nvfp4_dequant_kernel(
)
scale = _e4m3_to_float(scale.to(tl.int32)) if SCALE_E4M3_BYTES else scale.to(tl.float32)
group_scale = tl.math.div_rn(scale, tl.load(global_scale_ptr).to(tl.float32))
# Low nibble is the even column: join + reshape interleaves (lo, hi) into contiguous output columns.
values = tl.reshape(
tl.join(_e2m1_to_float(byte & 15), _e2m1_to_float(byte >> 4)), (BLOCK_R, BLOCK_C // 8, 16)
)
@ -142,7 +143,7 @@ def _nvfp4_dequantize_triton(
BLOCK_R = BLOCK_R,
BLOCK_C = BLOCK_C,
SCALE_E4M3_BYTES = e4m3,
# int32 offsets wrap once the output passes 2^31 elements; int64 only then, as it costs ~2%.
# int32 offsets wrap past 2^31 elements; int64 only then (~2% slower).
INT64_OFFSETS = rows * half * 2 >= 2**31,
num_warps = 4,
enable_fp_fusion = True,
@ -171,8 +172,449 @@ def nvfp4_dequantize(
return _nvfp4_dequantize_torch(packed, scale, global_scale, dtype)
# Opaque to torch.compile as whole matmuls: if the dequantized weight were a graph value, AOTAutograd would save it for
# backward in every layer (3.6x the NVFP4 storage each), which is exactly the memory W4A16 exists to avoid.
@triton.jit
def _e2m1_to_float_bits(code):
# Magnitude bits as fp16 exp (2) + mantissa (1) = E2M1 * 2^-14 (0.5 a subnormal); upcast and rescale are exact.
bits = ((code & 7) << 9) | ((code & 8) << 12)
return bits.to(tl.int16).to(tl.float16, bitcast = True).to(tl.float32) * 16384.0
@triton.jit
def _nvfp4_weight_tile(
packed_ptr,
scale_ptr,
global_scale,
rows,
k0,
n_rows,
half_cols,
scale_cols,
stride_pr,
stride_pc,
stride_sr,
stride_sc,
BLOCK_R: tl.constexpr,
BLOCK_K: tl.constexpr,
SCALE_E4M3_BYTES: tl.constexpr,
OUT_DTYPE: tl.constexpr,
INT64_OFFSETS: tl.constexpr,
MASK_PAD: tl.constexpr,
):
# Rounded like dense: E2M1 * (block scale / global scale) in fp32, then one cast; out-of-range decodes to 0.
c = k0 // 2 + tl.arange(0, BLOCK_K // 2)
g = k0 // 16 + tl.arange(0, BLOCK_K // 16)
if INT64_OFFSETS:
c = c.to(tl.int64)
g = g.to(tl.int64)
row_ok = rows[:, None] < n_rows
byte = tl.load(
packed_ptr + rows[:, None] * stride_pr + c[None, :] * stride_pc,
mask = row_ok & (c[None, :] < half_cols),
other = 0,
).to(tl.int32)
scale = tl.load(
scale_ptr + rows[:, None] * stride_sr + g[None, :] * stride_sc,
mask = row_ok & (g[None, :] < scale_cols),
other = 0,
)
scale = _e4m3_to_float(scale.to(tl.int32)) if SCALE_E4M3_BYTES else scale.to(tl.float32)
group_scale = tl.math.div_rn(scale, global_scale)
values = tl.reshape(
tl.join(_e2m1_to_float_bits(byte & 15), _e2m1_to_float_bits(byte >> 4)),
(BLOCK_R, BLOCK_K // 16, 16),
)
w = tl.reshape(values * group_scale[:, :, None], (BLOCK_R, BLOCK_K))
if MASK_PAD: # a partial tile: padding stays 0 even when scaling gives 0 * inf or 0 / 0
w = tl.where(row_ok & ((k0 + tl.arange(0, BLOCK_K))[None, :] < half_cols * 2), w, 0.0)
return w.to(OUT_DTYPE)
@triton.jit
def _nvfp4_matmul_fwd_kernel(
x_ptr,
packed_ptr,
scale_ptr,
global_scale_ptr,
y_ptr,
M,
N,
K,
half_cols,
scale_cols,
stride_xm,
stride_xk,
stride_pr,
stride_pc,
stride_sr,
stride_sc,
stride_ym,
stride_yn,
stride_ys,
k_per_split,
BLOCK_M: tl.constexpr,
BLOCK_N: tl.constexpr,
BLOCK_K: tl.constexpr,
GROUP_M: tl.constexpr,
SCALE_E4M3_BYTES: tl.constexpr,
INT64_OFFSETS: tl.constexpr,
MASK_PAD: tl.constexpr,
):
# Split K writes fp32 partials [split, M, N], summed in a fixed order afterwards (deterministic).
pid = tl.program_id(0)
split_id = tl.program_id(1)
if INT64_OFFSETS:
split_id = split_id.to(tl.int64)
k_start = tl.program_id(1) * k_per_split
k_end = min(k_start + k_per_split, K)
num_m = tl.cdiv(M, BLOCK_M)
num_n = tl.cdiv(N, BLOCK_N)
group = GROUP_M * num_n
first_m = (pid // group) * GROUP_M
size_m = min(num_m - first_m, GROUP_M)
pid_m = first_m + (pid % group) % size_m
pid_n = (pid % group) // size_m
rm = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
rn = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
if INT64_OFFSETS:
rm = rm.to(tl.int64)
rn = rn.to(tl.int64)
global_scale = tl.load(global_scale_ptr).to(tl.float32)
acc = tl.zeros((BLOCK_M, BLOCK_N), dtype = tl.float32)
for k0 in range(k_start, k_end, BLOCK_K):
rk = k0 + tl.arange(0, BLOCK_K)
if INT64_OFFSETS:
rk = rk.to(tl.int64)
x = tl.load(
x_ptr + rm[:, None] * stride_xm + rk[None, :] * stride_xk,
mask = (rm[:, None] < M) & (rk[None, :] < k_end),
other = 0.0,
)
w = _nvfp4_weight_tile(
packed_ptr,
scale_ptr,
global_scale,
rn,
k0,
N,
half_cols,
scale_cols,
stride_pr,
stride_pc,
stride_sr,
stride_sc,
BLOCK_N,
BLOCK_K,
SCALE_E4M3_BYTES,
x_ptr.dtype.element_ty,
INT64_OFFSETS,
MASK_PAD,
)
acc = tl.dot(x, tl.trans(w), acc)
tl.store(
y_ptr + split_id * stride_ys + rm[:, None] * stride_ym + rn[None, :] * stride_yn,
acc.to(y_ptr.dtype.element_ty),
mask = (rm[:, None] < M) & (rn[None, :] < N),
)
@triton.jit
def _nvfp4_matmul_dx_kernel(
g_ptr,
packed_ptr,
scale_ptr,
global_scale_ptr,
dx_ptr,
M,
N,
K,
half_cols,
scale_cols,
stride_gm,
stride_gn,
stride_pr,
stride_pc,
stride_sr,
stride_sc,
stride_dm,
stride_dk,
stride_ds,
n_per_split,
BLOCK_M: tl.constexpr,
BLOCK_N: tl.constexpr,
BLOCK_K: tl.constexpr,
GROUP_M: tl.constexpr,
SCALE_E4M3_BYTES: tl.constexpr,
INT64_OFFSETS: tl.constexpr,
MASK_PAD: tl.constexpr,
):
pid = tl.program_id(0)
split_id = tl.program_id(1)
if INT64_OFFSETS:
split_id = split_id.to(tl.int64)
n_start = tl.program_id(1) * n_per_split
n_end = min(n_start + n_per_split, N)
num_m = tl.cdiv(M, BLOCK_M)
num_k = tl.cdiv(K, BLOCK_K)
group = GROUP_M * num_k
first_m = (pid // group) * GROUP_M
size_m = min(num_m - first_m, GROUP_M)
pid_m = first_m + (pid % group) % size_m
pid_k = (pid % group) // size_m
rm = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
rk = pid_k * BLOCK_K + tl.arange(0, BLOCK_K)
if INT64_OFFSETS:
rm = rm.to(tl.int64)
rk = rk.to(tl.int64)
global_scale = tl.load(global_scale_ptr).to(tl.float32)
acc = tl.zeros((BLOCK_M, BLOCK_K), dtype = tl.float32)
for n0 in range(n_start, n_end, BLOCK_N):
rn = n0 + tl.arange(0, BLOCK_N)
if INT64_OFFSETS:
rn = rn.to(tl.int64)
g = tl.load(
g_ptr + rm[:, None] * stride_gm + rn[None, :] * stride_gn,
mask = (rm[:, None] < M) & (rn[None, :] < n_end),
other = 0.0,
)
w = _nvfp4_weight_tile(
packed_ptr,
scale_ptr,
global_scale,
rn,
pid_k * BLOCK_K,
n_end,
half_cols,
scale_cols,
stride_pr,
stride_pc,
stride_sr,
stride_sc,
BLOCK_N,
BLOCK_K,
SCALE_E4M3_BYTES,
g_ptr.dtype.element_ty,
INT64_OFFSETS,
MASK_PAD,
)
acc = tl.dot(g, w, acc)
tl.store(
dx_ptr + split_id * stride_ds + rm[:, None] * stride_dm + rk[None, :] * stride_dk,
acc.to(dx_ptr.dtype.element_ty),
mask = (rm[:, None] < M) & (rk[None, :] < K),
)
_FUSED_CALLS = {"fwd": 0, "bwd": 0}
def _read_fused_env():
# UNSLOTH_NVFP4_FUSED: 0 dense, 1 fused, unset = per-GPU row gate (UNSLOTH_NVFP4_FUSED_MAX_ROWS overrides it).
rows = os.environ.get("UNSLOTH_NVFP4_FUSED_MAX_ROWS")
return os.environ.get("UNSLOTH_NVFP4_FUSED", "auto").strip().lower(), int(rows) if rows else None
_FUSED_ENV = None
def _fused_env():
# Read once: os.environ lookups would add about 1 us to every NVFP4 linear.
global _FUSED_ENV
if _FUSED_ENV is None:
_FUSED_ENV = _read_fused_env()
return _FUSED_ENV
@functools.lru_cache(maxsize = None)
def _device_capability(index):
return torch.cuda.get_device_capability(index)
_FUSED_OFF = ("0", "false", "off", "no")
_FUSED_FORCE = ("1", "true", "on", "yes", "force")
_IS_HIP = torch.version.hip is not None
def _fused_ok(A, packed, scale, global_scale, inner):
# Anything unusual keeps the dense path (and its errors).
mode, rows = _fused_env()
if mode in _FUSED_OFF or _IS_HIP or not A.is_cuda:
return False
index = A.get_device()
if mode not in _FUSED_FORCE:
limit = rows if rows is not None else _FUSED_MAX_ROWS.get(_device_capability(index), 0)
if A.numel() > limit * inner: # unmeasured GPUs (limit 0) leave here
return False
if A.dtype not in (torch.float16, torch.bfloat16) or A.numel() == 0 or A.shape[-1] != inner:
return False
if A.dtype == torch.bfloat16 and _device_capability(index) < (8, 0):
return False
return (
packed.device == A.device
and scale.device == A.device
and global_scale.device == A.device
and global_scale.numel() >= 1
and packed.dtype == torch.uint8
and packed.dim() == 2
and packed.shape[0] > 0
and (mode in _FUSED_FORCE or (inner % 64 == 0 and packed.shape[0] % 64 == 0))
and not _is_transposed_view(packed)
and scale.dtype in (torch.float8_e4m3fn, torch.float32, torch.float16, torch.bfloat16)
and scale.dim() == 2
and scale.shape[0] == packed.shape[0]
and packed.shape[1] * 2 <= scale.shape[1] * NVFP4_GROUP_SIZE
)
# Max rows where fused beats dense (measured at N, K multiples of 64); unmeasured GPUs stay dense.
_FUSED_MAX_ROWS = {(8, 0): 64, (8, 9): 256, (12, 0): 128}
def _scale_args(scale):
e4m3 = scale.dtype == torch.float8_e4m3fn
return (scale.view(torch.uint8) if e4m3 else scale), e4m3
@functools.lru_cache(maxsize = None)
def _sm_count(index):
return torch.cuda.get_device_properties(index).multi_processor_count
def _fused_config(
M,
n_out,
k_red,
device,
dx = False,
):
"""(BLOCK_M, BLOCK_OUT, BLOCK_RED, num_warps, num_stages, split) for an [M, k_red] @ [k_red, n_out] product."""
index = device.index or 0
stages = 2 if _device_capability(index) < (8, 0) else 3
if M <= 16:
bm, bo, br, warps = (16, 128, 64, 4) if dx else (16, 64, 128, 4)
elif M <= 64:
bm, bo, br, warps = (32, 128, 64, 4) if dx else (32, 64, 128, 8)
elif M <= 256:
bm, bo, br, warps = (64, 128, 64, 4) if dx else (128, 64, 128, 8)
else:
bm, bo, br, warps = 128, 64, 64, 4
tiles = triton.cdiv(M, bm) * triton.cdiv(n_out, bo)
# Split under four waves; fp32 partials capped at 1/8 of the 16 bit weight.
split = 1
while (
tiles * split < 4 * _sm_count(index)
and k_red >= split * 2 * 4 * br
and 16 * split * 2 * M <= k_red
and split < 16
):
split *= 2
return bm, bo, br, warps, stages, split
@triton.jit
def _split_sum_kernel(partial_ptr, out_ptr, numel, SPLIT: tl.constexpr, BLOCK: tl.constexpr):
# Fixed-order sum of the split partials (determinism), one cast.
offs = tl.program_id(0).to(tl.int64) * BLOCK + tl.arange(0, BLOCK)
mask = offs < numel
acc = tl.load(partial_ptr + offs, mask = mask, other = 0.0)
for i in tl.static_range(1, SPLIT):
acc += tl.load(partial_ptr + i * numel + offs, mask = mask, other = 0.0)
tl.store(out_ptr + offs, acc.to(out_ptr.dtype.element_ty), mask = mask)
def _split_out(M, n_out, split, dtype, device):
out = torch.empty((M, n_out), dtype = dtype, device = device)
if split == 1:
return out, out, 0
partial = torch.empty((split, M, n_out), dtype = torch.float32, device = device)
return out, partial, partial.stride(0)
def _split_sum(partial, out):
if partial is out:
return out
numel = out.numel()
_split_sum_kernel[(triton.cdiv(numel, 1024),)](
partial, out, numel, SPLIT = partial.shape[0], BLOCK = 1024, enable_fp_fusion = True
)
return out
def _needs_int64(*tensors):
# Largest reachable element offset, not numel: a strided view can reach past 2**31 with fewer elements.
for t in tensors:
if t.is_contiguous():
if t.numel() > 2**31:
return True
elif sum((n - 1) * abs(st) for n, st in zip(t.shape, t.stride())) >= 2**31:
return True
return False
def _scale_groups(packed, scale):
# Scales past the logical width are ignored, as dense slices them off.
return min(scale.shape[1], -(-packed.shape[1] // 8))
def _per_split(k_red, split, block):
return triton.cdiv(triton.cdiv(k_red, split), block) * block
def _nvfp4_matmul_fwd_fused(X, packed, scale, global_scale):
K = X.shape[-1]
N = packed.shape[0]
X2 = X.reshape(-1, K)
M = X2.shape[0]
scale, e4m3 = _scale_args(scale)
BM, BN, BK, warps, stages, split = _CONFIG_HOOK(M, N, K, X.device, False)
Y, buf, stride_s = _split_out(M, N, split, X.dtype, X.device)
int64 = _needs_int64(X2, buf, packed, scale)
grid = (triton.cdiv(M, BM) * triton.cdiv(N, BN), split)
with _fp8_triton_device_context(packed):
_nvfp4_matmul_fwd_kernel[grid](
X2, packed, scale, global_scale, buf,
M, N, K, packed.shape[1], _scale_groups(packed, scale),
X2.stride(0), X2.stride(1), packed.stride(0), packed.stride(1),
scale.stride(0), scale.stride(1), buf.stride(-2), buf.stride(-1), stride_s,
_per_split(K, split, BK),
BLOCK_M = BM, BLOCK_N = BN, BLOCK_K = BK, GROUP_M = 8,
SCALE_E4M3_BYTES = e4m3, INT64_OFFSETS = int64, MASK_PAD = N % BN != 0 or K % BK != 0,
num_warps = warps, num_stages = stages, enable_fp_fusion = True,
) # fmt: skip
Y = _split_sum(buf, Y)
_FUSED_CALLS["fwd"] += 1
return Y.view(*X.shape[:-1], N)
def _nvfp4_matmul_dx_fused(grad, packed, scale, global_scale):
N = grad.shape[-1]
K = packed.shape[1] * 2
G = grad.reshape(-1, N)
M = G.shape[0]
scale, e4m3 = _scale_args(scale)
BM, BK, BN, warps, stages, split = _CONFIG_HOOK(M, K, N, grad.device, True)
dX, buf, stride_s = _split_out(M, K, split, grad.dtype, grad.device)
int64 = _needs_int64(G, buf, packed, scale)
grid = (triton.cdiv(M, BM) * triton.cdiv(K, BK), split)
with _fp8_triton_device_context(packed):
_nvfp4_matmul_dx_kernel[grid](
G, packed, scale, global_scale, buf,
M, N, K, packed.shape[1], _scale_groups(packed, scale),
G.stride(0), G.stride(1), packed.stride(0), packed.stride(1),
scale.stride(0), scale.stride(1), buf.stride(-2), buf.stride(-1), stride_s,
_per_split(N, split, BN),
BLOCK_M = BM, BLOCK_N = BN, BLOCK_K = BK, GROUP_M = 8,
SCALE_E4M3_BYTES = e4m3, INT64_OFFSETS = int64, MASK_PAD = N % BN != 0 or K % BK != 0,
num_warps = warps, num_stages = stages, enable_fp_fusion = True,
) # fmt: skip
dX = _split_sum(buf, dX)
_FUSED_CALLS["bwd"] += 1
return dX.view(*grad.shape[:-1], K)
_CONFIG_HOOK = _fused_config
# Opaque to torch.compile, else AOTAutograd saves every layer's dequantized weight for backward.
@_opaque_under_compile(
"nvfp4_matmul_fwd",
lambda X, packed, scale, global_scale: X.new_empty(X.shape[:-1] + (packed.shape[0],)),
@ -180,6 +622,8 @@ def nvfp4_dequantize(
def _nvfp4_matmul_fwd(
X: torch.Tensor, packed: torch.Tensor, scale: torch.Tensor, global_scale: torch.Tensor
) -> torch.Tensor:
if _fused_ok(X, packed, scale, global_scale, packed.shape[1] * 2):
return _nvfp4_matmul_fwd_fused(X, packed, scale, global_scale.reshape(-1)[:1])
return torch.matmul(X, nvfp4_dequantize(packed, scale, global_scale, X.dtype).t())
@ -192,6 +636,11 @@ def _nvfp4_matmul_fwd(
def _nvfp4_matmul_bwd(
grad: torch.Tensor, packed: torch.Tensor, scale: torch.Tensor, global_scale: torch.Tensor
) -> torch.Tensor:
# create_graph: the raw launch has no autograd history, so differentiate through the dense matmul.
if not (grad.requires_grad and torch.is_grad_enabled()) and _fused_ok(
grad, packed, scale, global_scale, packed.shape[0]
):
return _nvfp4_matmul_dx_fused(grad, packed, scale, global_scale.reshape(-1)[:1])
return torch.matmul(grad, nvfp4_dequantize(packed, scale, global_scale, grad.dtype))