Kernel Layer — CUDA, JIT, and the Functional API¶
OASR exposes custom CUDA / CUTLASS kernels to Python through TVM-FFI JIT
compilation, in the style of FlashInfer. Nothing is linked ahead of time in the
default build: a kernel is compiled by nvcc on its first call and cached, so
import oasr works on a machine with no compiled extension at all.
This document covers the C++/CUDA layer, the JIT pipeline, and the Python
functional API. For the nn.Module layer that models are built from, see
architecture.md § The layer waist. For a
step-by-step walkthrough of adding a kernel, use the /add-cuda-kernel skill.
Layered design¶
Python functional API (oasr/<family>.py) — @oasr_api decorated
└── JIT generator (oasr/jit/<family>.py) → JitSpec / JinjaJitSpec
└── TVM-FFI JIT binding (csrc/<family>_jit_binding.cu)
└── TVM-FFI launcher (csrc/<family>.cu)
└── Pure CUDA kernels (include/oasr/<family>.cuh) — facade
└── Config (cutlass_*_configs.h)
└── Template (*_cutlass_template.h)
└── Dispatch (*_cutlass.h / *_dispatch.inc)
The C++/CUDA layer¶
Directory map¶
| Path | Contents |
|---|---|
include/oasr/common/ |
Shared types (types.h), scalar/vector dtype conversion (vec_dtypes.h), warp/block reduction (reduction.h), SM dispatch (arch_dispatch.h), per-SM hardware facts (arch_facts.h), epilogue functors, and math utilities; plus the Ampere-class CuTe toolkit the three CuTe families share (cute_sm80.h) and its CuTe-free tile rules (tile_rules.h) — see The C++ CuTe families |
include/oasr/activation.cuh |
Vectorized exact GELU, sigmoid, tanh, ReLU, GLU, Swish, and Swoosh activations; unary sigmoid/tanh/ReLU also consume regular padded row strides such as channel chunks without a copy |
include/oasr/norm.cuh + norm_dispatch.inc |
LayerNorm, RMSNorm, fused add+LayerNorm/RMSNorm (with optional residual passthrough), BatchNorm1d, GroupNorm, fused norm+activation |
include/oasr/conv/ |
conv1d.cuh + conv1d_dispatch.inc (depthwise with asymmetric padding and optional FSMN mask/residual fusion, pointwise, causal); dense BTC Conv1D is the height-one specialization of the conv2d.cuh CUTLASS facade |
include/oasr/pooling.cuh |
BTC AvgPool1D and MaxPool1D; vectorized 2×2 specialization, generic padding/ceil/count semantics, and a flat narrow-channel path for one-channel traces |
include/oasr/recurrent/ |
LSTM and tanh/ReLU RNN inference: fused GEMV/cohort kernels (recurrent.cuh), CUTLASS 2.x recurrent GEMM and state epilogues (recurrent_cutlass.cuh) with Stream-K/Split-K candidates, the CUTLASS 3.x TMA warp-specialized path for SM90/SM100 (recurrent_cutlass_sm90.cuh), and the CuTe fused recurrent step (recurrent_step_*.h) |
include/oasr/attention/ |
The CuTe fused multi-head attention (fmha_*.h, cutlass_fmha_configs.h) |
include/oasr/mlp/ |
The CuTe fused gated MLP (gated_mlp_*.h, cutlass_gated_mlp_configs.h) |
include/oasr/gemm/ |
gemm.cuh facade, bmm.cuh, group_gemm.cuh |
include/oasr/quant/, include/oasr/scaled_mm/ |
Per-token and static 8-bit activation quantization, int8 and fp8 e4m3 (quantize.cuh's QuantTraits; pinned numerics, bit-exact with the torch twins) and the 8-bit scaled GEMM: CUTLASS 2.x int8 mma.sync (int32 accumulator) or Ada e4m3 mma.sync (fp32, fast accumulation, one summation order for every tile) with an Sm80 EVT epilogue that dequantizes (per-token × per-channel), adds the bias and applies the activation — in the three-header layout, one JIT module per format. On sm90 / sm100 the native lanes (*_sm90.h): CUTLASS 3.x collective builders over TMA and wgmma (ping-pong or cooperative warpgroups) or tcgen05 (1-SM / 2-SM, a double-buffered tensor-memory accumulator), with the same epilogue as an Sm90 EVT tree whose activation is a runtime argument (one kernel per tile and output dtype, not five) and whose activation-scale leaf is a dynamic per-token / per-tensor broadcast; one JIT module per format and lane, int8 on sm100 built for sm_100a (sm_100f has no tcgen05 kind::i8). The element functors both generations share are scaled_mm_functors.h; the launcher checks, csrc/scaled_mm_launcher.h. See quantization.md |
include/oasr/scaled_conv/ |
The int8 implicit-GEMM forward convolution of a quantized Conv2d / Conv1d (NHWC int8 input, KRSC int8 filter, exact int32 accumulator): CUTLASS 2.x has no EVT convolution, so it is DefaultConv2dFpropWithBroadcast with ScaledConvOutputOp -- the per-channel input_scale * weight_scale as the broadcast vector, the bias as a zero-stride source, the activation fused. JIT module scaled_conv (oasr/jit/scaled_conv.py), functional oasr.scaled_conv2d / scaled_conv1d. See quantization.md |
include/oasr/{softmax,topk,fft,features}.cuh, sort/ |
The remaining families |
include/oasr/ctc_decoder.cuh, include/oasr/wfst/ |
GPU decoder kernels |
csrc/<family>.cu |
TVM-FFI launcher |
csrc/<family>_jit_binding.cu |
JIT binding exports |
csrc/tvm_ffi_utils.h |
DLPack dtype dispatch, validation macros (CHECK_GEMM_ALIGNMENT, CHECK_CONTIGUOUS_INPUT, CHECK_LAST_DIM_CONTIGUOUS_INPUT, CHECK_VECTOR_ALIGNED, FLATTENED_ROWS), OptionalDataPtr |
csrc/templates/ |
Jinja2 templates for config-specific CUTLASS instantiations (gemm_cutlass_template.cu.jinja, bmm_cutlass_template.cu.jinja, group_gemm_cutlass_template.cu.jinja, scaled_mm_cutlass_template.cu.jinja, scaled_conv2d_cutlass_template.cu.jinja) |
csrc/decoder/ctc/ |
GPU CTC launcher + binding (ctc_decoder.cu, ctc_decoder_jit_binding.cu); ctc/cpu/ holds the CPU-side C++ decoders compiled into _C.so — greedy search, prefix beam search, WFST beam search (via k2), the streaming WFST decoder, ContextGraph for phrase boosting, and shared common/utils |
csrc/decoder/wfst/ |
In-tree GPU WFST decoder (TVM-FFI JIT). Its exact-semantics CPU reference oracle is test-only and lives separately under csrc/tests/wfst/, out of the production decoder library. |
csrc/pybind/ |
pybind11 module for the CPU decoder bindings, the alignment bindings and legacy enums (pybind_main.cpp, pybind_decoder.h, pybind_alignment.h) |
csrc/alignment/ |
The post-decode alignment pass in C++ — emission frames → per-token spans → words, plus extract_beam_tokens / extract_beam_row, which turn a padded [batch, beam, max_len] host tensor into nested lists. Not a kernel: plain host data shuffling, compiled into _C.so |
csrc/tokenizers/ |
The rendering half of the symbol_table tokenizer kind (token_pieces), which the word grouping calls once per finished hypothesis |
The GPU CTC launcher/binding pair is the one that does not live at the
csrc/ root (its JIT generator is oasr/jit/ctc_decoder.py); everything else
follows the flat convention.
The three-header CUTLASS pattern¶
Each CUTLASS kernel family splits config, template, and dispatch:
| Header | Purpose | Example (GEMM) |
|---|---|---|
cutlass_*_configs.h |
Config structs (GemmConfig), per-SM MMA traits (SmMMATraits), default configs (DefaultGemmConfig) |
gemm/cutlass_gemm_configs.h |
*_cutlass_template.h |
CUTLASS kernel template parameterized by Config + MMATraits | gemm/gemm_cutlass_template.h |
*_cutlass.h |
Public dispatch interface (JIT mode via OASR_TARGET_SM, AOT mode via OASR_DISPATCH_SM) |
gemm/gemm_cutlass.h |
Non-CUTLASS kernels (Conv1D, Norm, Activation) use *_dispatch.inc files with
VecSize / block_size dispatch macros instead.
The three CuTe families (attention, the gated MLP, the recurrent step) keep the
same three roles but split the template into CUTLASS 3.x-style collectives: the
config is cutlass_*_configs.h, the template is a mainloop + epilogue composed
by an arch-free kernel shell, and the dispatch is *_launch_template.h — see
The C++ CuTe families.
Dispatch modes¶
| Kernel family | Mode | Config source | Source generation |
|---|---|---|---|
| GEMM, BMM, GroupGEMM | jinja | cutlass_gemm_configs.h |
Jinja renders .cu with baked-in config |
| Dense Conv1D / Conv2D | jinja | cutlass_conv2d_configs.h |
Jinja renders .cu with baked-in config; each tactic exports strict BTC/KSC Conv1D and NHWC/KRSC Conv2D entry points. SM75–89 and SM120 use the CUTLASS 2.x implicit GEMM; SM90/SM100 use the 3.x conv::CollectiveBuilder — see Conv2D on SM90/SM100 for why that config is not a copy of the GEMM one |
| Depthwise / causal Conv1D | dispatch | conv1d_dispatch.inc |
Direct compilation, VecSize macro |
| Grouped / depthwise Conv2D | direct | grouped_conv2d.cuh |
NHWC 3×3/7×7 specializations; bias and optional activation share the convolution launch |
| Norm | dispatch | norm_dispatch.inc |
Direct compilation, block/vec macro |
| Activation | dispatch | activation_dispatch.inc |
Direct compilation, VecSize macro |
| Pooling | direct | pooling.cuh |
128-bit channel vectors in BTC layout; specialized 2×2, generic, and narrow-channel launches |
| Recurrent | direct + CUTLASS | recurrent/recurrent.cuh + recurrent/recurrent_cutlass{,_sm90}.cuh |
fused low-latency GEMV at small batch, shared-weight batch warps for cohorts, sequence-wide input projection, state epilogues, autotuned Stream-K/Split-K for wide states, and TMA warp-specialized collectives on SM90/SM100 |
| Fused attention, gated MLP, recurrent step (C++ CuTe) | jinja (per variant) | cutlass_{fmha,gated_mlp,recurrent_step}_configs.h + the CuTe-free tile headers |
Jinja renders one .cu per variant of a cell; the tile is derived (attention) or chosen from a compiled table (MLP, recurrent step) — see The C++ CuTe families |
- JIT mode (
OASR_TARGET_SMdefined): a single SM instantiation, with an optionalJitGemmConfig/JitConv2dConfigpassed via-Dflags. - AOT mode (no
OASR_TARGET_SM): theOASR_DISPATCH_SMmacro switches on the runtime SM version.
SM targets default to 75, 80, 86, 89, 90, 100, 120 in CMakeLists.txt;
setup.py queries the host GPUs and falls back to 80;86;89;90. Override either
with CUDA_ARCHITECTURES.
Conv2D on SM90 / SM100¶
The CUTLASS 3.x implicit-GEMM conv builder is close enough to the GEMM one to invite a copy, and far enough away that the copy does not compile. Four differences, each load-bearing:
| GEMM | Conv | |
|---|---|---|
K mode of TileShape |
flat Int<BK> |
nested Shape<Int<BK>> — implicit GEMM's K axis is the filter's (C, S, R) modes |
| Mainloop schedule | pingpong / cooperative | conv::KernelScheduleAuto; SM90 conv has one schedule (CUTLASS's own auto-selector has the cooperative branch commented out, and conv/dispatch_policy.hpp static_asserts on the persistent tags) |
| 2-SM SM100 atom | M tile doubled | M tile passed as-is; the atom comes from the cluster, and BM * 2 is "Invalid TileShape_M." |
Arguments |
leading GemmUniversalMode |
the mode-less constructor — SM90's ConvUniversal inherits GemmUniversal and has the mode, SM100's does not |
Two tile rules bound the space, both measured by compiling the grid rather than
documented upstream, and both enforced as filters in oasr/jit/conv.py because
a single unbuildable variant fails the whole JIT module:
- SM90 —
(BM + BN) * BK * 2 ≤ 106,496 B.StageCountAutoCarveouthas to fit two stages plus the epilogue into Hopper's 227 KiB; above that it resolves to zero stages and the build stops. - SM100 — a 256-row tile requires
cluster_m == 2.
The tile ladders themselves are unmeasured: they span the M range at two N
widths. They are also sized against build cost — each 3.x conv translation unit
peaks near 3.7 GB in cicc, and run_ninja only bounds parallelism when
MAX_JOBS is set.
Recurrent execution paths¶
The CUDA FP16/BF16 recurrent waist retains two complementary implementations:
recurrent/recurrent.cuhowns the latency path. A CTA computes a complete state row at batch 1, while the cohort specialization stages one output row's weights in shared memory for reuse by several batch warps. Affine accumulation, bias, gate activation, and state writes share a launch.recurrent/recurrent_cutlass.cuhowns the throughput path. The input affine is one sequence-wide OASR GEMM. LSTM weights are cached in[hidden, gate, K]order, so the four gates for a cell are adjacent; the recurrent CUTLASS epilogue can apply i/f/g/o and write h/c directly. Vanilla RNN uses the common tanh/ReLU CUTLASS epilogue and writes the hidden state directly. Matrix-C and output leading dimensions are independent, so BTC input projections feed the recurrence as strided batch rows without a transpose or intermediate copy.
The recurrent autotuner compares 16/32/64-row M tiles, Stream-K, and parallel Split-K. LSTM also exposes serial Split-K: its custom epilogue delays the nonlinear state transition until the final K partition and reuses GEMM's self-restoring semaphore path, avoiding a workspace clear per timestep. Parallel Split-K and Stream-K materialize one interleaved LSTM gate tile before the state finalizer; this costs one extra launch but is mathematically safe and can expose more SM parallelism for a thin M and large K. Vanilla RNN excludes serial Split-K because applying tanh/ReLU to an intermediate K partition is incorrect.
The new state is never written over the old. At one timestep a single
launch reads initial_h while other CTAs of it already store final_h, so an
in-place call (final_h=initial_h) returns half-updated state: on an RTX 5090
the scalar LSTM and RNN, the tensor-core LSTM and the fused step all did. Every
recurrent launcher therefore refuses an output that shares storage with an input
(oasr::StorageOverlaps in csrc/tvm_ffi_utils.h, exact for row-strided
views, so two column halves of one buffer are fine). Outputs may share storage
with each other: the single-timestep cell ring is final_c. The CuTeDSL step
checks nothing on the host, so the fused path leaves it for the decomposed one
when the state overlaps.
The direct kernel remains the default for decode-sized and moderate hidden
states. This is intentional: a tensor-core GEMM can underfill the device at
small M, and packing/projection plus dependent GEMM launches may cost more than
the work saved. benchmarks/kernels/recurrent.py exposes
native, cutlass16, cutlass32, cutlass64, streamk, splitk, and
serial_splitk (LSTM only) arms so architecture-specific crossover changes
are measured instead of inferred. The focused matrix is
benchmarks/testlists/recurrent_tactics.txt.
Architecture mapping. Every target gets a CUTLASS 2.x composition, and SM90/SM100 additionally get a 3.x one.
recurrent_cutlass.cuh holds the 2.x side. For FP16/BF16 that API specialises
exactly two arch tags, so every target maps onto one of them:
| JIT target | CUTLASS tag | MMA | Stages |
|---|---|---|---|
| 75 (Turing) | Sm75 |
m16n8k8 |
2 |
| 80, 86, 89, 90, 100, 103, 120 | Sm80 |
m16n8k16 |
3 |
Turing needs its own row twice over: a narrower MMA, and a kernel::DefaultGemm
that is specialised for a two-stage pipeline and no other stage count. Nothing
between the two rows is usable — Sm86 has no 2.x tensor-op specialisation at
all, and Sm89/Sm90 have one whose DefaultGemmConfiguration covers FP8
only — so Ada, Hopper and Blackwell all run the SM80 composition, which is
forward compatible and keeps one epilogue output-thread mapping across them.
Two static_asserts pin the arch/stage and arch/instruction-shape pairings, so
a remap that would issue an instruction the target lacks fails at compile time
instead of at decode.
recurrent_cutlass_sm90.cuh adds the CUTLASS 3.x collective path — TMA plus
wgmma on Hopper, tcgen05 on Blackwell datacenter — as two extra tactics,
tma_64 (id 6) and tma_128 (id 7). It is compiled only for targets 90 and
100, the two whose 3.x OpClassTensorOp builders accept FP16/BF16; SM120's is
restricted to F8/F6/F4, and no CutlassArch entry exists for 103. Everywhere
else the two ids are refused rather than rerouted, and the autotuner does not
offer them. Hopper's cooperative schedule static_asserts on an M tile below
128 rows, so the 64-row tile takes the pingpong schedule; SM100 selects from
kSMs and ignores the flag, which is why one pair of configs covers both.
On the 3.x path the LSTM is decomposed: the collective GEMM materialises one
gate-interleaved tile and the existing finalizer applies the state transition.
The fused custom epilogue cannot come along — it reconstructs logical
coordinates from a PredicatedTileIterator thread map, and 3.x replaced that
with cute layouts and an epilogue visitor tree, where a four-gates-to-one-cell
column reduction is not an elementwise node. The vanilla RNN has one gate, so
its nonlinearity stays fused in the collective epilogue.
The layer's automatic tensor-core selection still requires compute capability
8.0 (oasr/layers/recurrent.py), because the crossover was measured on Ampere
and later; on Turing the path is reachable through the functional API and the
autotuner.
Conventions¶
- The output tensor is the first parameter of every TVM-FFI launcher.
- Launchers take N-D tensors and flatten with
FLATTENED_ROWSrather than making Python callreshape(-1, K). - Output allocation stays in Python (
new_emptyvarargs) — allocating in the C++ launcher was measured and is slower. - Every GEMM-family launcher enforces the CUTLASS alignment-8 rule uniformly via
CHECK_GEMM_ALIGNMENT, with a message naming the fix. - One convolution launch addresses at most 2 GiB per tensor. CUTLASS's
implicit-GEMM convolutions use 32-bit byte offsets and refuse anything larger;
the launchers say so via
CHECK_CONV_LAUNCH_BYTES. Everyoasr.conv*entry point therefore splits an oversized batch into launches over row slices (_row_slicesinoasr/functionals/conv.py) — exact, because convolution rows are independent — before choosing a backend, and refuses a single row that is already too large. The in-tree grouped, depthwise and depthwise-1D kernels offset whole rows in 64 bits and positions within one in 32, and their launchers refuse a row past that; 64-bit arithmetic in the inner loop measured 1.8–4.6% slower on depthwise shapes.
The JIT pipeline¶
| Module | Role |
|---|---|
oasr/jit/core.py |
JitSpec (static sources) and JinjaJitSpec (Jinja-rendered), gen_jit_spec(), gen_jinja_jit_spec(), build_and_load() |
oasr/jit/templates.py |
Jinja2 rendering (get_template_env(), render_template()) |
oasr/jit/env.py |
Path constants (OASR_TEMPLATE_DIR, OASR_JIT_DIR), cutlass_version_stamp |
oasr/jit/cpp_ext.py |
The compile and link commands (build_commands), the build.ninja made from them, and their part of the cache key (build_identity) |
oasr/jit/<family>.py |
Per-family generators: gemm, conv, norm, activation, pooling, recurrent, softmax, topk, fft, features, ctc_decoder, wfst_decoder |
oasr/jit/fmha.py |
Fused attention, C++ CUTLASS/CuTe lane — one module per (sm, dtype, head_dim) cell, holding all 17 feature variants |
oasr/jit/gated_mlp.py, oasr/jit/recurrent_step.py |
The gated-MLP and recurrent-step C++ CUTLASS/CuTe lanes — one module per (sm, dtype, activation) / (sm, dtype, kind) cell |
oasr/jit/arch_facts.py |
The Python mirror of include/oasr/common/{arch_facts,tile_rules}.h the three CuTe lanes' tile mirrors are built on |
oasr/jit/attention.py |
The backend arbiter, and the CuTeDSL lane — different model, see below |
oasr/compilation_context.py |
CompilationContext detects GPU SMs at import time; pass supported_major_versions=[...] to get_nvcc_flags_list() for arch-restricted kernels |
Compiled modules are cached in ~/.cache/oasr/jit/<name>/<key>/ (OASR_JIT_DIR).
The key covers what is compiled, how, and with which tools:
- What: the sources (a rendered one by its text), the
include/tree and thecsrc/headers by relative path and content, and the CUTLASS version stamp. - How: the expanded compile and link commands, whatever put each flag
there: the module,
OASR_EXTRA_{CFLAGS,CUDAFLAGS,LDFLAGS},CC,CXX,OASR_NVCCor the arch list. - Which tools: nvcc's build and the host compilers' and TVM-FFI's versions.
Where the checkout sits is not in the key. Its root is replaced by a placeholder in the commands, so worktrees with the same sources share libraries. The tools' own paths stay in, because they select the tools.
A rendered source stays in memory until its module is built. The build writes
it into the key's directory, under gen/, so the file a build compiles is
always the text its key hashed. Renderers used to write a shared path named
after the variant, and another checkout rendering different text could
replace that file between the hash and the compile.
The key has no device in it. A process compiles for one architecture: the
current device's, or the one OASR_CUDA_ARCH_LIST names. It keeps one module
per family, so ASREngine refuses a device of another architecture at
construction (jit.core.require_device_family). On a host with mixed GPUs,
run one process per architecture and select its GPUs with
CUDA_VISIBLE_DEVICES.
A module is built under a file lock to <name>.so.partial and renamed into
place only once the link has finished (cubin_loader.locked_compile), so the
cached name never refers to a partly written library. That matters more than
it looks: dlopen of a truncated library does not raise, it kills the process
with SIGBUS. A process that finds a module mid-build therefore waits on the
lock instead of loading it, and a build killed mid-link leaves only the
.partial, which the next build discards. Before loading, build_and_load
also checks that every section and segment the ELF headers describe is in the
file (is_complete_shared_object) and rebuilds a library that fails.
A failed build is not repeated:
- In one process: once a module's build fails, later calls raise
oasr.jit.errors.JitBuildError with the original error, without building.
The failure belongs to the key, meaning the same sources, flags and tools.
The module getters are functools.cache-wrapped, and that caches results but
not exceptions, so each call used to pay for the whole compile again.
oasr.jit.core.forget_failed_builds() re-arms the build once the cause is
fixed, and clear_cache() does the same.
- Across processes: a process that was waiting on the build lock when the
build failed gets that failure too, instead of running the same build after
it. The builder records its failure in <name>.so.failed before releasing
the lock. A process that arrives after the failure builds again, so a fixed
cause is picked up.
- While waiting: a waiter logs every 30 s, naming the process that holds
the lock. After lock_timeout (600 s) it raises JitBuildLockTimeout. A
timeout is not a build failure and is not remembered.
Nothing removes old keys automatically. Every change to a header, a flag or
the toolchain leaves the previous key behind, and so does every one-off
module. oasr jit prune removes them explicitly:
oasr jit prune --dry-run # list what would go
oasr jit prune --unused-days 30 # remove keys nothing has loaded in 30 days
oasr jit prune --unused-days 7 --keep 0 # also let a whole unused module go
How the prune decides:
- When a key was last used: every load touches last_used in its key's
directory. A key built before that stamp existed has only its library's build
time to go on.
- What it always keeps: each module's --keep most recently used keys
(default 1), however old.
- What it never touches: a key whose build holds its lock.
- When to run it: when no OASR process is starting up. A process that has
found a library but not yet loaded it would fail if the prune removed it in
between. A process that already loaded a library keeps running on it.
The default flags (core._default_cuda_cflags) include -DNDEBUG, which is
how CUTLASS's own release builds compile. Without it, CuTe's device-side
assert()s become __assertfail calls inside the GEMM mainloop, and ptxas
serialises every wgmma across a function call. A build log shows that as
C7510: wgmma pipeline crossing function boundary; every SM90 GEMM kernel had
it. OASR's own sources contain no assert().
Fused attention has two kernel lanes and one arbiter over them.
oasr/jit/attention.py is the arbiter: select_backend(), set_backend_mode(),
fmha_config_supported(...), fmha_backend_for(...), warmup_fmha(...). It is
also the CuTeDSL lane — a functools.cache-keyed wrapper around
cutlass.cute.compile() rather than a Ninja JIT spec, which is why it sits
apart from the table above. select_backend() probes the device capability
eagerly at module load and resolves on sm_80 / 86 / 89 / 120, otherwise
"sdpa".
oasr/jit/fmha.py is the C++ lane and is an ordinary Ninja spec. Jinja
renders one translation unit per feature variant and ninja builds them into one
.so per cell = (target_sm, dtype, padded_head_dim) — the axes that
change the shared-memory layouts and so cannot share a binary. Inside a cell:
3 (none / causal / local) × 2 (bias) × 2 (dense / paged) = 12
+ 4 split-KV (unmasked only) + 1 static binding = 17
Everything else is runtime, not a compile axis: head counts, page size, window
bounds, cache_seqlens / cache_seqstarts, packed-vs-dense input, and whether
the bias is vectorisable. nvcc parallelises across translation units but not
within one, so a cell costs about one variant's wall time on a many-core box and
then covers every shape that cell will ever be asked for.
select_backend() answers a mode question and cannot see the shape.
fmha_backend_for(...) answers the rest — whether the preferred lane can serve
this call, and what to degrade to when it cannot. The case that needs it is
the sliding window, which the C++ lane has and the CuTeDSL one has no argument
for.
CUTLASS¶
CUTLASS is the 3rdparty/cutlass git submodule, pinned to v4.6.1.
git submodule update --init is what provides it — CMake fetches only pybind11.
Nothing links it: every CUTLASS kernel is JIT-compiled, so oasr/jit/env.py
hands the include directories to nvcc at runtime.
Its version.h is folded into the JIT cache key. Without that, build_and_load
short-circuits on an existing .so and a submodule bump keeps silently loading
binaries built against the old headers. Editing a vendored CUTLASS header
without bumping the version still needs rm -rf ~/.cache/oasr/jit. The stale
libraries also reach every other checkout that shares the cache, so give an
experiment on vendored headers its own OASR_JIT_DIR.
The CuTeDSL half of CUTLASS is the separate nvidia-cutlass-dsl wheel
(pip install -e .[attention], floor in oasr/jit/attention.py::MIN_CUTEDSL_VERSION),
kept at the same 4.6.1 release. It is optional: OASR_ATTN_BACKEND=auto
degrades oasr.fmha to SDPA when it is absent.
Evaluation of the 4.4.2 → 4.6.1 move: .artifacts/cutlass_upgrade.md.
The Python functional API¶
Every entry point is @oasr_api-decorated (oasr/api_logging.py — debug logging
plus exception context), JIT-compiles on first call via @functools.cache,
allocates its output tensor, and calls into the compiled module.
| Module | Exposes |
|---|---|
oasr/functionals/gemm.py |
gemm, bmm, group_gemm, and the fused epilogues gemm_activation (RELU/tanh-GELU/exact-erf GELU/SWISH) and gemm_log_softmax (the CTC head fast path) |
oasr/functionals/gemm_torch.py |
Torch/cuBLAS runners — torch_gemm, torch_gemm_activation, torch_bmm, torch_gemm_log_softmax — mirroring the CUTLASS launcher contract exactly (output-first, in-place / CUDA-graph-safe, D = A @ Bᵀ). Doubles as a Tactic("torch") autotuner candidate and as the production dispatch target. Deliberately free of any oasr.tune import. |
oasr/functionals/norm.py |
layer_norm, rms_norm, batch_norm1d, group_norm, fused norm+activation |
oasr/functionals/conv.py |
dense / depthwise / pointwise / causal Conv1D; dense, grouped and depthwise Conv2D. Dense NHWC 1×1 Conv2D dispatches as GEMM; Conv1D depthwise padding may be an integer or (left, right) pair |
oasr/functionals/activation.py |
standalone exact-erf gelu, sigmoid, tanh, relu, glu, swish, swoosh_l, swoosh_r |
oasr/functionals/pooling.py |
BTC/TC avg_pool1d and max_pool1d, including symmetric padding and ceil mode (count_include_pad for avg; dilation and return_indices are refused, not ignored) |
oasr/functionals/softmax.py, oasr/functionals/topk.py, oasr/functionals/fft.py |
softmax, log_softmax, masked_softmax (one pass over an attention score tensor: an additive bias and two boolean masks, each broadcast against the scores through its own strides, so a shifted as_strided relative-position window or a [..., ::ds] mask slice is consumed where it is), topk, rfft / rfft_power |
oasr/functionals/feature.py |
stft_frame, dct_lifter, fbank_preprocess, mel_log, whisper_logmel, lfr_gather — see features.md |
oasr/functionals/attention.py |
fmha(...) and fmha.persistent_inputs(...) |
oasr/functionals/mlp.py |
gated_mlp — a whole SwiGLU/GeGLU gate+up+activation+multiply in one dual-B GEMM, plus gated_mlp_available (the routing question, so a layer can ask before building anything) and gated_mlp_backend (which of the two lanes answered it). Refuses rather than falling back |
oasr/functionals/ctc_decode.py |
ctc_beam_search_decode, GpuStreamingDecoder — see ctc_decoder_gpu.md |
oasr/decode.py |
Thin helpers over the CPU-side oasr.decoder decoders |
oasr/decoder/ holds the Python wrappers for the CPU-side C++ decoders —
CtcGreedySearch, CtcPrefixBeamSearch, CtcWfstBeamSearch (requires k2), and
ContextGraph (a phrase-boosting trie) — plus the k2_available flag. Each
lazily imports the compiled _C extension and delegates to a _*Core C++
object.
LayerNorm with a per-token int8 output¶
layer_norm_quant and add_layer_norm_residual_quant (oasr/functionals/norm.py)
are LayerNorm and the fused add + LayerNorm with residual passthrough, emitting
the normalized output as per-token int8 (or fp8 e4m3, by the output tensor's
dtype -- a runtime flag in the store, not a second instantiation) plus one fp32
scale per row for an 8-bit GEMM consumer (quantization.md). That replaces two launches
(the norm storing T, then quantize_per_token reading it back) with one. The
contract is bit-exactness with that pair, torch.equal on q, scale and
residual_out:
- One kernel template, two sinks.
layerNormKernelandaddLayerNormResidualKerneltake akQuantOutparameter. The mean and variance passes, the block size and the vector/scalar choice are shared. Only the normalize pass's store differs. The plain kernels compile to the same instructions as before. - What is quantized is the T value. Each normalized value is rounded to the
input dtype exactly as the store would round it. The row's amax is taken over
those values, and
quant::quantizeInt8(include/oasr/quant/quantize.cuh) quantizes the held values rather than recomputing them. A thread's first vector stays in registers. Past 1024 vectors a row, the rest are staged in dynamic shared memory, with an opt-in past 40 KiB. - The launcher's vector/scalar choice must be LayerNorm's, decided from the input (and residual) pointers alone. It fixes each thread's accumulation order and hence the bits of the mean. The int8 tensor's alignment only decides whether the int8 store is 8 bytes wide.
- The affine's rounding is spelled out in source. nvcc contracts
n * w + binto an FMA only when it unswitches thebias != nullptrbranch, and that is a per-instantiation decision. The plain LayerNorm contracted, the firstkQuantOutbuild did not, and the two then differed by one ulp in a few elements per million. Each kernel now writes the rounding it compiled to before the change: anfmafinlayerNormKerneland in the scalaraddLayerNormResidualKernel,__fadd_rnin its vector path.tests/kernels/test_norm_quant.py::TestBitExactVsUnfused::test_volume_catches_a_one_ulp_driftis the config that sees such a drift every time; a rows=1000 grid sees it in about one config in seventy.
In the layer, LayerNorm.quantize_output is armed by
oasr.quantization.bind_quantization, never by hand. When armed, forward
returns a QuantizedActivation, and so does the first element of
forward_add_residual. That happens only where the fused kernel applies: CUDA,
fp16/bf16, a contiguous input (and residual), and the oasr backend. Everywhere
else the layer returns the plain tensor. reference_layer_norm_quant and
reference_add_layer_norm_residual_quant are the torch twins: bit-exact in the
quantize, within one int8 step overall, because torch's LayerNorm reduction is
not the kernel's. RMSNorm has no quant-out variant yet; the same kQuantOut
templating applies to rmsNormKernel / addRmsNormResidualKernel unchanged.
Shape-aware backend selection¶
gemm, gemm_activation, bmm and gemm_log_softmax route per shape.
jit.gemm.select_default_config(op, M, N, K) picks:
- a CUTLASS variant — default tile, serial split-K, parallel split-K (
pk), or Stream-K; - on SM90 and SM100, from either of two lanes. The native one is CUTLASS 3.x:
TMA plus wgmma (SM90) or tcgen05 (SM100). It includes small 64-row and 64-wide
tiles at K-tile 64 with 1×1 clusters. The other is the CUTLASS 2.x
mma.syncspace, with its split-K and Stream-K, rendered with the Sm80 arch tag (jit.gemm._MIXED_LANE_SMS). Only the GEMM family takes both lanes; BMM and grouped GEMM stay native (is_native_lane). CUTLASS's 3.x Stream-K scheduler was built and measured, and it won nowhere: its deterministic split-K ran about 1.3× behind the unsplit kernel; - the torch/cuBLAS backend (
oasr/functionals/gemm_torch.py); - or, for the CTC head only, the legacy single-call fused launcher.
The rules are data — the tuning database (oasr/tune/database.py, format
and policy in autotuning.md). One file per
(arch family, kernel family): the shipped system tier
(oasr/tune/db/sm120/gemm.json, …/conv1d.json), and a user tier in
~/.cache/oasr/tune/v2/ written by tuning runs on the machine that measured
them, which wins per signature. An entry is keyed by the static signature
(gemm|op=gemm|dt=half|N=512|K=256) and its ascending (m_hi, config) regions
are looked up by rounding M up, so a config only serves sizes at or below the
largest one it was measured at.
The lookup chain is user → system → cost model → GEMM_DEFAULT, each tier
counted (oasr.tune.database.tier_counts(), format_gap_report()). The model
tier ranks the compiled configs with the analytic model the arch's tuning file
ships (oasr/tune/cost_model.py) for an aligned gemm/gemm_activation shape
no entry covers, and only replaces the default when it predicts at least a 5%
gain (OASR_TUNE_MODEL_FALLBACK=0 disables it). A shape with no entry is still a
rule miss — counted by jit.gemm.rule_miss_report(), exported for re-tuning
by oasr tune export-misses.
Per architecture. jit.gemm._GEMM_HEURISTIC_RULES is a view of the system
tier keyed by SM family (and jit.conv._CONV1D_HEURISTIC_RULES likewise); an
arch with no file is tuned by oasr tune build on that card, not by an edit to
the selector. Only SM120 is measured today.
An architecture with no table is not an error — GEMM_DEFAULT computes the right
answer — but it is the largest gap the heuristic can have, because it is every
shape rather than one width, and it is the one gap that used to be invisible: the
arch fall-through returned before recording anything, so rule_miss_report()
printed "every shape this process issued had a tuned rule" on a box that had
never opened the table. It is now counted per architecture (not per shape, which
would name every GEMM the process issued) and reported by both
rule_miss_report() and oasr.layers.format_gap_report():
GEMM heuristic inactive on sm80: no tuned rule table (tuned: sm120), so all 8
shape lookup(s) used GEMM_DEFAULT. Tune this card with scripts/tune_asr_gemm.py.
The production module compiles the tuned subset. get_production_configs(sm)
is what the tuning DB references for the arch (both tiers), the default and a
small coverage basis — 20 of 35 variants on sm120, 9 of 43 on sm80; the tuning
modules (gemm_tune, bmm_tune, group_gemm_tune) compile the rest of the
space (get_unique_compile_configs minus production) and are only built by a
process that tunes or sweeps. Never a variant in both: two libraries
instantiating one CUTLASS kernel share its one-time initialisation
(STB_GNU_UNIQUE statics), so the second skips its own shared-memory opt-in and
fails to launch. Each family's production set is therefore frozen at first use
(jit.gemm._production_set) — a tuning-DB reload cannot move it under a loaded
library, and a config the reload adds is compiled by the tuning module instead.
OASR_GEMM_COMPILE_SET=all restores the old single-module behaviour.
What a failed launch does. A variant whose can_implement refuses the
problem raises oasr.jit.errors.VariantUnsupportedError before anything
launches (CHECK_GEMM_STATUS, csrc/tvm_ffi_utils.h). The GEMM, BMM and Conv1D
dispatch answer it with the default variant, remember it per shape and log it
once. Every other failure — a CUDA error, a failed shared-memory opt-in,
initialize() — is a plain RuntimeError and reaches the caller: relaunching
the default over it hid a sticky CUDA error until a later call tripped over it,
and a variant that could never launch until someone profiled.
The candidate space itself is one generator per MMA lane: every CUTLASS 2.x
family (sm_75/80/86/89/120) gets the lane's tiles at every pipeline depth its
template can take and its shared memory fits — which is how sm_86/sm_89, with
sm_120's 100 KiB budget, came to be offered the 4-stage tiles sm_120's table
uses.
Adding a table is a measurement. Rules that were reasoned about rather than
timed have shipped a 4.6x regression and an empty transcript here; the entries a
new table may name are held to its own architecture's emitted variant set by
tests/kernels/test_gemm_heuristic.py::TestPerArchRuleRegistry.
Three rules are structural rather than tuned:
GEMM_MIN_ROWS— a row floor below which CUTLASS's M-tiling leaves most of every tile empty and cuBLAS's GEMV-shaped kernel wins.- The candidate space is constrained before it is tuned
(
jit.gemm._epilogue_covers_warp). CUTLASS's tensor-op epilogue folds a warp's 32 lanes into a grid derived from the tile, and asserts nothing about that grid covering a warp; a tile where it does not compiles, launches at full speed and returns wrong numbers. At the 8-element access width half precision uses, that is everyblock_n < 32tile. Such a tile is refused by the per-SM builders — for GEMM, BMM, grouped GEMM and Conv2D alike, since all four render from the same list — and the refusal is readable throughjit.gemm.rejected_tiles(). This is not a tuning preference: a rule naming such a tile is a correctness bug that neither dispatch nor timing can see, and one shipped (.artifacts/gemm_thin_n_tile_epilogue.md). - The dispatch decision is a pure function of the call and is deliberately not relaxed under CUDA-graph capture, even though dispatch cost is free there: a capture-dependent branch makes the graph pick a different kernel than eager, and the resulting one-ulp fp16 difference has produced different tokens.
OASR_GEMM_HEURISTIC=0 disables the whole thing. Tuning a card:
oasr tune census + oasr tune build (autotuning.md).
Batch-invariant GEMM (batch_invariant=True)¶
Every choice above depends on M, and with it the order a row is summed in
(cuBLAS's own kernel and split, split-K, Stream-K, and F.linear below
GEMM_MIN_ROWS). The result is an ulp of drift per row as the batch changes.
An int8 quantizer downstream can turn that drift into a changed word
(quantization.md). gemm(..., batch_invariant=True) and
gemm_activation(..., batch_invariant=True) pin the call: every output row
is bitwise independent of M. They run only the CUTLASS 2.x data-parallel
variants that jit.gemm.is_batch_invariant defines: split_k == 1, no
Stream-K, no parallel split-K, not sliced-K (warp_k == block_k), at the shared
64-wide K tile. In these variants one thread owns each output element and walks
all of K in the same mma.sync order, whatever the tile, stage count or grid.
So every member of the class returns the same bits for a row, and the selector
may still pick the tile from M (tests/kernels/test_gemm_batch_invariant.py).
jit.gemm.select_batch_invariant_config picks the config:
- It keeps a measured rule's choice when that choice is already a member.
- Otherwise it applies an occupancy rule: among grids that fit in one wave, the smallest warp tile; past one wave, the least operand traffic.
- It is a pure function of the shape and the SM count.
- It never picks cuBLAS, split-K, Stream-K or the autotuner, even with tuning enabled.
- It falls back only to a
GEMM_DEFAULTthat is itself a member, and raises if no member is compiled.
On sm_120 at embed.out (N=256, K=4864), the pin costs 2.7-3.0× at M=64 and
2.1× at 256. Each CTA walks all 4,864 of K alone, which takes about 15 µs. It
costs 1.25× at 1024 and 1.02-1.20× from 2048 to 8192.
oasr.layers.Linear and LinearActivation expose the pin as
batch_invariant = False, set per instance. A pinned projection takes the
pinned call at every M on the CUDA fp16/bf16 path, row floor included. CPU,
fp32 and OASR_LAYERS_BACKEND=torch keep their routes, and quant_method runs
first.
The BMM general lane¶
bmm has a second lane that the shape rules do not reach. The rendered tile
variants are alignment-8 iterators over contiguous 3-D operands; a decomposed
attention block does not have that shape. Zipformer's is the case that forced
the lane: five products per layer, 4-D permuted views of a
(time, batch, head, dim) activation, one operand broadcast over the request
batch, head dims of 32 / 4 / 12 and a relative-position extent that is always
odd. FMHA cannot substitute — the probabilities are materialized once and
shared by SelfAttention and NonlinAttention, and a fused kernel never
materializes them.
oasr.bmm therefore accepts one or two broadcasting batch dimensions, arbitrary
N and K, and — the part that saves a copy at every call site — a B operand
contiguous along either trailing axis, so [..., N, K] and its transposed
view are both legal and neither needs .contiguous(). A contiguous 3-D
alignment-8 call still takes the tuned lane; everything else lands in
include/oasr/gemm/bmm.cuh, which turns three run-time facts into one
instantiation:
| Chosen from | Values | Why it is compile-time |
|---|---|---|
| B's memory layout | CUTLASS ColumnMajor / RowMajor |
the iterator's contiguous axis |
| operand alignment along K | 8 / 4 / 2 elements | cp.async cannot issue a 2-byte copy, so alignment 1 is not a tensor-op case at all |
| alignment along N (epilogue, and B when RowMajor) | 8 / 4 / 2 / 1 | an odd N has to be able to store one element at a time |
| threadblock tile | 128×128 / 64×64 / 32×32 / 64×16 (thin-N) | see below |
The grid is 124 instantiations (62 per dtype), rendered one translation unit per
(layout, dtype) from csrc/templates/bmm_general_template.cu.jinja — the same
template-per-configuration pattern as the tile variants, and for a measured
reason: nvcc parallelizes across translation units but not within one, so the
module's cold build is set by its largest TU. Inlining the grid into bmm.cu
costs 112 s; this split costs 40 s.
Anything outside that grid runs on CUTLASS SIMT GemmBatched, which constrains
nothing. Nothing falls back to PyTorch: a shape either has a kernel or the
call raises.
Two properties of the lane are worth knowing before changing it:
- The tile ladder is the difference between the lane and a regression. These
problems are 2–70 MFLOP, so they are latency-bound and the only thing that
matters is how much of the device the grid covers. A single 128×128 tile put
8–64 CTAs on an RTX 5090's 170 SMs and burned 246 registers/thread for 8.3%
achieved occupancy — 1.74× slower than cuBLAS. Selecting the largest tile that
still fills one wave brought that to 1.07×. Note the ceiling: CUTLASS's
tensor-op epilogue divides the tile's rows by the warp count in M before it
computes an iteration count, so a 32-row tile is a single-warp shape and
nothing smaller than 64 rows can use 128 threads. The same ceiling has a
column half, and that one is not an assert: the epilogue derives its lane
width from the tile's columns and its lane rows from the tile's rows
independently, and never checks that the two cover a warp — so the 16-column
tile is addressable at 4 elements per access and silently wrong at 8.
epilogueAlignmentinbmm.cuhcaps the store width for that tile alone; thecp.asyncload keeps its 8 elements. GemmBatchedadvances every operand by one constant stride, so two batch axes are one launch only when all three tensors are affine in the flattened index. Both flattening orders are tried, because a contiguous output satisfies one and a head-major view of a(time, batch, head, dim)activation satisfies the other. A broadcast inner axis satisfies neither and costsmin(batch0, batch1)launches.
Measurements, including the profile that produced the ladder:
.artifacts/kg5_strided_bmm.md.
Fused attention¶
oasr.fmha(q, k, v, *, softmax_scale, attn_bias, cache_seqlens, cache_seqstarts,
block_table, causal, window_left, window_right, out, backend)
Three cache modes share one signature:
| Mode | block_table |
cache_seqlens |
|---|---|---|
| Offline | None |
None |
| Dense streaming (caller concatenated old + new K/V) | None |
set |
| Paged streaming (K/V are pool views) | set | required |
Three backends share that signature — cxx (C++ CUTLASS/CuTe), cute
(CuTeDSL) and sdpa (PyTorch, fp32-friendly). OASR_ATTN_BACKEND selects
process-wide; the per-call backend= overrides it without touching the
global, which matters because set_backend_mode() clears three compile caches
and an in-process A/B driven through the environment variable recompiles the
CuTeDSL kernel on every flip. validate=False skips checks for proven inputs.
Naming a backend requires it: a request it cannot serve raises rather than
quietly computing something else. backend=None (the default) is the only mode
that may degrade, and it degrades through fmha_backend_for(...), which asks
whether the preferred lane can serve this shape rather than only which lane
the process prefers.
Two capabilities exist on the cxx lane only:
| Capability | Why not on cute |
|---|---|
window_left / window_right — a per-row sliding window, top-left aligned |
its local axis is the per-stream [seqstart_k, seqlen_k) pair, a different thing with no argument for this |
| causal and windowed varlen, and varlen at all without a second kernel | packed input there is a separate ~600-line kernel_varlen; here it is the same instantiation with a zero batch stride |
The cxx lane also splits the K range when the grid cannot fill the machine
(flash-decoding). num_splits is a pure function of (shape, SM count) —
never of CUDA-graph capture state, because splitting changes the order the fp32
partials are summed (rule 11). The workspace is allocated in Python, from
torch's caching allocator, deliberately not from oasr::getCachedWorkspace,
which branches on cudaStreamIsCapturing.
The kernel moves q, k, v and the output in 128-bit vectors along the head
dimension:
- Every operand: the base must be 16-byte aligned, and the stride of every
axis longer than one must be a whole vector. oasr.fmha and
oasr.fmha_varlen copy an operand that breaks this.
- head_dim: it must be a multiple of 8, or 16 for an fp8 KV cache. A
backend left to choose picks another lane for any other head_dim; naming
cxx refuses it.
- Integer inputs: the wrappers convert lengths, cu_seqlens and block
tables to int32, and packed-bias offsets to int64.
- Direct callers: the launchers check all of this again
(csrc/fmha_launcher.h), along with dtypes and shapes. Calling the module
directly therefore raises an error instead of stopping the CUDA context with
misaligned address.
Packed-bias offsets are int64 because the bias buffer holds sum_s H * T_s^2
elements, which passes 2**31 at 256 segments of 1024 frames and 8 heads. The
CuTeDSL varlen kernel still indexes the buffer with 32-bit indices. A larger
packed bias therefore runs on cxx under auto, and backend="cute" refuses
it. A packed bias may start at any element; when its base is not 4-byte
aligned, the kernel reads it element by element instead of in pairs.
Routing policy and measurements: .artifacts/fmha_tuning.md (CuTeDSL) and
.artifacts/fmha_cpp_validation.md (the C++ lane's falsifications and the A/B).
The C++ CuTe families¶
Three kernel families are written in C++ CUTLASS/CuTe rather than CUTLASS 2.x device templates: fused attention, the fused gated MLP and the fused recurrent step. They share one layout, file for file, so a reader who knows one knows where everything is in the other two:
| Role | attention | gated MLP | recurrent step |
|---|---|---|---|
| Which SMs are served, and the family's ceilings | cutlass_fmha_configs.h |
cutlass_gated_mlp_configs.h |
cutlass_recurrent_step_configs.h |
| Tile choice, CuTe-free | cutlass_fmha_configs.h (derived) |
gated_mlp_tiles.h (table) |
recurrent_step_tiles.h (ladder) |
| Arguments / params | fmha_params.h |
gated_mlp_params.h |
recurrent_step_params.h |
| Family-only CuTe helpers | fmha_utils.h, plus softmax, mask, bias, block, seqlen, paged-KV, combine |
gated_mlp_utils.h (the dual-B gemm) |
— |
| Mainloop collective | fmha_mainloop_sm80.h |
gated_mlp_mainloop_sm80.h |
recurrent_step_mainloop_sm80.h |
| Epilogue collective | fmha_epilogue.h |
gated_mlp_epilogue.h |
recurrent_step_epilogue.h |
| Kernel shell (arch-free) | fmha_kernel.h + fmha_tile_scheduler.h |
gated_mlp_kernel.h |
recurrent_step_kernel.h |
| Launch template (the one file naming an arch) | fmha_launch_template.h |
gated_mlp_launch_template.h |
recurrent_step_launch_template.h |
| TVM-FFI launcher, one TU per variant | csrc/templates/fmha{,_split,_kv8}_template.cu.jinja, checks in csrc/fmha_launcher.h |
gated_mlp_template.cu.jinja |
recurrent_step_template.cu.jinja |
| Capability oracle (the static TU) | csrc/fmha_jit_binding.cu |
csrc/gated_mlp_jit_binding.cu |
csrc/recurrent_step_jit_binding.cu |
| JIT generator + Python tile mirror | oasr/jit/fmha.py |
oasr/jit/gated_mlp.py |
oasr/jit/recurrent_step.py |
| Arbiter over the C++ and CuTeDSL lanes | oasr/jit/attention.py |
oasr/jit/mlp.py |
oasr/jit/recurrent_cute.py |
Names follow the files: Collective<Family>MainloopSm80,
Collective<Family>Epilogue, <Family>Kernel, <Family>Arch<SM>,
run_<family> — so a profile's kernel name says which family it is.
What the three have in common lives in include/oasr/common/, not in three
copies:
cute_sm80.h— the Ampere-class toolkit:ArchAmpere<SM>, the traits base every<Family>Arch<SM>derives from (themma.syncatom, the ZFILLcp.async,ldmatrix, the budgets and the capability bools); the swizzled shared-memory atom; the accumulator re-views; the packed down-conversion; the zero-filling (copy_zfill) and skipping (copy_if) tiled copies; the two warp-level gemm shapes with their refill hook; andlaunch_kernel, the one launcher. The copies this replaced had drifted: the gated MLP found that branching around an asynchronous copy costs three deadLDSper copy and that attention's down-conversion returned a view of a dead local, and neither fix had reached the other family.tile_rules.h— the CuTe-free half: the swizzle-row rule, the warp tiling and tiled-copy pass geometry every tile validator composes, and the occupancy estimate. CuTe-free so the capability oracles build in seconds; mirrored in Python byoasr/jit/arch_facts.py.
What stays in a family directory is what is actually that family's: the online softmax, the masks, the bias read, the paged gather, the split-KV combine, the dual-B gemm, the recurrent state transition, and each family's decision of which architectures it serves.
Three rules hold for all of them. Dispatch on ArchTraits::kIsWarpSpecialized
/ kHasTma, never on Tag::kMinComputeCapability >= 90 (true for sm_120,
which has neither). Every tile choice is a pure function of shape and machine,
never of CUDA-graph capture state (rule 11). And a new architecture is a new
<Family>Arch<SM>, a new collective and one conditional_t arm in the launch
template — nothing else moves.
The C++ attention kernel (include/oasr/attention/)¶
Fifteen headers, structured the way CUTLASS 3.x structures a collective:
CollectiveFmhaMainloopSm80 + CollectiveFmhaEpilogue + SingleTileScheduler,
composed by FmhaKernel — a shell that names no architecture and
touches no layout. fmha_launch_template.h is the one file that names
one, so a Hopper or Blackwell lane is a second FmhaArch<SM>, a second
collective and one more conditional_t arm; the arguments, the FFI signature
and the whole Python side do not move.
Two rules in that header are load-bearing rather than stylistic:
ArchTagselects instructions; a separatekIsSm86Or89selects tuning.cutlass::arch::Sm86exists, but the collective is the same one sm_80 uses.- Never branch on
ArchTag::kMinComputeCapability >= 90.Sm120's is 120, so FlashAttention'sUse_TMA_Otest is true on a part with no TMA at all.FmhaArch<SM>::kHasTma/::kIsWarpSpecializedsay what they mean.
fmha_softmax.h's header comment carries the numerics contract — twelve
numbered items, each one a place where FlashAttention's reference is wrong for
OASR's masking semantics, with the failure each one prevents.
Two implementation notes, because nothing in the C++ says them:
- Every load is branch-free but one. Q, every deeper K/V tile and the paged
gather fold their predicates into the ZFILL
cp.async(cute_sm80::copy_zfill); the branching form had left 144–168 deadLDSand aBSSY/BSYNCpair per copy in every variant. The exception is the dense first K tile, issued once per CTA: its branches keep ptxas from hoisting later stages' address math above the Q and first-K requests, which is the critical path of a CTA that walks one or two K tiles (copy_rows_or_clearrecords the measurement). - The bias is read at its point of use, in the right shape. Rows past the
plane are branched around (they are register loads, so there is no
asynchronous copy to order against) and boundary tiles read column pairs. A
one-tile-ahead register prefetch was built and measured: it bought 3–4% on
streaming chunks and cost up to 6% on interior 2–4-tile CTAs, including a
realistic Conformer batch, so it is not shipped (
fmha_bias.h).
FP8 KV variants (fmha_kv8.h)¶
An autoregressive decoder's KV cache can be stored as float8_e4m3fn with one
fp32 scale per (token, kv-head) (EngineConfig.quantization.kv_cache =
"fp8_e4m3"; see cache_manager.md). oasr.fmha
reads such a cache when k/v are fp8 and k_scale/v_scale are given:
- Dequantized in shared memory, inside the tile loop.
mma.synchas no fp16 x fp8 shape and quantizing Q to use the e4m3 MMA would spend the precision the format keeps, so the cp.async ring carries the fp8 bytes (and each row's scale, in the same commit group) and every stage is converted into one served-dtype tile right before the gemm that reads it -- K, then V, one tile for both. Each thread converts the 16-byte chunks it issued itself, so only the scales need the barrier the ring already takes; the conversion costs one extra__syncthreadsper gemm. Nothing past the conversion changes. - Bit-identical to the served-dtype kernel on the dequantized cache. The
scales are powers of two (
include/oasr/kv_quant/kv_quant.cuh), soq * scalerounded to fp16/bf16 is exact, andfmhaResolveTileKv8walks the same ladder asfmhaResolveTile, so a shipped head dim lands on the same K tile (the split count and the online-softmax tiling both follow it).tests/kernels/ test_fmha_kv8.pyholds the lane to that atatol=0, dense and paged. - Own cell, own variants.
fmha_kv8_sm{SM}_{dtype}_d{D}-- dense / paged x unsplit / split, rendered fromcsrc/templates/fmha_kv8_template.cu.jinja-- so a process that never reads fp8 never compiles it. Rendered for unmasked, bias-free reads only (a decode step, a cross-attention read): a prefill attends its own fresh K/V in the served dtype, so causal / windowed / biased fp8 reads have no caller, and asking for one raises with the reason (oasr.jit.attention.fmha_kv8_refusal), as does a padded head dim the 16-byte gather cannot tile exactly (it covers padded 32 / 64 / 128 / 256). - Routing is a declared rule (
fmha_kv8_backend): fp8 goes to this lane underautoandcxx;OASR_ATTN_BACKEND=cuteraises (that lane cannot read fp8) andsdparuns the dequantizing reference, which is also what CPU and an fp32 query get. The engine refuseskv_cacheundercute/sdpa.
Measured on an RTX 5090, graph-replayed decode steps (median of 15
interleaved rounds): Whisper-large-v3 cross-attention, B16 x H20 x 1500 keys,
51 us vs 82 us fp16 (1.61x); Qwen2-Audio-7B self-attention, B16 x H32 x ~800
keys, d128, bf16, 114 us vs 120-126 us (1.05-1.10x); at B4 the same step is
34 us vs 26-27 us (0.78x) -- with little K/V to read, the in-smem conversion is
the critical path. The quantize-and-scatter that writes the cache
(oasr.functionals.kv_quant.quantize_kv_store) is one launch for K and V per
layer, replacing the two index_put_ the fp16 path issues.
The C++ gated-MLP kernel (include/oasr/mlp/)¶
The same collective decomposition, one family over:
CollectiveGatedMlpMainloopSm80 + CollectiveGatedMlpEpilogue, composed by
GatedMlpKernel, with gated_mlp_launch_template.h the one file that names
an architecture. gated_mlp_tiles.h is deliberately CuTe-free — it holds the
CTA tiles and the arithmetic that picks one, as plain constexpr integers, so
csrc/gated_mlp_jit_binding.cu can export them as a cheap capability oracle
and tests/kernels/test_gated_mlp_cpp.py can hold the Python mirror to them
over four architectures from one box.
Where it differs from the attention family, and why:
| attention | gated MLP | |
|---|---|---|
| tile | resolved: fmhaResolveTile derives it from the architecture, because the question is "what fits" |
tabled: the question is a wave count, so it depends on rows, N and the SM count, and all six tiles are compiled as siblings in one module |
| cell | (sm, dtype, head_dim) — the axes that change the smem layouts |
(sm, dtype, activation) — the activation is the one axis that changes the arithmetic, and a checkpoint means one of them |
| variants in a cell | 3 masks x 2 bias x 2 paged = 12 | 6 tiles x 2 bias = 12 |
Two things it does that the CuTeDSL lane cannot:
| Capability | Why not on cute |
|---|---|
a K residue — K need not be a whole number of K tiles |
there the mainloop predicates only the row axis, so a partial K tile reads the next row of x; gated_mlp_shape_supported has to refuse it |
| arbitrary row strides on every operand | there the compiled signature marks the tensors compact, so a row-slice of a wider buffer has to be copied first |
One implementation note is worth carrying, because nothing in the C++ says it.
The K residue is predicated into the ZFILL cp.async's own src_size operand
(cute_sm80::copy_zfill), not into control flow. Written the obvious way —
if (pred) copy(...) else clear(...) — ptxas has to order a synchronous STS
against an asynchronous LDGSTS to the same shared address, and it does that
by bracketing every copy in BSSY/BSYNC and padding it with three dead
@!PT LDS RZ, [RZ]. Measured on the 64x64x32 tile: the K loop's load section
went from ~60 instructions to ~12, the LSU pipe from 2.34M instructions to
1.2M, and the kernel from 0.89x of the CuTeDSL lane to ahead of it everywhere.
The C++ fused recurrent step (include/oasr/recurrent/recurrent_step_*.h)¶
The same collective decomposition again, and the second lane for the kernel
oasr/kernels/cute/recurrent/step.py already implements in CuTeDSL:
CollectiveRecurrentStepMainloopSm80 + CollectiveRecurrentStepEpilogue,
composed by RecurrentStepKernel, with recurrent_step_launch_template.h the
one file that names an architecture. recurrent_step_tiles.h is CuTe-free, so
csrc/recurrent_step_jit_binding.cu exports it as a capability oracle and
tests/kernels/test_recurrent_cpp.py holds the Python mirror to it over six
architectures from one box.
One fused timestep, with the gate dimension interleaved so column n is
(hidden n / G, gate n % G):
gates[m, n] = sum_k previous_h[m, k] * weight_hh[n, k] + input_gates[m, n]
c[m, i] = sigmoid(g1) * previous_c[m, i] + sigmoid(g0) * tanh(g2)
h[m, i] = sigmoid(g3) * tanh(c[m, i])
The interleaving is what keeps the epilogue inside one CTA tile — a hidden
unit's gates are adjacent columns, so no cross-tile reduction is needed, and
recurrentStepTileValid refuses any block_n that would straddle one.
| gated MLP | recurrent step | |
|---|---|---|
| tile | wave count: is the last wave still saturating DRAM? | tabled ladder on (hidden, batch): a dependent launch has no next kernel to overlap its tail with, so what the measurements found was a boundary between two regimes, not a wave count |
| cell | (sm, dtype, activation) |
(sm, dtype, kind) — lstm / rnn_tanh / rnn_relu |
| variants in a cell | 6 tiles x 2 bias = 12 | 8 tiles |
The ladder is inherited from the CuTeDSL lane rather than re-derived, so both
lanes run the same tile on every shape and an A/B between them compares
kernels. test_the_ladder_reproduces_the_cutedsl_lane pins that.
Three things it does that the CuTeDSL lane cannot:
| Capability | Why not on cute |
|---|---|
| a K residue — the hidden width need not be a whole number of K tiles | there the mainloop loops ceil_div(K, k_block) and predicates only the row axis, so a partial K tile reads past the end of both operands |
| arbitrary row strides on every operand | there the compiled signature marks the tensors compact, so a row-slice has to be copied first |
| a staging buffer larger than the ring | there the epilogue aliases the ring; here it is a union, so can_implement needs no such clause |
Two epilogue details are worth carrying, because nothing in the C++ says them:
- The global loads are issued before the mainloop. The transition needs
input_gatesandprevious_cfrom global memory and the affine from shared; the first two depend on nothing the K loop computes, so the kernel shell issues them first (load_inputs) and their latency hides behind the whole loop — 1.04–1.10x on the latency-bound steps. The two 128x128 tiles, where the carried values would be 16–20 registers against a 512-thread CTA's 128-register cap, load them after the loop instead (kPrefetchInputs). - The accumulator staging is padded by 8 floats per row, not 1. Both avoid
the MMA-C store's bank conflict; only 8 also keeps every row 16-byte aligned,
so a cell's four gates come back in one
LDS.128instead of fourLDS.32.stride % 32 == 8puts a 16-laneSTS.64phase on 32 distinct banks and an 8-laneLDS.128phase likewise.cuobjdump -sassconfirms both.
One declared limitation: the widest one-gate variant (tile 7, 32 epilogue
slots on a 512-thread CTA) sits at REG:128 with a 32-byte stack frame, where
every LSTM variant is at REG:54-120 with no spill. Nothing reaches it — a
vanilla RNN is never routed under auto, and tile 7 needs hidden > 1536 and
batch > 128 — so it is declared rather than fixed. The header comment records
the two causes that were tested and refuted, so a later attempt starts past
them.
Routing is oasr/jit/recurrent_cute.py — the arbiter, and the CuTeDSL lane's
compile cache. OASR_RECURRENT_CUTE decides whether to fuse;
OASR_RECURRENT_BACKEND decides which lane. Measurements:
.artifacts/recurrent_cpp_validation.md.
CuteDSL kernels (oasr/kernels/)¶
oasr/kernels/ holds low-level implementations that do not use the TVM-FFI /
Ninja pipeline.
kernels/cute/attention/base.py— abstractFmhaBase+pick_arch_cls(major, minor)kernels/cute/attention/fmha_sm80.py—FmhaSm80, the mainloop (sm_80: A100, A30)kernels/cute/attention/fmha_sm{86,89,120}.py— thin subclasses for Ampere consumer, Ada and consumer Blackwell.kernels/cute/recurrent/step.py—RecurrentStepCute, one fused LSTM/RNN timestep as a tensor-core GEMM with the state transition in the epiloguekernels/cute/mlp/gated.py—GatedMlpCute, a dual-B GEMM: one A tile in shared memory feeding twomma.syncchains against two B tiles, withactivation(gate) * upapplied to the FP32 accumulators. The shape of CUTLASS'sexamples/45_dual_gemm, and the reason a gated MLP needs no intermediate tensorkernels/cute/— FlashAttention-style helpers:block_info.py,seqlen_info.py,mask.py,softmax.py,tile_scheduler.py,pack_gqa.py,paged_kv.py,named_barrier.py,copy_utils.py,layout_utils.py,ampere_helpers.py,utils.py
Each is compiled via cutlass.cute.compile() into a Python callable and cached
per config — oasr/jit/attention.py::_compiled_fmha,
oasr/jit/recurrent_cute.py::_compiled_step,
oasr/jit/mlp.py::_compiled_gated_mlp — always with
options="--enable-tvm-ffi", which is what lets the callable take torch tensors
directly and be captured into a CUDA graph.
Each of the three also owns a routing module beside its compile cache, because
a fused kernel that is faster on some shapes and slower on others has to say
which: OASR_ATTN_BACKEND, OASR_RECURRENT_CUTE, OASR_GATED_MLP_CUTE, each
with the measured band in its module docstring. For the gated MLP the band is
one m-tile (M <= 64): with one m-tile every weight element is read from
DRAM once, which is the bandwidth argument the fusion rests on; with two it is
an ordinary GEMM reading its operands twice and cuBLAS wins. The tile inside
the band is chosen by N rather than by M — see select_gated_mlp_tile, and
the ten lines of it that exist because a rows-keyed table lost 10% at one model
width.
Attention and the gated MLP each have two fused lanes, cute and cxx,
and they spell the choice differently on purpose. Attention folds both
questions into OASR_ATTN_BACKEND, because its fallback (sdpa) is itself a
backend and sits in the same list. The gated MLP cannot: its fallback is two
GEMMs, which is not a lane of this kernel at all, so "fuse or not"
(OASR_GATED_MLP_CUTE) and "which lane" (OASR_GATED_MLP_BACKEND, auto /
cute / cxx) are separate switches. Collapsing them would make a rollback
and an A/B the same knob, and they are different decisions: the first is an
operator's call about a shape, the second is a claim about two implementations
of the same thing.
auto resolves to cxx for the MLP — 1.00x-1.11x of the CuTeDSL lane over
four interleaved, graph-replayed sweeps with no row regressing, plus a strictly
wider shape contract — and to cute for attention, where the C++ lane's paged
mode still reads 0.85-0.94x.
Which routing decisions travel, and which are extrapolations¶
The routing splits cleanly in two, and only one half follows the card it runs on:
| reads the machine | example | |
|---|---|---|
| derived | yes | gated_mlp_ctas_per_sm takes multi_processor_count, the real opt-in shared memory and max_threads_per_multi_processor, and select_gated_mlp_tile does wave arithmetic against them; selectBmmTile takes getDeviceMultiProcessorCount() |
| measured | no | _LSTM_BANDS's (hidden, batch) cut-offs, the _TILES ranking, the gated-MLP candidate list — timed once and written down |
A measured cut-off is neither wrong nor a guess; it is the best available estimate. But the crossover it encodes is a function of SM count and memory bandwidth, and the supported set — sm_80, sm_86, sm_89, sm_120 — spans an A30 (56 SMs, 933 GB/s), an A100 (108, 1555), an L40S (142, 864) and an RTX 5090 (170, 1792). All four got the same numbers, with nothing saying whose they were.
oasr/jit/measured.py is where each table now names the GPU it was timed on, the
note holding the protocol, and what moves the crossover. Off that card the table
still applies — which side wins at each extreme is a property of the algorithm,
only the boundary between them is a property of the machine — but the fact is
logged once and reported by oasr.layers.format_gap_report():
routing tables measured on another GPU (applied as an extrapolation):
jit.recurrent_cute._LSTM_BANDS / _TILES
measured on NVIDIA GeForce RTX 5090 (sm_120, 170 SMs), 1792 GB/s; running on NVIDIA A30 (sm_80, 56 SMs)
Identity is (compute capability, SM count), both queried exactly. Bandwidth is
deliberately not computed from memory_clock_rate × bus_width: that formula is
right for HBM and GDDR6 and wrong for GDDR7, so on an RTX 50-series card it would
report the measured machine as an extrapolation of itself.
Utilities¶
oasr/utils/:
| Module | Contents |
|---|---|
validation.py |
@supported_compute_capability([80, 86, ...]) marks a check function with the SMs it supports; @backend_requirement(backend_checks={...}, common_check=fn) wires validation into the public API function and adds .is_backend_supported() / .is_compute_capability_supported() helpers |
mappings.py |
dtype and enum helpers |
timer.py |
timing helpers |
oasr/testing/bench_gpu_time(fn, args, ...) is the measurement primitive: CUDA
event timing with an optional CUPTI fallback via triton.testing.do_bench,
returning (median_s, std_s).
Ahead-of-time compilation¶
oasr/aot.py registers every kernel family for AOT builds, including
gen_all_gemm_variants() for systematic variant enumeration. AOT is optional —
the default path is JIT-on-first-call.
Autotuning¶
oasr/tune/ produces what the shape-aware selection reads: the census of shapes
a deployment runs, one benchmark protocol (oasr/tune/bench.py), the cost
model, the region partitioner and the tuning database, driven by oasr tune
census|build|evaluate|diff|status and by the oasr.autotune() context manager,
whose GEMM winners land in the user tier. See autotuning.md.
Adding a kernel family¶
Seven steps, in order:
- Kernel header in
include/oasr/<family>.cuh - TVM-FFI launcher in
csrc/<family>.cu - TVM-FFI JIT binding in
csrc/<family>_jit_binding.cu - JIT generator in
oasr/jit/<family>.py - Python functional API in
oasr/<family>.py nn.Modulewrapper inoasr/layers/<family>.py- AOT registration in
oasr/aot.py
The /add-cuda-kernel skill (.claude/skills/add-cuda-kernel/SKILL.md) walks
through each with worked code. /benchmark-kernel covers measuring the result.
pybind11 bindings (csrc/pybind/) remain for the work that is not a kernel:
the CPU-side CTC/WFST decoders and the post-decode alignment pass. New kernels
do not use them — TVM-FFI JIT is the route for anything that runs on the device.
The distinction is about where the work runs, not how fast it is. csrc/alignment/
holds no CUDA at all; it is there because the pass runs on the engine's step-loop
thread, which holds the GIL for every request the engine finishes, and in Python
it cost more than the CTC decode it decorated. The beam read-back is the same
story on the untimed path: out_lengths[b, k] is a 0-d tensor plus an
item(), and a slice is another tensor, so materialising a 16-beam block row by
row cost more than the decode's own device→host copy.
Neither has a Python twin in the package — oasr/engine/decode/alignment.py is
marshalling only, because a fallback here is a slow path a deployment lands on
silently. Both files are in OASR_SOURCES, so a successful build always has
them and no call site checks. So these are the one part of the tree where
test-cpu.yml, which compiles nothing, cannot cover the implementation: the
rule is checked against a Python oracle kept inside tests/decoders/test_alignment.py
(exact agreement over randomised input and the whole Unicode plane), and that
file skips without the extension.