Files
zl_bench/_cudagraph_plugin.py
T
AharrypotterandClaude Opus 5 c8ea586d0f feat: make the harness backend-agnostic
Everything about which accelerator a run targets was previously spelled CUDA:
device discovery went through nvidia-smi, pinning through CUDA_VISIBLE_DEVICES,
the vendor guard only recognised NVIDIA, and the -p plugin list was hardcoded in
run_pytest.sh with no way to drop the CUDA-only members. Running the same
operator corpus on another vendor's device therefore meant editing the drivers.

The selection now hangs off one ZL_BACKEND variable (cuda/musa/rocm/ascend, with
the usual aliases):

- backends/ owns per-backend device enumeration, visibility env keys and
  capability flags, so nothing above it names a vendor;
- plugin_plan.py derives the -p list from those capabilities instead of a
  hardcoded array, which is what lets a non-CUDA run omit the graph-capture and
  cluster-fix plugins rather than watch them fail;
- _timing_plugin selects the timer from the same capabilities: CUDA keeps
  graph capture with its per-shape fallback, other backends stay on upstream
  do_bench and record timer=direct, because capture-then-fallback on a device
  without graph support only produces noise;
- _device_setup_plugin replaces the NVIDIA-only vendor guard;
- providers/flaggems.py resolves the pytest node, so a caller may pass TEST_NODE
  directly instead of the OP/OP_FILE pair, and SHAPE_MODE=upstream can defer to
  FlagGems' own core_shapes;
- _run_identity.py writes run_identity.json next to each run, recording backend,
  device, timer, autotune mode and the resolved test node. Two runs whose
  calibers differ are otherwise indistinguishable after the fact.

run_batch gains --backend and --devices (--gpus kept as an alias) and carries
backend/device/timer into ops_status.csv. test_backends.py covers the registry,
alias resolution, visibility env and plugin planning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:39:14 +08:00

186 lines
7.6 KiB
Python

"""pytest plugin: swap `triton.testing.do_bench` for cudagraph-based timing.
Invoked by `_timing_plugin` when the active backend declares
`supports_cuda_graph` (CUDA only). Do not put this module on the default `-p`
list for MUSA/ROCm/Ascend: those backends must start on plain do_bench, not
attempt capture-then-fallback. Monkey-patches do_bench →
do_bench_cudagraph (kernel-only latency) at session configure, after Triton is
loaded but before any benchmark calls it. Kernels that can't be graph-captured
fall back to plain do_bench and print a BENCHMARK_DIRECT_NO_CUDAGRAPH marker
into the log. Before capturing, fn is explicitly warmed up for the caller's
warmup time budget (do_bench_cudagraph's own 5-iter warmup is too short to
settle autotune/JIT for the MoE kernels, which makes the first captured
measurement unstable run-to-run). The capture itself runs under a scratch CUDA
RNG state so that an aborted capture cannot poison the process-wide generator
(rationale: _capture_with_scratch_rng).
"""
from __future__ import annotations
import os
import sys
import triton.testing as _tt
_ORIGINAL_DO_BENCH = _tt.do_bench
def _is_fatal_cuda_error(exc: Exception) -> bool:
"""True for CUDA context errors (illegal address, OOM, device-side assert):
the context is poisoned, so re-raise rather than fall back and record a bogus
latency as success.
Markers must match the error text itself, not torch's generic footer: newer
torch appends "Compile with `TORCH_USE_CUDA_DSA` to enable device-side
assertions." to EVERY AcceleratorError, so a loose "device-side assert"
marker turned all capture-incompatible ops into fatal re-raises and killed
the documented do_bench fallback path entirely."""
msg = f"{type(exc).__name__}: {exc}".lower()
markers = (
"illegal memory access",
"cudaerrorillegaladdress",
"out of memory",
"device-side assert triggered",
"an illegal instruction",
"misaligned address",
"uncorrectable ecc",
)
return any(m in msg for m in markers)
def _warmup_before_capture(fn, warmup_ms, grad_to_none):
"""Warm fn up before cudagraph capture.
do_bench_cudagraph only warms up 5 iterations internally — too few for the
MoE kernels, where the first calls trigger Triton autotune config
compilation, libtuner selection and lazy JIT/init. If that work leaks into
the captured graph or the first timed iteration the latency is unstable
run-to-run (most visibly on the small-M multi-kernel path). Absorb the
one-off compile with a throwaway run, then loop for the caller's warmup time
budget so the graph is captured at steady state.
The loop is clamped to 5..200 iterations: the cap keeps sub-ms kernels from
spinning through a large budget (FlagGems' Config.warm_up defaults to
1000 ms) — 200 iterations settles them fine in practice — and the floor
covers kernels whose single run exceeds the budget. If first-run jitter
reappears on a new op, raising the cap is the first knob to try.
"""
import time
import torch
def _run():
if grad_to_none is not None:
for x in grad_to_none:
x.grad = None
fn()
# First call absorbs autotune/JIT compilation (can take seconds); discard it.
_run()
torch.cuda.synchronize()
# Size the warmup loop from one timed steady-state run. dt_ms includes
# launch overhead, so n errs low for tiny kernels; warmup needn't be exact.
t0 = time.perf_counter()
_run()
torch.cuda.synchronize()
dt_ms = (time.perf_counter() - t0) * 1000.0
budget = warmup_ms if (warmup_ms and warmup_ms > 0) else 25.0
n = int(budget / dt_ms) if dt_ms > 0 else 25
n = max(5, min(n, 200))
for _ in range(n):
_run()
torch.cuda.synchronize()
def _capture_with_scratch_rng(call):
"""Run a graph capture under a throwaway CUDA RNG state.
torch.cuda.graph's capture_begin unconditionally registers the default CUDA
RNG generator and sets its capturing flag; when a capture aborts midway the
epilogue may never run, the flag sticks, and every later CUDA RNG call in
the process (the next shape's torch.randn included) raises "Offset
increment outside graph capture". Repairing after the fact is unreliable —
when the poisoning surfaces depends on which stage the capture died in —
so prevent it instead: swap in a scratch state via graphsafe_set_state
before capturing and restore the original state object afterwards, success
or failure. The real generator never takes part in a capture, so poisoning
can only land on the discarded scratch state, and _seed_plugin determinism
is left untouched."""
import torch
dev = torch.cuda.current_device()
gen = torch.cuda.default_generators[dev]
saved = gen.graphsafe_get_state()
scratch = torch.Generator(device="cuda")
scratch.manual_seed(gen.initial_seed())
gen.graphsafe_set_state(scratch)
try:
return call()
finally:
gen.graphsafe_set_state(saved)
def _patched_do_bench(fn, warmup=25, rep=100, grad_to_none=None,
quantiles=None, return_mode="mean"):
"""Drop-in replacement for triton.testing.do_bench using cudagraph capture."""
op = os.environ.get("FLAGGEMS_PERF_CURRENT_OP", "<unknown>")
phase = "sync" # which step rejected cudagraph, for the fallback marker
try:
# do_bench_cudagraph runs fn on a fresh side stream without syncing
# with the default stream first; input tensors produced just before
# (quantize/topk in the input iter) may still be in flight, and racing
# on them intermittently kills the context (illegal instruction from a
# device trap on garbage indices). Sync before switching streams.
import torch
torch.cuda.synchronize()
phase = "warmup"
# Settle autotune/JIT before capture (rationale: _warmup_before_capture).
_warmup_before_capture(fn, warmup, grad_to_none)
phase = "capture"
return _capture_with_scratch_rng(lambda: _tt.do_bench_cudagraph(
fn,
rep=rep,
grad_to_none=grad_to_none,
quantiles=quantiles,
return_mode=return_mode,
))
except Exception as exc:
# Re-raise genuine device failures; anything else falls back to plain
# do_bench, with the failing phase (sync/warmup/capture) in the marker.
if _is_fatal_cuda_error(exc):
raise
reason = type(exc).__name__
detail = " ".join(str(exc).split())[:160]
from _term_style import YELLOW, paint
print(
paint(f"BENCHMARK_DIRECT_NO_CUDAGRAPH op={op} phase={phase} "
f"reason={reason} detail={detail!r}", YELLOW),
flush=True,
)
return _ORIGINAL_DO_BENCH(
fn,
warmup=warmup,
rep=rep,
grad_to_none=grad_to_none,
quantiles=quantiles,
return_mode=return_mode,
)
def pytest_configure(config):
from _term_style import tag
try:
from backends import get_backend
if not get_backend().supports_cuda_graph:
print(f"{tag('[cudagraph-plugin]')} skipped: backend "
f"{get_backend().name!r} does not support CUDA Graph",
file=sys.stderr, flush=True)
return
except Exception as exc:
print(f"{tag('[cudagraph-plugin]')} backend probe failed ({exc}); "
"not patching do_bench", file=sys.stderr, flush=True)
return
_tt.do_bench = _patched_do_bench
print(f"{tag('[cudagraph-plugin]')} triton.testing.do_bench → do_bench_cudagraph",
file=sys.stderr, flush=True)