perf(flce): accumulate low-precision Triton dW with addmm

Accumulate matching BF16/FP16 weight gradients directly into their existing buffer on SM80+, avoiding the per-chunk parameter-sized FP32 conversion. Preserve the guarded FP32 fast path, AMP dtype alignment, and legacy fallbacks. Add capability-aware regression coverage and explicit benchmark accumulator-policy controls.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Vaibhav Jindal
2026-09-10 06:36:46 +00:00
co-authored by Copilot
parent e4aad6c0c0
commit 3eddb174e0
5 changed files with 890 additions and 23 deletions
+8 -1
View File
@@ -263,9 +263,16 @@ This harness imports the backend `autograd.Function` classes **directly** (no di
--chunk-sizes 256 1024 4096
```
Pass `--accum-dtype default` to compare each backend's *native* accumulator policy instead of the FP32-matched default. **Warning:** the `default` panel is **not** precision-matched — with the BF16 weights used here `triton` and `cutedsl` accumulate the cross-chunk `dW` in **BF16** buffers while `cutile` **always** keeps an **FP32** accumulator:
```bash
.venv/bin/python benchmark/scripts/benchmark_flce_backends.py \
--backends all3 --accum-dtype default
```
- **Backends & arch policy.** `cutedsl` requires exactly SM100 (Blackwell, e.g. B200); `cutile` runs on SM90 (Hopper) **and** SM100. So on a B200 the default — and `--backends all3` — is all three (`triton`, `cutedsl`, `cutile`) in one process; on SM90 the default is `triton` + `cutile`; elsewhere `triton` only. Explicitly requesting a backend the GPU cannot run fails loudly rather than degrading, and a missing SDK surfaces its own `ImportError`.
- **GEMM ownership differs per backend** (this is why the numbers are not apples-to-apples kernel isolation): `triton` runs the projection, `dX` and `dW` all through PyTorch/cuBLAS; `cutedsl`'s projection uses CuTe DSL SM100 GEMM; `dX`/`dW` use PyTorch/cuBLAS; `cutile` uses PyTorch BLAS for the projection and `dX` and only a cuTile MMA schedule for `dW`.
- **Numerics.** Inputs are BF16 with `accum_dtype=torch.float32`, so the `dW` accumulation precision matches across backends. The `cutile` `dW` retains raw FP32 through accumulation and is cast to BF16 only after the combined upstream × global-mean scaling in backward, preserving the tiny-value range of the original path.
- **Numerics & `--accum-dtype`.** Inputs are BF16. `--accum-dtype fp32` (the default, backward-compatible) forces `accum_dtype=torch.float32` on every backend so the cross-chunk `dW` accumulation precision matches, and the table's `eff_accum` column reads `fp32` for every row. `--accum-dtype default` instead passes `None` and lets each backend apply its own policy: with BF16 weights `triton` and the native `cutedsl` path store the cross-chunk `dW` in **BF16**, while `cutile` **always** retains an **FP32** accumulator — so that panel is **not** precision-matched (`eff_accum` reads `bf16` for `triton`/`cutedsl` and `fp32` for `cutile`). The requested mode is echoed in the header. The `cutile` `dW` retains raw FP32 through accumulation and is cast to BF16 only after the combined upstream × global-mean scaling in backward, preserving the tiny-value range of the original path.
- **`chunk` vs `eff_chunk`.** A chunk larger than the token count is clamped to `B*T`; the table prints both the requested `chunk` and the `eff_chunk` actually used, so a clamp is never reported as the request.
- **Peak memory** is the *incremental* peak **allocated** bytes (not reserved) above an input-only baseline: both leaf grads are cleared before the baseline is snapshotted, and a dedicated memory-probe iteration is kept separate from the timed loop.
+83 -18
View File
@@ -48,6 +48,16 @@ Requested vs. effective chunk size: a chunk larger than the token count ``N`` is
clamped to ``N``; the table reports the ``chunk`` you asked for and the
``eff_chunk`` actually used, so a clamp is never mistaken for the request.
Cross-chunk ``dW`` accumulator (``--accum-dtype``): ``fp32`` (the default, kept for
backward compatibility) passes ``torch.float32`` to every backend so the ``dW``
accumulation precision is matched across ``triton`` / ``cutedsl`` / ``cutile``.
``default`` passes ``None`` and lets each backend apply its own policy: with the
BF16 weights used here ``triton`` and the native ``cutedsl`` path store the
cross-chunk ``dW`` in **BF16**, while ``cutile`` **always** retains an **FP32**
accumulator even when ``None`` is requested. The header states the requested mode
and the table's ``eff_accum`` column reports the effective accumulator per backend,
so the ``default`` panel is never mislabelled as precision-matched.
Results are printed to stdout only — no CSV is written into the repository.
Example::
@@ -55,6 +65,10 @@ Example::
.venv/bin/python benchmark/scripts/benchmark_flce_backends.py \\
--backends all3 --tokens 4096 --hidden-size 2048 --vocab-size 32000 \\
--chunk-sizes 256 1024 4096
# per-backend default accumulator policy (triton/cutedsl BF16, cutile FP32):
.venv/bin/python benchmark/scripts/benchmark_flce_backends.py \\
--backends all3 --accum-dtype default
"""
from __future__ import annotations
@@ -68,6 +82,10 @@ import torch
_ACCUM_DTYPE = torch.float32
_DTYPE = torch.bfloat16
# ``--accum-dtype`` maps a requested mode onto the value passed at Function slot 11:
# ``fp32`` -> torch.float32 (matched precision), ``default`` -> None (per-backend policy).
_ACCUM_DTYPE_CHOICES = {"fp32": torch.float32, "default": None}
_MODULE_PATHS = {
"triton": "liger_kernel.ops.fused_linear_cross_entropy",
"cutedsl": "liger_kernel.ops.cutedsl.ops.fused_linear_cross_entropy",
@@ -144,6 +162,21 @@ def _resolve_backends(requested, cc):
return list(requested)
def _effective_accum(name, requested):
"""Human label of the *effective* cross-chunk ``dW`` accumulator for BF16 weights.
``fp32`` forces ``torch.float32`` on every backend. Under ``default`` (``None``
passed through) the native ``triton`` / ``cutedsl`` paths store the cross-chunk
``dW`` in BF16 to match the BF16 weights, while ``cutile`` *always* retains an
FP32 accumulator regardless — so the two are not precision-matched.
"""
if requested == "fp32":
return "fp32"
if name == "cutile":
return "fp32"
return "bf16"
def _build_inputs(tokens, hidden, vocab, device, seed=0):
"""Deterministic BF16 inputs — identical values reused for every backend."""
generator = torch.Generator(device=device).manual_seed(seed)
@@ -153,9 +186,11 @@ def _build_inputs(tokens, hidden, vocab, device, seed=0):
return x, w, target
def _apply(fn, x, w, target, chunk_size):
# 15 base positional args + (ce_impl, ce_mode, chunk_size); accum_dtype=float32
# so the dW accumulation precision matches across triton / cutedsl / cutile.
def _apply(fn, x, w, target, chunk_size, accum_dtype=_ACCUM_DTYPE):
# 15 base positional args + (ce_impl, ce_mode, chunk_size). ``accum_dtype`` is the
# cross-chunk dW accumulator at slot 11: torch.float32 (matched precision) or None
# (per-backend policy — see _effective_accum). Threaded as a parameter, never a
# mutable global, so one process can compare modes without state leak.
loss, _, _, _ = fn.apply(
x,
w,
@@ -168,7 +203,7 @@ def _apply(fn, x, w, target, chunk_size):
"mean", # reduction
None, # softcap
False, # return_z_loss
_ACCUM_DTYPE, # accum_dtype
accum_dtype, # accum_dtype
False, # use_token_scaling
False, # return_token_accuracy
False, # return_predicted_tokens
@@ -179,16 +214,16 @@ def _apply(fn, x, w, target, chunk_size):
return loss
def _one_iteration(fn, x, w, target, chunk_size, upstream):
def _one_iteration(fn, x, w, target, chunk_size, upstream, accum_dtype=_ACCUM_DTYPE):
x.grad = None
w.grad = None
loss = _apply(fn, x, w, target, chunk_size)
loss = _apply(fn, x, w, target, chunk_size, accum_dtype=accum_dtype)
loss.backward(upstream)
def _loss_value(fn, x, w, target, chunk_size):
def _loss_value(fn, x, w, target, chunk_size, accum_dtype=_ACCUM_DTYPE):
"""Return a representative loss as a Python float, keeping no autograd graph alive."""
loss = _apply(fn, x, w, target, chunk_size)
loss = _apply(fn, x, w, target, chunk_size, accum_dtype=accum_dtype)
value = float(loss.detach())
del loss
x.grad = None
@@ -196,13 +231,13 @@ def _loss_value(fn, x, w, target, chunk_size):
return value
def _time_backend(fn, x, w, target, chunk_size, warmup, iters):
def _time_backend(fn, x, w, target, chunk_size, warmup, iters, accum_dtype=_ACCUM_DTYPE):
upstream = torch.ones((), device=x.device, dtype=torch.float32)
# One unconditional compile pass (excluded) so warmup may legitimately be 0.
_one_iteration(fn, x, w, target, chunk_size, upstream)
_one_iteration(fn, x, w, target, chunk_size, upstream, accum_dtype=accum_dtype)
for _ in range(warmup):
_one_iteration(fn, x, w, target, chunk_size, upstream)
_one_iteration(fn, x, w, target, chunk_size, upstream, accum_dtype=accum_dtype)
# Baseline must be input-only: drop both leaf grads before snapshotting.
x.grad = None
@@ -212,7 +247,7 @@ def _time_backend(fn, x, w, target, chunk_size, warmup, iters):
# Dedicated memory probe (separate from the timed loop for a precise peak).
torch.cuda.reset_peak_memory_stats(x.device)
baseline = torch.cuda.memory_allocated(x.device)
_one_iteration(fn, x, w, target, chunk_size, upstream)
_one_iteration(fn, x, w, target, chunk_size, upstream, accum_dtype=accum_dtype)
torch.cuda.synchronize()
peak = torch.cuda.max_memory_allocated(x.device)
incremental_peak_mb = max(0.0, (peak - baseline) / (1024**2))
@@ -223,7 +258,7 @@ def _time_backend(fn, x, w, target, chunk_size, warmup, iters):
ends = [torch.cuda.Event(enable_timing=True) for _ in range(iters)]
for i in range(iters):
starts[i].record()
_one_iteration(fn, x, w, target, chunk_size, upstream)
_one_iteration(fn, x, w, target, chunk_size, upstream, accum_dtype=accum_dtype)
ends[i].record()
torch.cuda.synchronize()
@@ -240,26 +275,45 @@ def _run(args):
for name in backends:
_check_backend_supported(name, cc)
requested = args.accum_dtype
accum_value = _ACCUM_DTYPE_CHOICES[requested]
if requested == "fp32":
accum_note = "cross-chunk dW accumulator forced to FP32 on every backend (precision matched)"
else:
accum_note = (
"per-backend accumulator policy for BF16 weights: triton/cutedsl store cross-chunk dW in "
"BF16, cutile always retains an FP32 accumulator (NOT precision matched)"
)
print(
f"device={torch.cuda.get_device_name(device)} cc={cc} "
f"tokens={args.tokens} hidden={args.hidden_size} vocab={args.vocab_size} "
f"dtype={_DTYPE} accum_dtype={_ACCUM_DTYPE}"
f"dtype={_DTYPE} accum_dtype_requested={requested} ({accum_note})"
)
header = (
f"{'backend':<10} {'chunk':>8} {'eff_chunk':>10} {'eff_accum':>10} "
f"{'median_ms':>12} {'peak_MiB':>12} {'loss':>12}"
)
header = f"{'backend':<10} {'chunk':>8} {'eff_chunk':>10} {'median_ms':>12} {'peak_MiB':>12} {'loss':>12}"
print(header)
print("-" * len(header))
for name in backends:
fn = _load_backend(name)
eff_accum = _effective_accum(name, requested)
for chunk_size in args.chunk_sizes:
effective = min(chunk_size, args.tokens)
x, w, target = _build_inputs(args.tokens, args.hidden_size, args.vocab_size, device, seed=args.seed)
x.requires_grad_(True)
w.requires_grad_(True)
# Representative loss (recomputed cleanly, no graph retained into timing).
loss_val = _loss_value(fn, x, w, target, chunk_size)
median_ms, peak_mb = _time_backend(fn, x, w, target, chunk_size, args.warmup, args.iters)
print(f"{name:<10} {chunk_size:>8} {effective:>10} {median_ms:>12.4f} {peak_mb:>12.2f} {loss_val:>12.5f}")
loss_val = _loss_value(fn, x, w, target, chunk_size, accum_dtype=accum_value)
median_ms, peak_mb = _time_backend(
fn, x, w, target, chunk_size, args.warmup, args.iters, accum_dtype=accum_value
)
print(
f"{name:<10} {chunk_size:>8} {effective:>10} {eff_accum:>10} "
f"{median_ms:>12.4f} {peak_mb:>12.2f} {loss_val:>12.5f}"
)
# Drop this shape's tensors before the next backend/chunk allocates.
del x, w, target
@@ -301,6 +355,17 @@ def _build_parser():
"--warmup", type=_non_negative_int, default=5, help="warmup iterations excluded from timing (default: 5)"
)
parser.add_argument("--iters", type=_positive_int, default=20, help="timed iterations, must be >= 1 (default: 20)")
parser.add_argument(
"--accum-dtype",
choices=sorted(_ACCUM_DTYPE_CHOICES),
default="fp32",
help=(
"cross-chunk dW accumulator policy: 'fp32' (default, backward-compatible) passes "
"torch.float32 to every backend so accumulation precision matches; 'default' passes None "
"so each backend uses its own policy — with BF16 weights triton/cutedsl accumulate dW in "
"BF16 while cutile always keeps an FP32 accumulator (default: fp32)"
),
)
parser.add_argument("--seed", type=int, default=0, help="RNG seed for the shared inputs (default: 0)")
return parser
@@ -105,7 +105,10 @@ def fused_linear_cross_entropy_forward(
grad_input = torch.zeros_like(_input, device=device)
# we use fp32 for loss and gradients accumulator
# loss_1d is always fp32. The weight/bias grad accumulators are NOT always fp32:
# with accum_dtype=None they inherit the parameter dtype (e.g. bf16/fp16), and only
# take accum_dtype when one is explicitly requested. The weight-grad projection below
# therefore has a dtype-matched low-precision path in addition to the fp32 fast path.
if input_requires_grad:
if accum_dtype is None:
grad_weight = torch.zeros_like(weight, device=device) if weight_needs_grad else None
@@ -290,13 +293,17 @@ def fused_linear_cross_entropy_forward(
if grad_weight is not None and input_requires_grad:
grad_logits_t = grad_logits_chunk.t()
grad_logits_is_half = grad_logits_t.dtype in (torch.float16, torch.bfloat16)
on_cuda_sm80 = (
grad_weight.device.type == "cuda" and torch.cuda.get_device_capability(grad_weight.device)[0] >= 8
)
if (
_ADDMM_SUPPORTS_OUT_DTYPE
and grad_weight.device.type == "cuda"
and torch.cuda.get_device_capability(grad_weight.device)[0] >= 8
and on_cuda_sm80
and grad_weight.dtype == torch.float32
and grad_logits_t.dtype in (torch.float16, torch.bfloat16)
and grad_logits_is_half
):
# FP32 accumulator (accum_dtype=torch.float32, or fp32 params under AMP).
# Unlike torch.mm, torch.addmm's out_dtype path does not participate in
# autocast operand casting, so under AMP (fp32 params, no bias) _input_chunk
# can stay fp32 while grad_logits is the autocast dtype. addmm requires mat1
@@ -311,7 +318,26 @@ def fused_linear_cross_entropy_forward(
out_dtype=torch.float32,
out=grad_weight,
)
elif on_cuda_sm80 and grad_logits_is_half and grad_weight.dtype == grad_logits_t.dtype:
# Low-precision accumulator (accum_dtype=None with bf16/fp16 params) whose
# dtype already matches grad_logits. Accumulate straight into grad_weight with
# addmm(out=grad_weight); this mirrors the CuTe backend's direct bf16 addmm and
# avoids the legacy path's parameter-sized bf16->fp32 temporary + cast per chunk.
# In-place out ops are not autocast, so -- as torch.mm's autocast used to do --
# align _input_chunk (which may be promoted fp32 under bf16 AMP) to grad_logits.
input_chunk = _input_chunk
if input_chunk.dtype != grad_logits_t.dtype:
input_chunk = input_chunk.to(grad_logits_t.dtype)
torch.addmm(
grad_weight,
grad_logits_t,
input_chunk,
out=grad_weight,
)
else:
# Legacy fallback: unsupported torch/device (no out_dtype), fp64 accumulators,
# or a dtype mismatch (e.g. fp32 grad_logits promoted under AMP). Correct but
# allocates a parameter-sized fp32 temporary before summing into grad_weight.
grad_weight += torch.mm(grad_logits_chunk.t(), _input_chunk).float()
if bias is not None and input_requires_grad:
+628
View File
@@ -0,0 +1,628 @@
"""Regression tests for the weight-gradient ``addmm`` projection in
``fused_linear_cross_entropy_forward``.
These lock in the low-precision ``grad_weight`` accumulation path: when
``accum_dtype=None`` and the parameters are bf16/fp16, the per-chunk weight
gradient must be accumulated *directly* into ``grad_weight`` via
``torch.addmm(out=grad_weight)`` (mirroring the CuTe backend's direct bf16
addmm), instead of the legacy ``torch.mm(...).float()`` which materialises a
parameter-sized bf16->fp32 temporary + cast every chunk.
The tests assert on the actual aten dispatch (``TorchDispatchMode``) *and* on
numerical parity against a full-precision autograd reference. The whole file is
GPU-gated by ``test/ops/conftest.py``'s autouse ``_require_cuda_for_ops_tests``
fixture, and the accumulation path itself requires a CUDA SM80+ device.
"""
from __future__ import annotations
from typing import Optional
import pytest
import torch
import torch.nn.functional as F
from torch.utils._python_dispatch import TorchDispatchMode
import liger_kernel.ops.fused_linear_cross_entropy as flce_ops
from liger_kernel.ops.fused_linear_cross_entropy import fused_linear_cross_entropy_forward
# ---------------------------------------------------------------------------
# Fast-path capability gates
# ---------------------------------------------------------------------------
#
# The whole file is CUDA-gated by ``conftest.py``'s autouse fixture, but *being
# on CUDA* is not sufficient for the ``addmm(out=)`` / ``addmm.dtype_out`` fast
# paths. Production (``fused_linear_cross_entropy_forward``) gates them on:
#
# * ``on_cuda_sm80`` -- ``get_device_capability()[0] >= 8`` (SM80+; this is
# also exactly where bf16 GEMM hardware becomes available), and
# * ``_ADDMM_SUPPORTS_OUT_DTYPE`` -- torch >= 2.8, additionally required for
# the fp32-accumulator ``addmm.dtype_out`` overload.
#
# On valid-but-older setups (real SM70/SM75, or the base ``torch>=2.1.2`` pin
# through torch<2.8) production correctly falls back to ``torch.mm(...).float()``.
# Only tests that *assert the fast path was taken* must be gated with the same
# predicate; parity/fallback tests stay ungated so they still run everywhere.
def _cuda_capability_major() -> int:
if not torch.cuda.is_available():
return -1
return torch.cuda.get_device_capability()[0]
# Reused across every fast-path-asserting test (mirrors production's on_cuda_sm80).
requires_sm80 = pytest.mark.skipif(
_cuda_capability_major() < 8,
reason="addmm(out=) fast path requires CUDA SM80+ (get_device_capability()[0] >= 8)",
)
# Additionally required by the fp32-accumulator addmm.dtype_out fast path.
requires_addmm_out_dtype = pytest.mark.skipif(
not flce_ops._ADDMM_SUPPORTS_OUT_DTYPE,
reason="addmm out_dtype overload (fp32 accumulator fast path) requires torch >= 2.8",
)
# ---------------------------------------------------------------------------
# Dispatch recorder
# ---------------------------------------------------------------------------
_ADDMM_OPS = ("aten.addmm.out", "aten.addmm.dtype_out")
class _OpRecorder(TorchDispatchMode):
"""Records the aten ops we care about: addmm (with the out-tensor shape and
whether the out_dtype overload was used) and ``_to_copy`` (with in/out dtype
and shape so we can detect parameter-sized upcasts)."""
def __init__(self):
self.addmm_out_shapes = [] # list of torch.Size for aten.addmm.out
self.addmm_dtype_out_shapes = [] # list of torch.Size for aten.addmm.dtype_out
self.mm_default = 0 # count of aten.mm.default
self.to_copies = [] # list of (in_dtype, out_dtype, shape)
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
kwargs = kwargs or {}
name = str(func)
if name in _ADDMM_OPS:
out = kwargs.get("out")
shape = tuple(out.shape) if out is not None else None
if name == "aten.addmm.out":
self.addmm_out_shapes.append(shape)
else:
self.addmm_dtype_out_shapes.append(shape)
elif name == "aten.mm.default":
self.mm_default += 1
elif name == "aten._to_copy.default":
src = args[0] if args else None
if isinstance(src, torch.Tensor):
self.to_copies.append((src.dtype, kwargs.get("dtype"), tuple(src.shape)))
return func(*args, **kwargs)
def has_addmm_out_into(self, shape) -> bool:
return tuple(shape) in self.addmm_out_shapes
def has_addmm_dtype_out_into(self, shape) -> bool:
return tuple(shape) in self.addmm_dtype_out_shapes
def has_param_upcast(self, shape, low_dtypes=(torch.bfloat16, torch.float16)) -> bool:
"""True iff some ``_to_copy`` upcast a *parameter-shaped* low-precision
tensor to fp32 (the legacy ``.float()`` temporary we want to eliminate)."""
tgt = tuple(shape)
return any(src in low_dtypes and dst == torch.float32 and sh == tgt for (src, dst, sh) in self.to_copies)
# ---------------------------------------------------------------------------
# Reference + driver helpers
# ---------------------------------------------------------------------------
DEVICE = "cuda"
def _reference_grads(
_input: torch.Tensor,
weight: torch.Tensor,
target: torch.Tensor,
bias: Optional[torch.Tensor] = None,
ce_weight: Optional[torch.Tensor] = None,
ignore_index: int = -100,
label_smoothing: float = 0.0,
reduction: str = "mean",
token_grad_output: Optional[torch.Tensor] = None,
):
"""Full-precision autograd reference for (loss, grad_input, grad_weight, grad_bias)."""
xi = _input.detach().float().requires_grad_(True)
wi = weight.detach().float().requires_grad_(weight.requires_grad)
bi = None
if bias is not None:
bi = bias.detach().float().requires_grad_(bias.requires_grad)
logits = xi @ wi.t()
if bi is not None:
logits = logits + bi
cw = ce_weight.detach().float() if ce_weight is not None else None
loss = F.cross_entropy(
logits,
target,
weight=cw,
ignore_index=ignore_index,
label_smoothing=label_smoothing,
reduction=reduction,
)
if reduction == "none":
assert token_grad_output is not None
loss.backward(token_grad_output.float())
else:
loss.backward()
return (
loss.detach(),
xi.grad.detach(),
wi.grad.detach() if wi.grad is not None else None,
bi.grad.detach() if (bi is not None and bi.grad is not None) else None,
)
def _run_flce(
_input,
weight,
target,
*,
bias=None,
ce_weight=None,
ignore_index=-100,
label_smoothing=0.0,
reduction="mean",
accum_dtype=None,
chunk_size=None,
use_token_scaling=False,
token_grad_output=None,
recorder: Optional[_OpRecorder] = None,
autocast_dtype=None,
):
"""Invoke the forward the same way the autograd Function does: under
``no_grad`` (Function.forward runs with grad disabled), optionally under
autocast, optionally under a dispatch recorder."""
def _call():
return fused_linear_cross_entropy_forward(
_input,
weight,
target,
ce_weight=ce_weight,
bias=bias,
ignore_index=ignore_index,
label_smoothing=label_smoothing,
reduction=reduction,
accum_dtype=accum_dtype,
use_token_scaling=use_token_scaling,
token_grad_output=token_grad_output,
chunk_size=chunk_size,
)
import contextlib
stack = contextlib.ExitStack()
with stack:
stack.enter_context(torch.no_grad())
if autocast_dtype is not None:
stack.enter_context(torch.autocast("cuda", dtype=autocast_dtype))
if recorder is not None:
stack.enter_context(recorder)
loss, z_loss, token_acc, pred, grad_input, grad_weight, grad_bias = _call()
return loss, grad_input, grad_weight, grad_bias
_TOLS = {
torch.float16: dict(atol=5e-2, rtol=1e-2),
torch.bfloat16: dict(atol=1e-1, rtol=2e-2),
torch.float32: dict(atol=1e-4, rtol=1e-4),
}
def _mk_inputs(
BT,
V,
H,
dtype,
*,
bias,
ce_weight,
seed=0,
input_dtype=None,
weight_requires_grad=True,
input_requires_grad=True,
all_ignore=False,
first_chunk_ignore=False,
chunk_size=None,
):
g = torch.Generator(device=DEVICE).manual_seed(seed)
input_dtype = input_dtype or dtype
_input = torch.randn(BT, H, device=DEVICE, dtype=input_dtype, generator=g) * 0.1
_input.requires_grad_(input_requires_grad)
weight = torch.randn(V, H, device=DEVICE, dtype=dtype, generator=g) * 0.1
weight.requires_grad_(weight_requires_grad)
b = None
if bias:
b = (torch.randn(V, device=DEVICE, dtype=dtype, generator=g) * 0.1).requires_grad_(True)
cw = None
if ce_weight:
cw = torch.rand(V, device=DEVICE, dtype=torch.float32, generator=g) + 0.1
target = torch.randint(0, V, (BT,), device=DEVICE, generator=g, dtype=torch.long)
if all_ignore:
target[:] = -100
elif first_chunk_ignore and chunk_size is not None:
target[:chunk_size] = -100
return _input, weight, b, cw, target
# ---------------------------------------------------------------------------
# 1. Dispatch: the low-precision default path uses addmm(out=) and no param upcast
# ---------------------------------------------------------------------------
@requires_sm80
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
@pytest.mark.parametrize("bias", [True, False])
def test_lowprec_default_uses_addmm_out_no_param_upcast(dtype, bias):
BT, V, H, cs = 40, 64, 32, 16 # 3 chunks (16,16,8) -> multichunk + tail
_input, weight, b, cw, target = _mk_inputs(BT, V, H, dtype, bias=bias, ce_weight=False, chunk_size=cs)
rec = _OpRecorder()
loss, gi, gw, gb = _run_flce(_input, weight, target, bias=b, chunk_size=cs, accum_dtype=None, recorder=rec)
# The accumulator inherits the (low-precision) parameter dtype ...
assert gw.dtype == weight.dtype
# ... and is written by addmm(out=grad_weight) into a parameter-shaped buffer.
assert rec.has_addmm_out_into(weight.shape), (
f"expected aten.addmm.out into {tuple(weight.shape)}, saw {rec.addmm_out_shapes}"
)
# The fp32 out_dtype overload must NOT be used for a low-precision accumulator.
assert not rec.has_addmm_dtype_out_into(weight.shape)
# And crucially: no parameter-sized bf16/fp16 -> fp32 temporary (the legacy .float()).
assert not rec.has_param_upcast(weight.shape), f"unexpected parameter-shaped upcast to fp32: {rec.to_copies}"
# Parity vs full-precision autograd reference.
tol = _TOLS[dtype]
_, ref_gi, ref_gw, ref_gb = _reference_grads(_input, weight, target, bias=b)
assert torch.isfinite(gw).all()
torch.testing.assert_close(gw.float(), ref_gw, **tol)
torch.testing.assert_close(gi.float(), ref_gi, **tol)
if bias:
torch.testing.assert_close(gb.float(), ref_gb, **tol)
# ---------------------------------------------------------------------------
# 2. Numerical parity across the combinatorial space (small shapes)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16, torch.float32])
@pytest.mark.parametrize("reduction", ["mean", "sum"])
@pytest.mark.parametrize("bias", [True, False])
@pytest.mark.parametrize("ce_weight", [True, False])
@pytest.mark.parametrize("label_smoothing", [0.0, 0.1])
def test_parity_combinations(dtype, reduction, bias, ce_weight, label_smoothing):
BT, V, H, cs = 40, 64, 32, 16
_input, weight, b, cw, target = _mk_inputs(BT, V, H, dtype, bias=bias, ce_weight=ce_weight, chunk_size=cs, seed=1)
_, gi, gw, gb = _run_flce(
_input,
weight,
target,
bias=b,
ce_weight=cw,
reduction=reduction,
label_smoothing=label_smoothing,
chunk_size=cs,
)
_, ref_gi, ref_gw, ref_gb = _reference_grads(
_input,
weight,
target,
bias=b,
ce_weight=cw,
reduction=reduction,
label_smoothing=label_smoothing,
)
tol = _TOLS[dtype]
assert torch.isfinite(gw).all() and torch.isfinite(gi).all()
torch.testing.assert_close(gw.float(), ref_gw, **tol)
torch.testing.assert_close(gi.float(), ref_gi, **tol)
if bias:
torch.testing.assert_close(gb.float(), ref_gb, **tol)
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
def test_parity_reduction_none_nonuniform_upstream(dtype):
BT, V, H, cs = 24, 48, 32, 8 # 3 chunks (8,8,8)
_input, weight, b, cw, target = _mk_inputs(BT, V, H, dtype, bias=True, ce_weight=False, chunk_size=cs, seed=2)
g = torch.Generator(device=DEVICE).manual_seed(7)
token_grad_output = torch.rand(BT, device=DEVICE, dtype=torch.float32, generator=g) + 0.05
_, gi, gw, gb = _run_flce(
_input,
weight,
target,
bias=b,
reduction="none",
token_grad_output=token_grad_output,
chunk_size=cs,
)
_, ref_gi, ref_gw, ref_gb = _reference_grads(
_input,
weight,
target,
bias=b,
reduction="none",
token_grad_output=token_grad_output,
)
tol = _TOLS[dtype]
torch.testing.assert_close(gw.float(), ref_gw, **tol)
torch.testing.assert_close(gi.float(), ref_gi, **tol)
torch.testing.assert_close(gb.float(), ref_gb, **tol)
# ---------------------------------------------------------------------------
# 3. Ignore-index edge cases initialise correctly (zero-init preserved)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
def test_first_chunk_all_ignored(dtype):
BT, V, H, cs = 40, 64, 32, 16
_input, weight, b, cw, target = _mk_inputs(
BT, V, H, dtype, bias=True, ce_weight=False, chunk_size=cs, seed=3, first_chunk_ignore=True
)
_, gi, gw, gb = _run_flce(_input, weight, target, bias=b, chunk_size=cs)
_, ref_gi, ref_gw, ref_gb = _reference_grads(_input, weight, target, bias=b)
tol = _TOLS[dtype]
assert torch.isfinite(gw).all()
# First chunk was fully ignored -> its rows contribute zero grad_input.
assert torch.count_nonzero(gi[:cs]) == 0
torch.testing.assert_close(gw.float(), ref_gw, **tol)
torch.testing.assert_close(gi.float(), ref_gi, **tol)
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
def test_all_tokens_ignored_zero_grad(dtype):
BT, V, H, cs = 32, 64, 32, 16
_input, weight, b, cw, target = _mk_inputs(
BT, V, H, dtype, bias=True, ce_weight=False, chunk_size=cs, seed=4, all_ignore=True
)
_, gi, gw, gb = _run_flce(_input, weight, target, bias=b, chunk_size=cs)
# Every token ignored -> all grads are exactly the zero-initialised buffers.
assert torch.isfinite(gw).all()
assert torch.count_nonzero(gw) == 0
assert torch.count_nonzero(gi) == 0
assert torch.count_nonzero(gb) == 0
# ---------------------------------------------------------------------------
# 4. Frozen weight + trainable input -> no grad_weight, no addmm into weight
# ---------------------------------------------------------------------------
def test_frozen_weight_trainable_input():
BT, V, H, cs = 40, 64, 32, 16
_input, weight, b, cw, target = _mk_inputs(
BT,
V,
H,
torch.bfloat16,
bias=False,
ce_weight=False,
chunk_size=cs,
seed=5,
weight_requires_grad=False,
input_requires_grad=True,
)
rec = _OpRecorder()
_, gi, gw, gb = _run_flce(_input, weight, target, chunk_size=cs, recorder=rec)
assert gw is None
assert not rec.has_addmm_out_into(weight.shape)
assert not rec.has_addmm_dtype_out_into(weight.shape)
# grad_input must still be produced and correct.
_, ref_gi, _, _ = _reference_grads(_input, weight, target)
torch.testing.assert_close(gi.float(), ref_gi, **_TOLS[torch.bfloat16])
# ---------------------------------------------------------------------------
# 5. Non-contiguous input / weight
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
def test_noncontiguous_input_and_weight(dtype):
BT, V, H, cs = 40, 64, 32, 16
g = torch.Generator(device=DEVICE).manual_seed(6)
_input = (torch.randn(H, BT, device=DEVICE, dtype=dtype, generator=g) * 0.1).t().requires_grad_(True)
weight = (torch.randn(H, V, device=DEVICE, dtype=dtype, generator=g) * 0.1).t().requires_grad_(True)
assert not _input.is_contiguous() and not weight.is_contiguous()
target = torch.randint(0, V, (BT,), device=DEVICE, generator=g, dtype=torch.long)
_, gi, gw, gb = _run_flce(_input, weight, target, chunk_size=cs)
_, ref_gi, ref_gw, _ = _reference_grads(_input, weight, target)
tol = _TOLS[dtype]
assert torch.isfinite(gw).all()
torch.testing.assert_close(gw.float(), ref_gw, **tol)
torch.testing.assert_close(gi.float(), ref_gi, **tol)
# ---------------------------------------------------------------------------
# 6. Explicit fp32 accumulator stays on the out_dtype fast path
# ---------------------------------------------------------------------------
@requires_sm80
@requires_addmm_out_dtype
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
def test_fp32_accum_uses_dtype_out_fastpath(dtype):
BT, V, H, cs = 40, 64, 32, 16
_input, weight, b, cw, target = _mk_inputs(BT, V, H, dtype, bias=False, ce_weight=False, chunk_size=cs, seed=8)
rec = _OpRecorder()
_, gi, gw, gb = _run_flce(_input, weight, target, accum_dtype=torch.float32, chunk_size=cs, recorder=rec)
# Accumulator is fp32; the returned grad is cast back to the weight dtype.
assert gw.dtype == weight.dtype
# The fp32 out_dtype overload must be used, and the plain out overload must not.
assert rec.has_addmm_dtype_out_into(weight.shape), (
f"expected aten.addmm.dtype_out into {tuple(weight.shape)}, saw {rec.addmm_dtype_out_shapes}"
)
assert not rec.has_addmm_out_into(weight.shape)
# No parameter-sized bf16/fp16 -> fp32 legacy temporary.
assert not rec.has_param_upcast(weight.shape)
_, ref_gi, ref_gw, _ = _reference_grads(_input, weight, target)
torch.testing.assert_close(gw.float(), ref_gw, atol=1e-2, rtol=1e-2)
torch.testing.assert_close(gi.float(), ref_gi, **_TOLS[dtype])
# ---------------------------------------------------------------------------
# 7. Pre-2.8 torch (no out_dtype support) -> legacy fallback, never the out_dtype op
# ---------------------------------------------------------------------------
@requires_sm80
@pytest.mark.parametrize("accum_dtype", [None, torch.float32])
def test_pre28_falls_back_to_legacy_mm(monkeypatch, accum_dtype):
BT, V, H, cs = 40, 64, 32, 16
# Under bf16 params + fp32 accumulator, the fast path needs out_dtype support.
_input, weight, b, cw, target = _mk_inputs(
BT, V, H, torch.bfloat16, bias=False, ce_weight=False, chunk_size=cs, seed=9
)
monkeypatch.setattr(flce_ops, "_ADDMM_SUPPORTS_OUT_DTYPE", False)
rec = _OpRecorder()
_, gi, gw, gb = _run_flce(_input, weight, target, accum_dtype=accum_dtype, chunk_size=cs, recorder=rec)
# The out_dtype overload must never be reached when the flag is False.
assert not rec.has_addmm_dtype_out_into(weight.shape)
if accum_dtype is torch.float32:
# fp32 accumulator falls back to legacy torch.mm(...).float(): mm + a
# parameter-sized fp32 upcast, and no addmm at all into the weight buffer.
assert rec.mm_default >= 1
assert rec.has_param_upcast(weight.shape)
assert not rec.has_addmm_out_into(weight.shape)
else:
# bf16 accumulator does not depend on the out_dtype flag; the dtype-matched
# low-precision addmm(out=) path is still taken (no legacy upcast).
assert rec.has_addmm_out_into(weight.shape)
assert not rec.has_param_upcast(weight.shape)
_, ref_gi, ref_gw, _ = _reference_grads(_input, weight, target)
torch.testing.assert_close(gw.float(), ref_gw, **_TOLS[torch.bfloat16])
torch.testing.assert_close(gi.float(), ref_gi, **_TOLS[torch.bfloat16])
# ---------------------------------------------------------------------------
# 8. AMP: in-place out ops are not autocast, so _input_chunk must be aligned
# ---------------------------------------------------------------------------
@requires_sm80
def test_amp_bf16_params_fp32_input_uses_lowprec_addmm():
"""BF16 params + FP32 input under bf16 autocast: grad_weight is bf16 and
grad_logits is bf16, so the dtype-matched low-precision addmm(out=) path runs.
The fp32 _input_chunk must be cast to bf16 for the (non-autocast) out op."""
BT, V, H, cs = 40, 64, 32, 16
g = torch.Generator(device=DEVICE).manual_seed(10)
_input = (torch.randn(BT, H, device=DEVICE, dtype=torch.float32, generator=g) * 0.1).requires_grad_(True)
weight = (torch.randn(V, H, device=DEVICE, dtype=torch.bfloat16, generator=g) * 0.1).requires_grad_(True)
target = torch.randint(0, V, (BT,), device=DEVICE, generator=g, dtype=torch.long)
rec = _OpRecorder()
_, gi, gw, gb = _run_flce(_input, weight, target, chunk_size=cs, recorder=rec, autocast_dtype=torch.bfloat16)
assert gw.dtype == torch.bfloat16
assert rec.has_addmm_out_into(weight.shape)
assert not rec.has_addmm_dtype_out_into(weight.shape)
assert not rec.has_param_upcast(weight.shape)
_, ref_gi, ref_gw, _ = _reference_grads(_input, weight, target)
tol = _TOLS[torch.bfloat16]
torch.testing.assert_close(gw.float(), ref_gw, **tol)
torch.testing.assert_close(gi.float(), ref_gi, **tol)
@requires_sm80
@requires_addmm_out_dtype
def test_amp_fp32_params_no_bias_uses_dtype_out_fastpath():
"""FP32 params, no bias, under bf16 autocast: grad_weight is fp32 and
grad_logits is bf16, so the out_dtype fast path runs (with _input_chunk
left fp32 and aligned to bf16 for addmm)."""
BT, V, H, cs = 40, 64, 32, 16
g = torch.Generator(device=DEVICE).manual_seed(11)
_input = (torch.randn(BT, H, device=DEVICE, dtype=torch.float32, generator=g) * 0.1).requires_grad_(True)
weight = (torch.randn(V, H, device=DEVICE, dtype=torch.float32, generator=g) * 0.1).requires_grad_(True)
target = torch.randint(0, V, (BT,), device=DEVICE, generator=g, dtype=torch.long)
rec = _OpRecorder()
_, gi, gw, gb = _run_flce(_input, weight, target, chunk_size=cs, recorder=rec, autocast_dtype=torch.bfloat16)
assert gw.dtype == torch.float32
assert rec.has_addmm_dtype_out_into(weight.shape)
assert not rec.has_addmm_out_into(weight.shape)
_, ref_gi, ref_gw, _ = _reference_grads(_input, weight, target)
torch.testing.assert_close(gw.float(), ref_gw, atol=1e-2, rtol=1e-2)
torch.testing.assert_close(gi.float(), ref_gi, atol=1e-2, rtol=1e-2)
def test_amp_fp32_params_fp32_bias_promotes_and_falls_back():
"""FP32 params + FP32 bias under bf16 autocast: the (bf16) logits + (fp32)
bias add promotes grad_logits to fp32, so neither addmm fast path applies and
the legacy torch.mm(...).float() path must be used (preserving prior behavior).
This guards against accidentally running an fp32 addmm for promoted logits."""
BT, V, H, cs = 40, 64, 32, 16
g = torch.Generator(device=DEVICE).manual_seed(12)
_input = (torch.randn(BT, H, device=DEVICE, dtype=torch.float32, generator=g) * 0.1).requires_grad_(True)
weight = (torch.randn(V, H, device=DEVICE, dtype=torch.float32, generator=g) * 0.1).requires_grad_(True)
bias = (torch.randn(V, device=DEVICE, dtype=torch.float32, generator=g) * 0.1).requires_grad_(True)
target = torch.randint(0, V, (BT,), device=DEVICE, generator=g, dtype=torch.long)
rec = _OpRecorder()
_, gi, gw, gb = _run_flce(
_input, weight, target, bias=bias, chunk_size=cs, recorder=rec, autocast_dtype=torch.bfloat16
)
# grad_logits promoted to fp32 -> no fp32-out fast path, no low-prec out path.
assert not rec.has_addmm_dtype_out_into(weight.shape)
assert not rec.has_addmm_out_into(weight.shape)
assert rec.mm_default >= 1
_, ref_gi, ref_gw, ref_gb = _reference_grads(_input, weight, target, bias=bias)
torch.testing.assert_close(gw.float(), ref_gw, atol=1e-2, rtol=1e-2)
torch.testing.assert_close(gi.float(), ref_gi, atol=1e-2, rtol=1e-2)
torch.testing.assert_close(gb.float(), ref_gb, atol=1e-2, rtol=1e-2)
@requires_sm80
def test_amp_use_token_scaling_lowprec():
"""Scaled-loss (use_token_scaling) under bf16 params still routes through the
dtype-matched low-precision addmm(out=) path and stays numerically finite."""
BT, V, H, cs = 24, 48, 32, 8
_input, weight, b, cw, target = _mk_inputs(
BT, V, H, torch.bfloat16, bias=True, ce_weight=False, chunk_size=cs, seed=13
)
rec = _OpRecorder()
_, gi, gw, gb = _run_flce(_input, weight, target, bias=b, chunk_size=cs, use_token_scaling=True, recorder=rec)
assert gw.dtype == torch.bfloat16
assert rec.has_addmm_out_into(weight.shape)
assert not rec.has_param_upcast(weight.shape)
assert torch.isfinite(gw).all() and torch.isfinite(gi).all()
# ---------------------------------------------------------------------------
# 9. Capability guard: sub-SM80 reported capability forces the legacy fallback
# ---------------------------------------------------------------------------
def test_sm_capability_guard_forces_legacy_fallback(monkeypatch):
"""Guard regression -- intentionally *not* SM80-gated. Even on real SM80+
hardware, production gates the addmm fast paths on ``get_device_capability()
[0] >= 8``; if the reported capability is < 8 the fast paths must not run.
Monkeypatch the capability to (7, 0) and assert the legacy
``torch.mm(...).float()`` fallback is taken (bf16 params, which would
otherwise hit the low-precision addmm(out=) path)."""
BT, V, H, cs = 40, 64, 32, 16
_input, weight, b, cw, target = _mk_inputs(
BT, V, H, torch.bfloat16, bias=False, ce_weight=False, chunk_size=cs, seed=14
)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a, **k: (7, 0))
rec = _OpRecorder()
_, gi, gw, gb = _run_flce(_input, weight, target, chunk_size=cs, recorder=rec)
# Sub-SM80 capability -> neither addmm fast path; legacy mm + fp32 upcast instead.
assert not rec.has_addmm_out_into(weight.shape)
assert not rec.has_addmm_dtype_out_into(weight.shape)
assert rec.mm_default >= 1
assert rec.has_param_upcast(weight.shape)
_, ref_gi, ref_gw, _ = _reference_grads(_input, weight, target)
torch.testing.assert_close(gw.float(), ref_gw, **_TOLS[torch.bfloat16])
torch.testing.assert_close(gi.float(), ref_gi, **_TOLS[torch.bfloat16])
+141
View File
@@ -202,6 +202,19 @@ class TestArgValidation:
with pytest.raises(SystemExit):
self._parse(["--warmup", "-1"])
def test_accum_dtype_defaults_to_fp32(self):
# Backward compatible: omitting the flag keeps the FP32-matched accumulator.
assert self._parse([]).accum_dtype == "fp32"
@pytest.mark.parametrize("mode", ["fp32", "default"])
def test_accum_dtype_choices_accepted(self, mode):
assert self._parse(["--accum-dtype", mode]).accum_dtype == mode
@pytest.mark.parametrize("bad", ["bf16", "none", "float32", "auto"])
def test_accum_dtype_rejects_unknown(self, bad):
with pytest.raises(SystemExit):
self._parse(["--accum-dtype", bad])
class TestTimeBackendLifecycle:
def _patch_cuda(self, monkeypatch, on_baseline=None):
@@ -278,6 +291,7 @@ class TestRunFailFast:
"warmup": 0,
"iters": 1,
"seed": 0,
"accum_dtype": "fp32",
},
)()
@@ -314,6 +328,132 @@ class TestRunFailFast:
assert "eff_chunk" in out
class _SlotRecFn(torch.autograd.Function):
"""Records the value passed at FLCE Function slot 11 (``accum_dtype``)."""
seen = []
@staticmethod
def forward(ctx, x, w, target, *rest):
# rest = (bias, ce_weight, ignore_index, lse_square_scale, label_smoothing,
# reduction, softcap, return_z_loss, accum_dtype, ...); slot 11 overall
# is accum_dtype, i.e. rest[8].
_SlotRecFn.seen.append(rest[8])
ctx.save_for_backward(x, w)
loss = (x.float() @ w.float().t()).sum()
return loss, None, None, None
@staticmethod
def backward(ctx, grad_loss, *rest):
x, w = ctx.saved_tensors
return (torch.ones_like(x) * grad_loss, torch.ones_like(w) * grad_loss) + (None,) * 16
class TestAccumDtypeThreading:
def test_effective_accum_labels(self):
# fp32 forces FP32 everywhere; default keeps triton/cutedsl BF16 but cutile FP32.
for name in ("triton", "cutedsl", "cutile"):
assert bench._effective_accum(name, "fp32") == "fp32"
assert bench._effective_accum("triton", "default") == "bf16"
assert bench._effective_accum("cutedsl", "default") == "bf16"
assert bench._effective_accum("cutile", "default") == "fp32"
def test_apply_forwards_dtype_to_slot_11(self):
x, w, target = _cpu_inputs()
_SlotRecFn.seen = []
bench._apply(_SlotRecFn, x, w, target, None, accum_dtype=torch.float32)
bench._apply(_SlotRecFn, x, w, target, None, accum_dtype=None)
assert _SlotRecFn.seen == [torch.float32, None]
def test_apply_default_is_fp32(self):
# Drop-in default preserves the earlier hard-coded FP32 accumulator.
x, w, target = _cpu_inputs()
_SlotRecFn.seen = []
bench._apply(_SlotRecFn, x, w, target, None)
assert _SlotRecFn.seen == [torch.float32]
def _patch_cuda(self, monkeypatch):
_FakeEvent._clock = 0.0
monkeypatch.setattr(torch.cuda, "synchronize", lambda *a, **k: None)
monkeypatch.setattr(torch.cuda, "reset_peak_memory_stats", lambda *a, **k: None)
monkeypatch.setattr(torch.cuda, "memory_allocated", lambda *a, **k: 1000)
monkeypatch.setattr(torch.cuda, "max_memory_allocated", lambda *a, **k: 5000)
monkeypatch.setattr(torch.cuda, "Event", lambda *a, **k: _FakeEvent())
@pytest.mark.parametrize("accum", [torch.float32, None])
def test_both_modes_roundtrip_through_iterations(self, monkeypatch, accum):
# Every timed/warmup/probe iteration threads the same accum dtype to slot 11.
x, w, target = _cpu_inputs()
self._patch_cuda(monkeypatch)
_SlotRecFn.seen = []
bench._time_backend(_SlotRecFn, x, w, target, None, warmup=2, iters=3, accum_dtype=accum)
# 1 compile + 2 warmup + 1 memory-probe + 3 timed = 7 applies, all identical.
assert len(_SlotRecFn.seen) == 7
assert all(v is accum for v in _SlotRecFn.seen)
def _run_args(self, backends, accum_dtype):
return type(
"Args",
(),
{
"backends": backends,
"tokens": 8,
"hidden_size": 4,
"vocab_size": 6,
"chunk_sizes": [4],
"warmup": 0,
"iters": 1,
"seed": 0,
"accum_dtype": accum_dtype,
},
)()
def _patch_run(self, monkeypatch):
monkeypatch.setattr(bench, "_capability", lambda: (10, 0))
monkeypatch.setattr(bench, "_check_backend_supported", lambda *a, **k: None)
monkeypatch.setattr(bench, "_load_backend", lambda name: _CpuFLCE)
monkeypatch.setattr(bench, "_build_inputs", lambda *a, **k: _cpu_inputs())
monkeypatch.setattr(torch.cuda, "get_device_name", lambda *a, **k: "MockGPU")
self._patch_cuda(monkeypatch)
def test_default_mode_prints_requested_and_per_backend_effective(self, monkeypatch, capsys):
self._patch_run(monkeypatch)
bench._run(self._run_args(["triton", "cutedsl", "cutile"], "default"))
out = capsys.readouterr().out
# Header advertises the requested mode and refuses to call it precision-matched.
assert "accum_dtype_requested=default" in out
assert "NOT precision matched" in out
assert "eff_accum" in out
rows = {
line.split()[0]: line
for line in out.splitlines()
if line.split() and line.split()[0] in bench._MODULE_PATHS
}
# cutile keeps FP32 even under default; triton/cutedsl drop to BF16.
assert "fp32" in rows["cutile"].split()
assert "bf16" in rows["triton"].split()
assert "bf16" in rows["cutedsl"].split()
def test_fp32_mode_labels_precision_matched(self, monkeypatch, capsys):
self._patch_run(monkeypatch)
bench._run(self._run_args(["triton", "cutile"], "fp32"))
out = capsys.readouterr().out
assert "accum_dtype_requested=fp32" in out
assert "precision matched" in out
assert "NOT precision matched" not in out
for line in out.splitlines():
if line.split() and line.split()[0] in bench._MODULE_PATHS:
assert "fp32" in line.split()
def test_run_does_not_mutate_module_globals(self, monkeypatch):
self._patch_run(monkeypatch)
before = bench._ACCUM_DTYPE
bench._run(self._run_args(["triton"], "default"))
bench._run(self._run_args(["triton"], "fp32"))
# Modes flow purely as parameters; no state leak between runs.
assert bench._ACCUM_DTYPE is before is torch.float32
# ===========================================================================
# B. GPU projection-geometry traces (whole-pipeline, not CE-only isolation)
# ===========================================================================
@@ -523,6 +663,7 @@ def test_benchmark_run_all3_smoke():
"warmup": 0,
"iters": 2,
"seed": 0,
"accum_dtype": "fp32",
},
)()
bench._run(args) # must not raise