Workflow for porting an existing Triton kernel in `fla/ops/**` to Gluon (`triton.experimental.gluon`) to gain explicit control over tensor layouts, shared memory, async data movement (cp.async / TMA), MMA (WGMMA / tcgen05), and scheduling (persistent kernels, warp specialization). Covers when a port is worth it, an incremental porting sequence that keeps numerical parity at every step, a Triton-to-Gluon API mapping, compile-time / autotune / smem-budget management for heavily unrolled kernels, and a pitfall checklist (proxy fences, mbarrier semantics, layout costs, bitwise-cancellation traps, NaN-poisoned OOB handling). Use when a Triton kernel is register-bound, when `num_stages` pipelining underperforms, or when Hopper/Blackwell features (TMA, TMEM, tcgen05) are needed.
71
86%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Gluon shares Triton's compiler stack, JIT, and SPMD tile model; host-side launch code is unchanged. The difference: layouts, shared memory, asynchrony, and synchronization are all explicit. Port incrementally: first a literal translation that passes the op's frozen pytest, then upgrade layer by layer driven by profiling, keeping numerical parity after every step.
Related skills:
fla-optimization-loop — the iteration discipline around this port (frozen test contract, recording, when to stop).fla-nvidia-performance — profiling workflow, hardware baselines, MR-ready perf evidence.fla-correctness-coverage — test coverage matrix for the op being ported.Worth it:
num_stages pipelining fails to overlap load and compute the way you want.tcgen05_mma, 2-CTA MMA, CLC dynamic scheduling.Not worth it:
nvidia modules are NVIDIA-only (AMD is a separate submodule).triton.experimental and its API moves between Triton versions.
The official tutorials (https://triton-lang.org/main/getting-started/tutorials/gluon/) track the main branch;
verify names against the installed Triton with dir() before copying tutorial code.
Known examples: in Triton 3.5.1 there is no gluon.aggregate, and the TMA/cp.async load is
async_copy_global_to_shared (named async_load on main).NameError: ... instantiated as constexpr). Pass such values as constexpr kernel arguments instead — that also keeps host and
kernel in sync and makes them visible to autotune key/prune functions.cp.async needs Ampere+; TMA, WGMMA, gl.warp_specialize, CGA clusters need Hopper+;
TMEM, tcgen05_*, CLC, and TMA gather/scatter need Blackwell.
Follow the hardware baseline rules in fla-nvidia-performance.from triton.experimental import gluon
from triton.experimental.gluon import language as gl
from triton.experimental.gluon.language.nvidia import ampere, hopper, blackwell
from triton.tools.tensor_descriptor import TensorDescriptor # TMA, host side| Triton | Gluon | Notes |
|---|---|---|
@triton.jit | @gluon.jit | triton.autotune, triton.cdiv, do_bench are reused as-is |
tl.load / tl.store | gl.load / gl.store | every tensor (including pointer tensors) needs an explicit layout |
tl.arange | gl.arange(..., layout=gl.SliceLayout(dim, parent)) | 2D offsets = SliceLayout + expand_dims + broadcast (free) |
tl.dot | Hopper: hopper.warpgroup_mma; Blackwell: blackwell.tcgen05_mma | async instructions; explicit wait/commit required |
num_stages=N | manual multi-buffering: smem gets a leading [num_buffers, ...] dim | prologue / steady-state / epilogue skeleton below |
| (compiler-managed smem) | gl.allocate_shared_memory(dtype, shape, layout) | SwizzledSharedLayout / NVMMASharedLayout to avoid bank conflicts |
| (compiler-managed layout) | gl.BlockedLayout(size_per_thread, threads_per_warp, warps_per_cta, order) | see layout guidance below |
tl.trans(b) | b_smem.permute((1, 0)) | forwarded to the MMA hardware, zero-copy |
tl.static_range | gl.static_range | used for prologue peeling |
Keep the Triton kernel and the op's tests/ops/test_<op>.py untouched (they are the frozen contract per
fla-optimization-loop). The Gluon kernel is added alongside and must pass the same parity tests
(forward and backward, via fla.utils.assert_close) before any optimization.
gl.load/gl.store, correctness first)Add layouts, no async anything. A literal port is a parity scaffold, not a deliverable: it drops
Triton's automatic vectorization and num_stages pipelining without adding any manual control, so it
usually ties or loses to the Triton kernel. Measured on a bandwidth-bound op (attnres, GB200): the
literal port was ≈ Triton; the wins (fwd 1.3–1.45×, reaching 66–72% of HBM peak) all came from the
restructuring steps below.
While translating, also restructure what Triton forced you to express dynamically: small runtime
dimensions (e.g. a source/tensor count) become constexprs, so runtime pointer-select chains
(tl.where(o == i, ptrs_i, p)) turn into static indexing over an unrolled gl.static_range — and
gathers over many tensors become contiguous per-tensor block loads that async copy can handle.
Layout starting points:
size_per_thread=[1] — each warp issues exactly one 128-byte coalesced access; measured faster
than larger per-thread tiles in the tutorials.size_per_thread=[1, N], order=[1, 0]. The layout's contiguous dim must match the
tensor's contiguous dim — a mismatch costs an order of magnitude of bandwidth (6.3 → 0.8 TB/s in the tutorial).gl.convert_layout in the middle, use square-ish blocks (e.g. 128×128).cp.async (Ampere+, small diff): ampere.async_copy.async_copy_global_to_shared →
commit_group() → wait_group(N).
TMA (Hopper+, frees registers so blocks can grow): host side
TensorDescriptor.from_tensor(t, block_shape); smem must use NVMMASharedLayout; strides 16-byte aligned;
loads tracked by an mbarrier (expect(bar, nbytes) → wait(bar, phase)), stores by
tma.store_wait(pendings=N). Out-of-bounds masking is automatic.
Pipeline skeleton (same shape for both mechanisms):
smem = allocate([num_buffers, BM, BN]); one mbarrier per buffer
prologue: issue num_buffers - 1 loads (gl.static_range)
steady state: issue load i + num_buffers - 1; wait load i; compute; release buffer i
buffer index = i % num_buffers; mbarrier phase = i // num_buffers & 1
epilogue: drain with decreasing wait countsPick the pipeline depth from the load/compute latency ratio; going deeper past bandwidth saturation buys nothing.
cp.async specifics learned the hard way:
gl.thread_barrier() before the refill
(WAR safety); it costs ~a barrier, not a pipeline stall.wait_group(N) counts every group issued later, so
it cannot express "wait for slot l only" once you interleave prefetches for the next loop iteration
with consumption of the current one — the wait would also cover the new issues and serialize you
again. For per-slot pipelining across iterations, switch to ampere.mbarrier: one barrier per slot,
mbarrier.init(bar, count=num_warps * 32), and after each thread's issues
async_copy.mbarrier_arrive(bar, increment_count=False) (the noinc form consumes the
pre-initialized count; the default self-increments and never completes with a thread-count init).
Consumers mbarrier.wait(bar, phase=t & 1) — one fill per iteration flips parity.NVMMADistributedLayout(version=[3, 0]); M ≥ 64 (one warpgroup minimum); results must flow through the
return value of warpgroup_mma_wait(deps=...) or ordering is not guaranteed.allocate_tensor_memory + TensorMemoryLayout);
TMEM loads/stores need a full warpgroup (each warp sees only 32 of 128 rows); completion via
tcgen05_commit + mbarrier; tcgen05_copy moves smem→TMEM without a register round-trip, and
same-pipe tcgen05 instructions are implicitly ordered (a copy followed by an MMA needs no wait).use_acc=False is the cheapest way to zero-initialize the accumulator.Persistent kernels (grid = min(num_sms, num_tiles) + a tile scheduler — add grouped/swizzled tile order
or L2 hit rate drops) → gl.warp_specialize (load/MMA/epilogue partitions; a TMA-issue-only partition
needs 1 warp and 24 registers; set maxnreg explicitly) → multi-CTA / CLC (Blackwell).
Re-autotune after every layer: in the tutorials, the pre-pipelining best config lost >100 TFLOPS
after pipelining was added.
Three interacting constraints that only show up at scale:
gl.static_range(K) fully unrolls; a body unrolled ~30×
across two passes, multiplied by ~9 autotune configs, can take tens of minutes per shape. Use
fla_cache_autotune(..., prune_configs_by={'early_config_prune': fn}); the prune fn receives all
kernel args ({**named_args, **kwargs}), so it can cap the sweep to 1–2 configs when the unroll
factor is large.gluon.constexpr_function switch (e.g. RESIDENT = (L+1)*BT*BD*ES <= budget):
resident path for the common case, streaming double-buffer fallback for the rest. Prune configs
whose minimal footprint still exceeds the budget — Triton's autotuner does not reliably skip
smem-overflow configs on its own.dram__throughput...pct_of_peak low + stalled_long_scoreboard high + warps_active low means
serialized loads, not insufficient bandwidth.fence_async_shared() — this holds across warp_specialize partitions and is not waived by
mbarrier arrive/wait ordering. Sole exception: after mbarrier.wait on a TMA read barrier,
reading that smem needs no fence.i // num_buffers & 1. A barrier only tracks the current and previous
phase; running more than one phase ahead desynchronizes permanently. Never reuse one mbarrier for
both TMA and tcgen05 completion (undefined behavior) — allocate separately or reinitialize.tma.store_wait waits only for the smem read by default, not the global write. If the stored
range is read afterwards (e.g. cross-CTA signaling), pass read_only=False.gl.static_print on layouts and convert_layout(..., assert_trivial=True) to prove a
conversion is actually free.y_offset 16-byte).maxnreg × (num_warps + 4) × 32), a cross-warp convert_layout silently routing through smem,
or a persistent schedule tanking L2 hit rate (lts__t_sector_hit_rate in NCU — see
fla-nvidia-performance for the profiling workflow).sum(a*b) at two program points must agree to the last bit
(e.g. softmax bwd over a single source: ds = p*(dp - delta) with dp == delta), Gluon may compile
the two reductions differently and leave O(eps) residue that explodes against an exactly-zero
reference. Branch on the degenerate constexpr case and emit exact zeros.0 * NaN = NaN). Clamp indices to a valid row
instead of masking the loads, then zero the one tensor (e.g. the incoming gradient) whose zeroing
provably kills every masked contribution downstream; keep masks only on stores.Run on a GPU worker per fla-nvidia-performance hardware baselines (sm_90+; prefer sm_100/sm_103):
python -m pytest tests/ops/test_<op>.py -q # frozen parity gate, fwd + bwd
python benchmarks/ops/run.py --op <op> --base main # before/after vs the Triton baselineRecord every iteration per the fla-optimization-loop protocol; dense workloads for quick iteration,
varlen checked before the MR.
Iteration-speed hygiene (Gluon compiles are expensive):
TRITON_CACHE_DIR — a fresh
machine per run recompiles every kernel × config from scratch and dominates wall-clock.FLA_<OP>_<BACKEND>), benchmark each
backend in its own process with the env var set at launch.python/tutorials/gluon/; complete kernels under
python/examples/gluon/ (e.g. 02-convolution.py, a pipelined warp-specialized convolution).python -c "from triton.experimental.gluon.language.nvidia import hopper; print(dir(hopper.tma))"27967b9
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.