Model Layer¶
OASR's model layer is architecture-agnostic in the vLLM / SGLang sense: the engine touches a model only through a small base-class surface, and a new architecture plugs in by subclassing and registering. No engine edits.
This document covers the contracts, the registry, and the seven built-in
architectures. The registry seams themselves are in
architecture.md; checkpoint conversion is in
checkpoints.md; parity and WER results are in
.artifacts/model_validation.md.
The engine-facing surface¶
oasr/models/base.py defines what the engine may assume.
| Member | Meaning |
|---|---|
encode_offline / encode_chunk_paged |
Encoder only → raw hidden states |
forward_offline / forward_chunk_paged |
Fused encoder + head → log-probs (the CTC fast path CUDA-graph capture preserves) |
forward_offline_packed |
Gapless varlen offline forward, used when sequence packing is enabled |
cache_spec |
CacheSpec describing the streaming cache; None when streaming_kind == "none" |
decode_type / default_decode_type / capabilities |
Which decode families this checkpoint can serve (DecodeType in base.py) |
encoder.streaming_kind |
"paged" / "slot" / "stateful" / "none" |
encoder.subsampling_rate, encoder.right_context |
Streaming geometry inputs |
load_weights(state_dict) → LoadReport |
Weight loading, reporting mapped / dropped / missing |
BaseEncoder.n_kv_head and head_dim are paged-streaming only — they are
non-abstract with a self-explaining raise, so an offline-only encoder implements
neither.
Three optional encoder declarations¶
All three default to "nothing declared", so an encoder that needs none of them is unaffected by their existence. Together they are what makes a new streaming cache data rather than a new manager.
| Declaration | What it says |
|---|---|
streaming_state_specs |
Extra fixed-extent per-stream tensors → CacheSpec.stream_states. See cache_manager.md §10. |
fixed_attention_window |
A trained attention span. The engine pre-fills the paged K/V window so cache_seqlens is uniform across the cohort — the precondition for one shared relative-position table, since a Transformer-XL table's distances are cache + i - j and a per-row table would run the positional projection per row. It also makes cache_t1 constant, so the graph cache captures one graph per batch size instead of one per (B, cache_t1 bucket). |
streaming_geometry(chunk_size) |
A front-end the generic (chunk_size - 1) * sub + right_context + 1 window formula does not describe — a cached causal subsampling consumes exactly chunk_size * sub frames with no lookahead. Also the place to refuse a chunk size the encoder cannot serve; the backend calls it once at construction. |
A fourth optional declaration is model-level: quantization_layout() returns a
QuantLayout — the SmoothGroups (which norm feeds which GEMMs, and how a
SmoothQuant scale is absorbed) and the layers kept at full precision. Pure data;
the default declares nothing smoothable, and online round-to-nearest quantization
works without it. See quantization.md. A model that infers
its dtype from a parameter uses compute_dtype, not next(parameters()).dtype,
which reads int8 storage on a quantized model.
streaming_kind must describe what this config's weights can actually do, not
what the class implements. Returning "none" makes streaming service mode fail
at construction with a clear message and makes cache_spec None, so no paged
pool is allocated. An encoder that over-claims builds an engine that raises on
its first request instead.
Capabilities¶
oasr/models/interfaces.py::CAPABILITIES is a declarative table: for each decode
family, the dotted model-attribute paths it requires plus a one-line why.
build_decode_strategy calls require_capability once, so a checkpoint
advertising a capability it cannot serve fails at engine construction naming
the missing members. tests/models/test_contract.py validates the table against
every registered architecture, built tiny on CPU.
This is the answer to "what must a model implement to support family X".
Registry and loading¶
oasr/models/registry.py provides register_model, build_model_from_checkpoint,
load_checkpoint_bundle, and the CheckpointConverter protocol.
- Built-ins come from one
_BUILTIN_PACKAGESlist. - Out-of-tree architectures arrive through
oasr.modelsentry points, so adding one needs no registry edit. A broken plugin warns and is skipped rather than taking the built-ins down. oasr/models/loaders.py::from_pretrained(re-exported asoasr.from_pretrained) resolves a local directory or a Hugging Face Hub id.load_pretrainedadditionally returns the tokenizer / feature / decoding specs plus aLoadReport, and is whatASREngineuses.
Config deserialization¶
Model configs inherit one type-driven from_dict (oasr.models.base.coerce_config):
it restores Tuple fields, recurses into nested dataclasses, and unwraps
Optional. A config overrides _from_dict_overrides only for a flat encoder
dict or a polymorphic field — e.g. TransducerModelConfig.encoder, whose class
comes from the sibling encoder_type.
Weight-load accounting¶
load_weights returns a LoadReport{mapped, dropped, missing}, built via
LoadReport.build (which folds unexpected into dropped and uses set
membership). The registry then warns on dropped weights according to the
converter's declarations:
| Converter declaration | Effect |
|---|---|
expected_unused_prefixes |
Silent — e.g. icefall simple_*_proj |
capability_drop_hints |
Named warning — e.g. the U2++ decoder.* rescoring branch |
(none): a model tensor left missing |
Load error, carrying the drop hints that fired |
Autoregressive decoder contracts¶
oasr/models/decoders/base.py holds BaseDecoder, PredictionNetwork, Joiner
and TransducerPredictor — the transducer / AED / LLM extension points.
Two batched decoder shapes exist, and both are driven from
oasr/engine/decode/:
- Frame-synchronous (transducer):
init_state+step. - Label-synchronous AR (Whisper, speech-LLM): the incremental surface
prefill/step/select/merge.select(state, idx)is anindex_select, so repeated indices are legal — which is what lets beam search both expand a prefilled batch into aB × kgrid and reorder it onto each slot's parent, with no new model method.merge(a, b)joins two prefilled states into one forward, guarded bycan_merge; it works because the KV is indexed per row rather than off a shared generation offset (see decoding.md § Groups merge).
Two class-level declarations go with it, both answered by the decoder rather
than probed. supports_paged_kv says prefill accepts a kv_manager and will
page its decoder KV out of that pool — for an AED, the cross-attention over
the encoder window as well (build_cross_kv); supports_step_graphs says a
step can be recorded into a CUDA graph — which requires that everything it
reads either lives at a process-stable address or is small enough to copy
into a static buffer per step. Whisper meets it only paged: dense, its
cross-attention K/V is allocated per prefill and a graph would bake in a
pointer to the batch it was captured on, so the graph cache refuses a dense
state whatever the class declares.
supports_step_graphs also decides storage under the default
kv_storage="auto": paging is what makes a step capturable, and a cost
otherwise, so a decoder that declares it gets a paged pool and one that does
not keeps its dense capacity buffer. Declare it from what this config's
weights can do, not from what the class implements — the failure mode is the
same as over-claiming streaming_kind, an engine that builds and then
misbehaves instead of one that refuses at construction.
An AR decoder may optionally add cross_attention(enc_out, token_ids, heads,
max_frames), returning the attention probabilities of a declared (layer, head)
set plus the pass's logits. It is what backs word timestamps for the aed family
(DTW over the attention — see
decoding.md § Word timings), and it is optional in
the real sense: a decoder without it makes AedDecodeStrategy.word_timing_modes
report (), and the engine then refuses a word_timestamps request instead of
answering it with the field missing. It is deliberately a second teacher-forced
forward rather than a hook on generation, so the cost lands on the request that
asked rather than on every decode step of every request.
The transducer predictor state is opaque¶
TransducerPredictor declares init_state / predict / advance(state, tokens, emit) /
stack_states / unstack_states. One greedy loop therefore serves both:
- a stateless label-window predictor (icefall: recomputable from the last
klabels), and - a recurrent one (NeMo's 2-layer LSTM: not).
Two consequences:
- A recurrent predictor's start state is not zeros. NeMo runs the LSTM once
on the blank embedding (the zero row, via
padding_idx) from a zero hidden state, and that response is what the first frame's joint sees. beam_size > 1is refused at construction for such a predictor (label_window_state = False), because modified beam search keeps the beam's states in one(B, k, ctx)buffer and gather-reorders them onto their parents — which only expresses a label window.
oasr/models/decoders/transformer_decoder.py holds the WeNet/ESPnet-compatible
TransformerDecoder / BiTransformerDecoder (state-dict keys mirror WeNet's
embed.0.weight / decoders.N.self_attn.linear_q.* layout, so U2++ decoder.*
weights load 1:1), the add_sos_eos / reverse_pad_list helpers, and an
incremental forward_one_step. It is built on oasr.layers: WeNet's
linear_q / linear_k / linear_v / linear_out names over the shared
Attention core, and feed_forward as a FeedForward(names=("w_1", "w_2"))
whose ReLU folds into the GEMM epilogue. Cross-attention takes memory_lens
rather than a materialized memory mask — the form the fused kernel enforces
directly.
Heads¶
oasr/models/heads/ctc.py::CTCHead wraps oasr.layers.ctc.CtcProjection, the
fused log_softmax(x @ Wᵀ + b) via oasr.gemm_log_softmax.
Output projections are allocated at a GEMM-aligned width. CUTLASS's
alignment-8 iterators mean an unaligned head cannot reach the kernel at all, so
oasr.models.base.align_out_features rounds the width up and pad_output_projection
widens the checkpoint on load, biasing the padding rows to PAD_LOGIT so they can
never win an argmax. test_no_architecture_needs_an_unaligned_gemm keeps this
closed.
The seven built-in architectures¶
| Registry key | Package | Decode families | Streaming | Source format |
|---|---|---|---|---|
conformer |
models/conformer/ |
ctc, ctc_aed_rescoring |
paged | WeNet |
zipformer |
models/zipformer/ |
ctc |
slot (config-dependent) | icefall |
transducer |
models/transducer/ |
transducer |
follows the encoder | icefall (explicit only) |
nemotron |
models/nemotron/ |
transducer |
paged | Hugging Face |
whisper |
models/whisper/ |
aed |
none | Hugging Face |
paraformer |
models/paraformer/ |
paraformer |
none | FunASR |
speech_llm |
models/speech_llm/ |
llm |
none | Hugging Face |
list_models() prints this table's key column as the registry sees it at
runtime, including any out-of-tree architecture that arrived through the entry
point group.
Every architecture is built from oasr.layers,
not from bare nn.Linear / nn.LayerNorm / nn.Embedding.
tests/models/test_layer_waist.py enforces it, and its tiny-config table is keyed off
list_models() so a newly registered architecture with no entry fails rather
than going uncovered.
Conformer (WeNet U2/U2++)¶
model.py, config.py, convert.py::WenetConverter. streaming_kind="paged";
supports sequence packing.
A U2/U2++ directory whose train.yaml declares a (bi)transformer decoder gets
the AED branch loaded as self.decoder (decoder.left_decoder.* keys map 1:1;
plain-transformer decoder.decoders.* keys are remapped into left_decoder),
giving capabilities={"ctc", "ctc_aed_rescoring"} with default_decode_type="ctc".
Rescoring is opt-in via EngineConfig.decode_method. The decoder config — including
sos/eos (raw vocab − 1) and the trained reverse_weight — lives on
ConformerModelConfig.decoder.
The converter builds only a WeNet asr_model or k2_model with a
ConformerEncoder, because OASR decodes through the CTC head those two train. It
refuses, at load:
- another encoder or model class, and model_conf.ctc_weight: 0;
- an encoder_conf value this Conformer does not implement, such as a
pos_enc_layer_type other than rel_pos, a selfattention_layer_type other
than rel_selfattn, RMSNorm, concat_after, or a static chunk without a
dynamic one;
- an activation other than swish or relu. WeNet's gelu is exact erf, while
the convolution module's fused one is the tanh approximation;
- an encoder_conf key it does not know.
A decoder_conf it cannot build drops the AED branch with a warning instead. The
tables of keys are in convert.py.
Zipformer (icefall CTC)¶
model.py, encoder.py, subsampling.py, scaling.py, config.py,
convert.py::IcefallConverter (which infers the config from checkpoint shapes).
streaming_kind is "slot" only when the config is streaming-capable
(causal=True and chunk_size > 0), otherwise "none" — so a non-causal release
such as zipformer-large-cr-ctc is refused in streaming service mode at engine
construction rather than raising on its first request. The converter tells a
causal release from its conv modules (icefall's ChunkCausalDepthwiseConv1d
has causal_conv + chunkwise_conv where a non-causal one has
depthwise_conv.weight) and gives it the decode geometry the releases are
published at, chunk 16 / left context 128.
Streaming follows icefall's recipe exactly, pinned against the release's own
TorchScript streaming export (TestCausalRelease):
- windows of
2 * chunk + 13input frames at a stride of2 * chunk(streaming_window_frames/streaming_chunk_frames) — the embed turnsTframes into(T - 7) // 2 - 3, so that is what yields exactlychunkembed frames, the unit the downsampled stacks need whole; a short final window is padded with icefall'sLOG_EPS(streaming_pad_value); - a processed-length mask: the state's last tensor counts the embed frames a stream has been through, and the part of the left-context cache that is still initial zeros is masked.
The whole per-stream cache — the embed's cached frames, six tensors per layer
(key, nonlin-attention, two value caches, two conv tails), the processed-length
counter — is declared as slot_state_specs, one StreamStateSpec per tensor with
its icefall batch axis as the slot axis. The engine's slot runtime owns it (see
cache_manager.md § 10) and graph-captures the whole step; the
list API (get_streaming_init_states / streaming_forward, plus
stack_streaming_states / unstack_streaming_states) stays for the
"stateful" runtime, which is the parity oracle.
The NonlinAttention gate uses the standalone Tanh waist module; its gate is
one chunk of a three-way projection output, so a whole-GEMM epilogue cannot
represent the operation.
The attention is decomposed on purpose and cannot be fused.
RelPositionMultiheadAttentionWeights materializes the probabilities once and
both SelfAttention and NonlinAttention consume the same tensor, so FMHA —
which never materializes them — has no way to express the sharing. The five
products per layer therefore go through oasr.bmm's general lane: 4-D permuted
views, an operand broadcast over the request batch, head dims of 32 / 4 / 12 and
an always-odd relative-position extent. See
kernels.md § "The BMM general lane"; the remaining fusion (score
add, relative shift, masks, softmax) is the open item.
Transducer (icefall RNNT)¶
model.py::TransducerModel, decoder.py (stateless predictor), joiner.py,
config.py, convert.py::IcefallTransducerConverter.
encoder_type ∈ {"conformer", "zipformer"} selects the acoustic front-end, and
streaming follows the encoder (paged, or slot for a causal Zipformer).
Not auto-detected. icefall directories sniff as zipformer (CTC) and hybrid
checkpoints carry both branches, so load with
from_pretrained(dir, architecture="transducer"). The config is shape-inferred
from decoder.* / joiner.*; simple_*_proj is declared-dropped and
ctc_output.* is a named capability hint.
The predictor starts from icefall's window. Before the first label the state
is [-1] * (context_size - 1) + [blank] (StatelessDecoder.init_state), and a
negative label embeds to zero in StatelessDecoder.forward and in both fused
decode kernels. That is the input icefall trains on: [blank] + y, with the
embeddings left-padded by zero vectors. It is also where icefall's own decoders
and sherpa-onnx start. A window of blanks is a different input wherever
blank's embedding row is not zero. The Zipformer recipes declare no
padding_idx, so their blank row is learned, and a blank-filled start window
changes the first emitted tokens.
The predictor's post-embedding/conv ReLU and the joiner's tanh-over-projection-sum use standalone activation modules. Neither can use a GEMM epilogue: the former also covers the no-conv context-size-one case, and the latter precedes the vocabulary projection.
Nemotron ASR (FastConformer + RNN-T)¶
encoder.py, subsampling.py, predictor.py, convert.py::HFNemotronConverter
(auto-detects config.json: model_type=nemotron3_5_asr). Offline and
cache-aware streaming. Reference release:
nvidia/nemotron-3.5-asr-streaming-0.6b.
Every subsampling-stage pointwise Conv2D fuses ReLU into its existing epilogue.
The RNN-T joint still uses standalone Relu because activation applies to the
sum of encoder and decoder projections before the vocabulary head.
- 24-layer macaron Conformer over causal depthwise-separable 8× Conv2d subsampling, kept in NHWC so the projection's input columns are permuted once at load.
- Transformer-XL relative position (
rel_shift). WeNet's convention needs no shift, which is whyoasr.layers.RelPositionMultiHeadedAttentionhas none and this is a separate module. - A
chunked_limitedattention mask that applies offline too — it is trained, not a streaming-only device: a query sees its own chunk ofnum_lookahead_tokens + 1frames plus(sliding_window - 1) // chunkearlier ones. - Offline attention runs in query blocks. The same window bounds what the
offline pass computes: each block of
OFFLINE_BLOCK_FRAMESqueries (64, rounded up to whole chunks) is scored against its own frames plus the trained history, i.e. one streaming step withcache_t1 = left, run for every block at once. Scores and the relative table then grow withT, notT². Upstream builds the wholeT x (2T - 1)table and masks almost all of it, which ran long audio out of memory. An utterance that blocks would not cut to half the dense scores (T <= 2 * keys, 240 frames on the release) stays one dense block, because copying K and V into overlapping blocks costs more than it saves there. predictor.py— 2-layer LSTM predictor onoasr.layers.LSTM's fused recurrent kernels + additive jointhead(relu(enc_proj + dec_proj))+ the language-prompt projector, with no residual — its output replaces the hidden state. Upstream concatenates a 128-wide one-hot onto every encoder frame; here the one-hot's columns of the first projection are folded with its bias into a per-prompt table at load (W_h h + (W_p[:, id] + b)), so each row adds its own prompt's row of it. That drops the concat and shrinks the GEMM's K from 1152 to 1024, and is within one rounding of the concat rather than bit-identical (the GEMM output is rounded before the bias is added).- The prompt is per request.
DecodingOptions.language(a bare ISO-639 code) resolves against the checkpoint's ownprocessor_config.jsontable: the code if it is listed (en→ 0), else the table'sxx-XXspelling (zh→zh-ZH→ 4), else the one slot its locales share (vi→vi-VN→ 33). A code the table does not know, or whose locales disagree, is refused at admission. No language meansdefault_prompt_id(auto, 101: the model's own language ID). The model declares it throughBaseAsrModel.encoder_options, so the transducer family admitslanguagehere and refuses it on a checkpoint that has no prompt. Offline, each batch row carries its id intoencode_offline(encoder_conditioning); a batch where nobody names a language takes the plain call. Streaming, the id is a per-streampersistentslot state (StreamStateSpec.persistent), written at admission, read inside the chunk forward so captured graphs gather each replay's rows, and kept across a VAD turn reset, which rewinds what the encoder heard but not what the request asked for.num_lookahead_tokensis still fixed at construction: it changes the streaming geometry, not a per-row input. TokenizerSpec(kind="huggingface", special_ids=[blank])— blank 13087 sits past the tokenizer's 13087 pieces, so it must be filtered before the backend sees it.
Streaming carries four kinds of state, each on a declared axis rather than as a
special case: the three subsampling-stage tails (streaming_state_specs), the
per-layer post-GLU conv tail (the engine's existing "conv" slot cache —
conv_kernel_size - 1 is exactly what this encoder needs), the prefilled
paged K/V window (fixed_attention_window), and the frontend frame grid (on the
extractor, not here). The model adds a fifth slot state, the stream's prompt id,
which describes the request rather than the audio.
Two alignment preconditions, both silent if violated, both refused at engine
construction by streaming_geometry:
chunk_sizemust be a whole number of trained attention chunks (num_lookahead_tokens + 1) — the mask groups absolute frame positions, so a partial trained chunk would need keys from the future.- The resulting feature window must be a multiple of the subsampling factor, so every stage's input length is a multiple of its stride.
Whisper (Hugging Face)¶
model.py (encoder + decoder in HF key layout), config.py (including
generation-control ids), convert.py::HFWhisperConverter (auto-detects
config.json: model_type=whisper, reads model.safetensors +
generation_config.json).
Offline-only (streaming_kind="none"), decoded by the incremental aed strategy
with suppress / begin-suppress lists and the SOT prompt from the config. The
decoder exposes a batched incremental prefill / step / select surface.
fc1 / fc2 stay flat because HF's layout puts them on the layer; fc1 is an
exact-erf LinearActivation("gelu"), which preserves the flat checkpoint keys
while applying GELU in the GEMM epilogue. The two frontend convs use standalone
Gelu over their BTC output.
Paraformer (FunASR, non-autoregressive)¶
encoder.py— SANM encoder: FSMN-memory self-attention over the sharedoasr.layers.Attention, 560 → 512 first layer, sinusoidal PE, LayerNorm eps 1e-12 (ESPnet convention; parity breaks at PyTorch's 1e-5).predictor.py—CifPredictor(CifPredictorV2) with the vectorizedcif_v1prefix-sum integrate-and-fire, always fp32. Sigmoid runs over the contiguous aligned alpha head before selecting its real column; the post-sigmoid ReLU was redundant and is omitted.decoder.py— SANM NAR decoder: FFN-first layer order, FSMN-only self-attention, one parallel pass over the CIF acoustic embeddings. Cross-attention takes encoder lengths, not a mask.convert.py::FunASRParaformerConverter— auto-detectsconfig.yaml: model: Paraformer, parsesam.mvnCMVN into syntheticencoder.cmvn_shift/cmvn_scalestate-dict buffers so native round-trip is automatic.
CIF fire positions become per-token RequestOutput.timestamps.
speech_llm (Qwen2-Audio-style LLM-ASR)¶
audio_tower.py— Whisper-geometry encoder (×32, d=1280) with key-padding-only attention mask +AvgPool1d(2)+ post-pool LayerNorm. Valid lengths follow HF's two-stage formula:(mel − 1) // 2 + 1, then(feat − 2) // 2 + 1.llm.py::Qwen2Lm— a faithful Qwen2 causal LM built onoasr.layers(RMSNorm,NeoxRotaryEmbedding,GatedMLP,Attention). RMSNorm accumulates in fp32 to the store where HF rounds before the weight multiply — the more accurate order, one rounding step apart under bf16.prefill(inputs_embeds, valid[, capacity])/step/selecthandle left-padded variable-length prompts (per-row positions =cumsum(mask) − 1, HF's masked-generate convention). Withcapacity, the per-layer K/V buffers are preallocated and each step writes its slot in place.model.py— module names mirror HF 1:1 (audio_tower/multi_modal_projector/language_model);load_weightsnormalizes both the 4.x (language_model.model.*) and 5.x-resave (language_model.model.model.*) key layouts.convert.py::HFQwen2AudioConverter— auto-detectsconfig.json: model_type=qwen2_audio, fills omittedtext_configfields fromQwen2Configdefaults (the published 7B relies on them), and reads sharded safetensors.
Checkpoint bundles load host-side (map_location="cpu") with the dtype cast
before the device move — an 8.4B checkpoint double-books the GPU otherwise.
Pre-norm residual streams use the waist's fused add+norm operations: Whisper, the Qwen2-Audio tower, Paraformer, and Nemotron reuse add+LayerNorm, while the Qwen2 LM uses add+RMSNorm. The residual-returning forms carry the unnormalized sum into the next sublayer without a separate elementwise launch. Zipformer is intentionally different: its learned bypass is a lerp and its endpoint is BiasNorm, so it cannot use the LayerNorm/RMSNorm fusion without changing model semantics.
Adding an architecture¶
See the cookbook in architecture.md. In brief:
class FooEncoder(BaseEncoder)—forward, the introspection properties, and (for streaming) eitherforward_chunk_pagedorget_streaming_init_states/streaming_forward.class FooModel(BaseAsrModel)withfrom_config+load_weights.- A
CheckpointConverter+register_model("foo", ...)in the package__init__— or anoasr.modelsentry point, out of tree. - Build it from
oasr.layersand add a tiny config totests/models/test_layer_waist.py.