Make a single PassNet Triton kernel fast AND numerically faithful: performance model (when a replacement can win at all), block/grid/warp tuning, autotune policy, launch overhead, and per-op numeric recipes to pass the dtype baseline tolerances. Use when a pass matches and is correct but speedup below expected, or when correctness fails by small numeric margins.
Scanned 9/9/2026
Install to Claude Code
npx -y skills add VectorSpaceLab/AREX-Skill --skill passnet-triton-opt --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Passnet Triton Opt?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/vectorspacelab-passnet-triton-opt)More formats (shields.io, HTML) on the badges page.
---
name: passnet-triton-opt
description: >
Make a single PassNet Triton kernel fast AND numerically faithful: performance model
(when a replacement can win at all), block/grid/warp tuning, autotune policy, launch
overhead, and per-op numeric recipes to pass the dtype baseline tolerances. Use when a
pass matches and is correct but speedup below expected, or when correctness fails by small
numeric margins.
---
## 1. Performance model — know if a win is even possible
Measured costs (A100, torch 2.7.1 / triton 3.3.1; same order of magnitude on the eval box):
| component | cost per call |
|---|---|
| dynamo guards + FX graph interpretation + dispatch wrapper | ~25–45 µs on a 3-op graph; grows with node count (~150–230 µs seen on a 13-node graph) — calibrate from your first eval |
| Triton kernel Python launch (`kernel[grid](...)`) | ~19 µs each |
| `torch.empty_like` / `torch.empty` | ~2–3 µs each |
| each aten op you REMOVED from the graph | +5–25 µs back (small ops) or its real kernel time |
So per variant: `compiled_e2e ≈ eager_e2e − Σ(absorbed aten µs) + tax + Σ(your kernels µs)`.
Rules of thumb:
- Whole eager forward < ~150 µs and you can absorb ≤2 cheap ops → ceiling < 1. Ship a
safe correct pass, accept ~0.8–0.95, spend your time on another bottleneck.
- Small or narrow fusions can match and be numerically correct yet still lose e2e when
launch/framework overhead exceeds the eager work removed.
- Layout-only, view/reshape-only, split/fanout, tiny activation, and small reduction-like
kernels can also lose even when their local kernel is correct: eager may treat the work
as metadata, use a vendor-tuned primitive, or pay less framework overhead than the
compiled path. Treat these as floor/downside caps unless they bridge substantial compute
or remove an actual materialization.
- The lever is **ops absorbed per launch** (and avoiding extra launches/allocs), not
shaving 10% off an already-tiny kernel.
- Big tensors (≥ a few MB; kernel ≥ 100 µs): overhead noise vanishes; now memory traffic
is everything — a clean fused kernel that reads each input once and writes once usually
beats the multi-pass eager chain by the ratio of memory passes saved.
- **The biggest wins can be ALGORITHMIC, not tuning** — but the default for a heavy conv/matmul
is still "leave it in the library, fuse its tail," because the library is near-optimal inside
the regime it tuned for. The clearest case where a rewrite beats it: a **non-overlapping
convolution** (`stride == kernel_size`, `kernel_size > 1`, `padding == 0`) is a dense patch
matmul while cuDNN runs a heavier general im2col path — a `tl.dot` patch-gather kernel
(kernel-templates §13) can win big. It usually does NOT pay to rewrite a **1×1 conv** (already
a cuBLAS-optimal pointwise GEMM), an **overlapping conv** (im2col blows up memory), a
**depthwise/grouped conv**, or a **general dense matmul** — those are vendor sweet spots.
Treat this as a prior, not a ban: the completed eval decides. Pursue any rewrite only after a
floor is banked, and don't dismiss the non-overlapping case as "cuDNN, unbeatable" without
checking `stride == kernel_size`.
- For large cat/stack/gather-style outputs, one giant kernel is not automatically best if it
needs heavy branching or irregular index logic. A small fixed number of simple kernels can
be faster and more timing-stable when each launch writes a large contiguous region. Compare
sequential micro-bench signals, then trust the completed evaluation.
- Repeated round evidence favors long memory-bound elementwise and broadcast-affine chains
when one legal single-output kernel absorbs multiple affine, activation, residual, layout,
or normalization consumers. These are the first places to spend tuning effort after a
correct floor because saved memory passes can amortize wrapper and launch overhead.
- Repeated completed evaluations also favor normalization-affine compute prefixes and
concat-to-normalization-affine prefixes when they remove large memory passes in one launch.
Treat kernel layout choices for concat branches as workload-dependent: a broad prefix can
win strongly, but small/branchy cases or attempts to absorb extra producers can regress in
score, correctness, or timing stability. Keep the best completed floor/prefix state and
revert completed regressions instead of stacking more small kernels or sweeping blindly.
- Reduction-only regions and vendor-tail replacements still need completed-evaluation proof.
A correct reduction kernel, a small tail behind a vendor op, or a region that replaces a
vendor-tuned primitive can remain below eager even when local kernel timing looks plausible.
Use the completed eval to decide keep/revert; do not infer success from the triage family.
- Compare `[Speedup][gpu]` vs `[Speedup][e2e]` in the eval log: gpu≈e2e&both<1 → on-stream
gaps/launches dominate (absorb more ops / fewer launches); gpu>1 but e2e<1 → host-side
overhead (reduce launches, drop autotune key churn, simplify wrapper Python).
- Only a completed evaluation can prove the final overhead-bound conclusion. Local
micro-bench and `check_pattern --bench` are pre-flight signals; if the completed eval is
matched and correct but below eager for the maximal legal region, stop or revert instead
of sweeping launch-bound tuning knobs.
## 2. Kernel quality checklist (in order of impact)
1. **One read per input element, one write per output element.** No multi-launch chains
when one kernel can hold the chain in registers. Exception: for heterogeneous
direct-output cat/stack branches, compare a branch-heavy single kernel with grouped
launches; choose by measured stable e2e speed, not by launch count alone.
2. **Contiguous innermost access**: thread `offs` should walk the last (stride-1) dim.
For strided/permuted inputs pass strides; never `.contiguous()` (poison-illegal) —
absorbing the transpose INTO the index math is free.
3. **Block sizing**: elementwise → BLOCK 1024 (n ≥ 1M: 2048, num_warps=8). Row kernels →
`BLOCK_N = next_power_of_2(N)`, and start with **num_warps=4** — more warps on small
rows HURTS (measured: 512-elem rows, warps=8 was 1.8× slower than warps=4). If
N > 16384 switch to a looped accumulation (template 6 in kernel-templates).
For tiny rows (≤1024 elems), SWEEP rows-per-program ∈ {1, 2, 4, 8} (2D tile) — the
winner is shape-dependent (measured 1.45× for ROWS=4 at D=512/131k rows, but ROWS=1
best on a 768-wide LayerNorm); never assume, always micro-bench (§5).
4. **Grid**: prefer 1D `cdiv(n, BLOCK)`; for row kernels one program per row is fine up
to ~256k rows. Avoid grids of 1–4 programs on big inputs (underutilization) — split.
5. **num_stages**: only matters for `tl.dot`/looped kernels (3–4); elementwise ignore.
6. **Avoid recompilation churn**: every distinct `tl.constexpr` value (incl. BLOCK chosen
from shape) compiles a new binary during warmup. 2–3 specializations fine; per-shape
formulas over 100-variant samples → timeout risk. Derive constexprs from a small fixed
set (e.g. BLOCK_N = next_pow2 clamped to {128, 512, 1024, 4096}).
7. **Autotune policy**: `@triton.autotune` is legal and runs during the untimed warmup
(poison-safe). Use ≤4–6 configs, `key=['N']`-style coarse keys. SKIP autotune when the
sample has many graph variants (tuning cost × variants × 600 s timeout) or when the
kernel is launch-bound anyway. A hand-picked config from one local micro-benchmark is
usually within 5%.
8. **Scalar args beat tensor args**: precompute `n`, `C`, `HW`, strides in the wrapper
(metadata access is poison-legal) — never load shape info from tensors in-kernel.
9. **Reduce wrapper Python**: no dict lookups in hot path beyond the route `if`; compute
grid inline; allocate with `torch.empty` (not zeros — don't pay a memset you'll
overwrite).
## 3. Numeric fidelity — passing t=-5 baseline tolerances
Baselines: fp32 rtol 1.3e-6/atol 1e-5 (tight — mirror eager's algorithm), fp16 rtol 1e-3,
bf16 rtol 1.6e-2 (loose). The same kernel must satisfy ALL dtype variants.
Recipes (these match aten's CUDA implementations):
- **Always compute in fp32** (`.to(tl.float32)` after load), store with
`.to(out_ptr.dtype.element_ty)`. For fp16/bf16 inputs this reproduces aten (which also
accumulates in fp32) to ≤1 ulp.
- Reductions: `tl.sum`/`tl.max` over a row in one block ≈ aten's order closely enough for
fp32 rtol 1.3e-6 at typical sizes (N ≤ 16k). For looped accumulation keep ONE fp32
accumulator (don't reorder into multiple partial schemes unnecessarily).
- softmax: subtract row max (aten does), exp, divide by sum. Don't use exp2 shortcuts on
fp32 variants.
- layer_norm/batch_norm: biased variance (÷N), `1/sqrt(var+eps)` in fp32, then scale/shift.
- mean: `sum/N` (single division at the end, not running mean).
- GELU exact: `0.5x(1+erf(x/√2))` via `tl.math.erf`; tanh GELU only when model.py says
`approximate='tanh'`.
- sigmoid/silu: `1/(1+exp(-x))` fp32 — fine at all baselines.
- `x.pow(2)` → `x*x`; `torch.rsqrt` → `1.0/tl.sqrt` (matches aten rsqrt within 1 ulp; if
a strict fp32 variant complains, try `tl.math.rsqrt`).
- Integer/bool tensors: compare with `torch.equal` (atol/rtol=0 conceptually) — your
kernel must be EXACT (indices, masks, argmax-style ops: replicate tie-breaking by
first-occurrence).
- Output dtype must equal eager's per output ([Datatype] check), including intermediate
`.to(torch.float32)` nodes you absorbed — store in the dtype eager would have at the
region's output.
Diagnosing accuracy failures from eval logs: `[Correctness][max_diff]` magnitude vs the
table above (parse_eval_log.py does this) —
- max_diff ~1e-3·|values| on fp16 only → fp16 rounding mismatch: ensure fp32 internal math.
- fails ONLY fp32 variants → algorithmic mismatch (reduction order, formula) — mirror
aten's exact sequence.
- max_diff huge (≥1) → indexing/masking bug, not numerics: check strides, `other=` values
contaminating reductions (use `-inf` for max, `0` for sum, and mask BEFORE divide),
uninitialized output regions (mask coverage), `offs` overflow on >2^31 elements
(use `tl.int64` offsets when numel > 2e9).
- NaN/Inf only in compiled → division by masked-out zero lanes; `tl.where` the mask before
the division, not after.
## 4. Launch-overhead reduction (when e2e-bound)
- Merge kernels (one launch per region; the whole point of fusion).
- Do not add a tiny independent kernel after a correct-but-slow state unless it absorbs
enough real eager work to pay for another launch. If a completed eval shows the added
region lowers score, revert to the best completed state and stop that line.
- Avoid per-call `next_power_of_2` recompute churn → precompute in wrapper; constexpr set
small (see 2.6).
- Don't return tuples/lists from the wrapper unless the pattern output needs it.
- Buffer reuse across calls (cache `out` keyed by shape in a module dict) saves ~2 µs but
risks aliasing bugs if the model returns your output AND mutates downstream — only as a
last resort, verify with full eval.
- CUDA graphs / `torch.compile` of the wrapper / `no_dispatch` tricks: NOT allowed
(torch.* calls blocked / hacking). Don't.
## 5. Micro-benchmark before full eval (GPU, seconds)
```python
import torch, time
def bench(fn, *args, iters=500):
for _ in range(20): fn(*args)
torch.cuda.synchronize(); t0 = time.perf_counter()
for _ in range(iters): fn(*args)
torch.cuda.synchronize(); return (time.perf_counter() - t0) / iters * 1e6 # µs
```
Compare: (a) eager region ops chained, (b) your wrapper. Build inputs with the shapes /
dtypes from weight_meta (use the checker's replay). Target: wrapper ≤ eager_region − 40 µs
for small graphs; for ms-scale kernels target the memory-traffic bound (bytes moved /
~1.5 TB/s on A100 ≈ achievable µs).
`check_pattern.py --bench` (passnet-feedback) automates exactly this comparison per
variant; trust the full eval for the final number (it adds guards/FX costs that local
micro-bench misses).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!