te.extern Pattern for C7x Kernel Dispatch
How TVM maps Relax ops to optimized C7x kernels using te.extern and
tir.call_extern. Covers every dispatch site, the full compilation
chain, and the DMA injection layer.
The Core Pattern
Every C7x kernel dispatch follows the same four-step chain:
Relax op(s)
→ FuseOpsByPattern (composite)
→ PyExprMutator → builder_.call_te(te_fn, ...)
→ te.extern → tir.call_extern
→ CodeGenCStaticLib → C function call
te.extern replaces what would otherwise be a loop nest with a single
fcompute callback that emits one tir.call_extern. TVM is responsible
for tiling, DMA transfers, and double buffering via the
InjectMMALIBDMA and ScheduleC7xDMATiling passes; the external kernel
handles the inner compute (MMA coprocessor programming) for each tile.
te.extern(
output_shape,
[input_tensors],
lambda ins, outs: tir.call_extern("int32", "kernel_name", *args),
name="...",
dtype="...",
)
All te.extern Dispatch Sites
| File | Line | Extern name | Inputs | Output dtype |
|---|---|---|---|---|
ti_mmalib_legalize.py |
~123 | mmalib_matmul_i16 |
[a, b] |
int16 |
ti_mmalib_legalize.py |
~386 | mmalib_conv2d_i16 |
[data, weight] |
int16 |
ti_mmalib_qdq_fusion.py |
~582 | mmalib_conv2d_i8 |
[data, weight, bias, scale, shift] |
int8 |
ti_mmalib_qdq_dwconv.py |
~523 | mmalib_depthwise_conv2d_i8 |
[data, weight, bias, scale, shift] |
int8 |
ti_mmalib_qdq_fc.py |
~425 | mmalib_matmul_bias_i8 |
[data, weight, bias, scale, shift] |
int8 |
ti_mmalib_i16_fc.py |
~251 | mmalib_matmul_bias_i16 |
[data, weight, bias, scale, shift] |
int16 |
ti_residual_add.py |
~459 | c7x_int8_residual_add_relu |
[x, skip, params] |
int8 |
fuse_dequantize_matmul.py |
~343 | c7x_dequantize_vecmatmul |
[act, w, scale] |
float32 |
ti_fuse_sdpa_decode.py |
~248 | c7x_sdpa_decode |
[q, k, v, mask] |
float32 |
All files are under python/tvm/relax/transform/.
Full Compilation Chain
Relax IR
↓ FuseMMALIBQDQFC / FuseMMALIBQDQConv2d / ...
FuseOpsByPattern marks composite: "mmalib.fc_i8_qdq_bias"
↓ _MMALIBQDQFCLowerer (PyExprMutator)
- Extracts constants: weight_int8, bias_float, scales, zero_points
- Folds quantization math at compile time → bias_i32, scale_u8, shift_u8
- Calls builder_.call_te(te_mmalib_fc_i8, data, weight, bias, ...)
↓ te.extern → ExternOp → TIR PrimFunc
T.evaluate(tir.call_extern("int32", "mmalib_matmul_bias_i8", ...))
↓ InjectMMALIBDMA (TIR pass)
Wraps call_extern with L2 Allocate + DMA copy/wait if input fits
↓ LowerL2SramAlloc
Allocate(scope="global.l2sram") → tvm_l2_alloc(nbytes)
↓ LowerDMAToExtern
tir.dma_copy / tir.dma_wait → tir.call_extern("tvm_dsp_dma_copy", ...)
↓ CodeGenCStaticLib
call_extern → C: mmalib_matmul_bias_i8(data->data, weight->data, ...)
↓ cl7x + lnk7x --dynamic=lib
Resolves symbol from firmware DLOAD export table
Deep Dive: FC Lowering (ti_mmalib_qdq_fc.py)
Matched Patterns
Four DFPatterns are registered, matched in order (longest/most-specific first):
| Composite name | Pattern |
|---|---|
mmalib.fc_i8_qdq_reshape_bias |
dequant → reshape → matmul → reshape → add → quantize |
mmalib.fc_i8_qdq_reshape |
dequant → reshape → matmul → reshape → quantize |
mmalib.fc_i8_qdq_bias |
dequant → matmul → add → quantize |
mmalib.fc_i8_qdq |
dequant → matmul → quantize |
The reshape variants handle 3D inputs from aten.linear which
decomposes as reshape + matmul + reshape before quantize.
Eligibility Check (_check_mmalib_qdq_fc)
A pattern match is rejected if any of these fail:
w_zpis not a zero constant (requires zero-point-free weights)- weight dtype is not
int8 - data dtype is not
int8or is a constant (weights-only not supported here) - weight shape is not 2D
[N_out, K] K % 64 != 0orN_out % 64 != 0(MMA tile alignment)permute_dimsis not a standard last-two-dim transpose- bias (if present) is not a compile-time constant
Compile-Time Parameter Folding
The lowerer extracts all constants and folds the quantization math before
emitting te.extern. Nothing is computed at runtime.
# Zero-point correction absorbed into bias
weight_sum = w_int8_np.astype(np.int32).sum(axis=1) # [N_out]
zp_correction = -d_zp_val * weight_sum # [N_out]
# Bias converted to accumulator integer scale
dw_scale = d_scale_val * w_scale_np # per-channel
bias_accum = round(bias_float / dw_scale) # [N_out] int32
bias_i32 = bias_accum + zp_correction # [N_out] int32
# Output zero-point absorbed into bias too
if o_zp_val != 0:
bias_i32 += round(o_zp_val / (dw_scale / o_scale_val))
# Requantization represented as uint8 fixed-point multiply-shift
combined_rescale = d_scale_val * w_scale_np / o_scale_val
scale_u8, shift_u8 = _float_to_scale_shift(combined_rescale)
# → kernel does: out = clip((acc * scale_u8) >> shift_u8, -128, 127)
te.extern Emission
def te_mmalib_fc_i8(data_t, weight_t, bias_t, scale_t, shift_t):
def fcompute(ins, outs):
return tir.call_extern(
"int32",
"mmalib_matmul_bias_i8",
ins[0].data, # int8 activations [M, K]
ins[1].data, # int8 weights [N_out, K]
ins[2].data, # int32 bias [N_out]
ins[3].data, # uint8 scale [N_out]
ins[4].data, # uint8 shift [N_out]
outs[0].data, # int8 output [..., N_out]
M, K, N_out,
)
return te.extern(
data_shape[:-1] + [N_out],
[data_t, weight_t, bias_t, scale_t, shift_t],
fcompute,
name="mmalib_fc", # TIR PrimFunc name (different from extern!)
dtype="int8",
)
Weight is passed in natural [N_out, K] layout. The wrapper accepts this
directly without a reorder step (unlike depthwise conv which reorders at runtime).
DMA Injection for FC Layers
InjectMMALIBDMA (ti_mmalib_inject_dma.py) explicitly supports
mmalib_matmul_bias_i8 in its _SUPPORTED set.
Dimension Mapping
FC has no spatial dimensions, so _extract_dims_fc_i8 maps
the flat [M, K, N] args into the shared conv-shaped slot names:
# call_extern args: (name, input, weights, bias, scale, shift, output, M, K, N)
{
"input_arg_idx": 1, # activations pointer
"weight_arg_idx": 2, # weight pointer
"c_in": K, # "channels in" = K
"h_in": M, # "height" = M (batch)
"w_in": 1,
"c_out": N,
"kh": 1, "kw": 1,
}
Buffer sizes:
input_bytes = K * M bytes (activations)
weight_bytes = N * K bytes (weights)
When DMA Fires
The pass skips DMA entirely if input_bytes > l2_budget (default 384 KB):
| Scenario | M | K | N | input KB | weight KB | DMA fires? |
|---|---|---|---|---|---|---|
| Decode (single token) | 1 | 256 | 512 | 0.25 | 128 | Yes — both |
| Prefill seq=64 | 64 | 256 | 512 | 16 | 128 | Yes — both |
| Prefill seq=512 | 512 | 256 | 512 | 128 | 128 | Yes — both |
| Prefill seq=2048 | 2048 | 256 | 512 | 512 | 128 | No (>384 KB) |
Weights are only cached if input_bytes + weight_bytes <= l2_budget.
Injected TIR Structure
When DMA fires, the single call_extern is wrapped:
Allocate l2_guard[int8, guard_bytes] # SE backward-prefetch guard
Allocate l2_input[int8, input_bytes]
Allocate l2_weight[int8, weight_bytes] # omitted if weights don't fit
Evaluate(tvm_dsp_dma_copy(0, l2_input, ddr_input, input_bytes, 0))
Evaluate(tvm_dsp_dma_copy(0, l2_weight, ddr_weight, weight_bytes, 0))
Evaluate(tvm_dsp_dma_wait(0, 0)) # block until complete
Evaluate(mmalib_matmul_bias_i8(l2_input, l2_weight, bias, scale, shift, ...))
The guard allocation (pad_top * w_in * elem_bytes, min 128 bytes)
bumps the L2 bump pointer so the input buffer does not start at address 0
of L2, preventing streaming-engine backward-prefetch underflow.
LowerL2SramAlloc later converts each Allocate(scope="global.l2sram")
to a tvm_l2_alloc(nbytes) call.
TIR Pass Order (c_static -mcpu=c7x -mmalib=1)
FuseMMALIBQDQFC / FuseMMALIBQDQConv2d / FuseMMALIBQDQDwConv2d
FuseInt8ResidualAdd
FuseQDQToInt8Conv2D
EliminateQDQRoundTrip
FuseDequantizeMatmul
LegalizeOps
FuseOps → FuseTIR
InjectMMALIBDMA ← wraps call_extern with L2 DMA
ScheduleC7xDMATiling
InjectSoftwarePipeline
LowerOpaqueBlock
FlattenBuffer
LowerAsyncDMA
LowerDMAToExtern ← tir.dma_copy/wait → call_extern
NarrowDataType(32)
StorageRewrite
LowerL2SramAlloc ← Allocate(l2sram) → tvm_l2_alloc
MakePackedAPI
CodeGenCStaticLib
MMALIB fusion passes run before standard QDQ passes to capture the full
dequantize → op → quantize graph before FuseQDQToInt8Conv2D
collapses it.
Key Implementation Details
Shared Helpers (ti_mmalib_legalize.py)
_float_to_scale_shift(val)— findss ∈ [0,255],sh ∈ [0,31]such thats × 2^(-sh) ≈ val. Used for per-channel requantization._resolve_constant_tensor(expr)— unwrapsreshape/expand_dims/squeeze/astypechains around constants so bias/scale/zp are recognized even when PT2E wraps them.
Two-Names Convention
Every lowering pass uses two distinct names:
- extern_name: The literal C symbol (e.g., "mmalib_conv2d_i8") — must match
firmware DLOAD export table exactly.
- name_hint: The te.extern name= (e.g., "mmalib_conv2d") — only names
the generated TIR PrimFunc and loop variable.
Never reuse extern_name for name_hint — it creates a local variable
that shadows the function declaration and breaks the build.
Constant Folding with bind_constants
Per fuse_ops.cc (lift_constants = !bind_constants), bind_constants=False
lifts matched relax.Constant leaves out to become parameters of the
composite function, passed in as call-site arguments — the composite body
itself normally holds no relax.Constant. bind_constants=True (default)
leaves them bound inside the composite body instead.
Both the C7x activation passes (FuseQDQToC7xActivation, FuseQDQToC7xAvgPool,
FuseQDQToC7xLayerNorm) and the MMALIB QDQ passes use bind_constants=False,
then recover the constant values from the composite call's arguments via
_resolve_constant_tensor (dereferencing through PyExprMutator.lookup_binding
as needed) for compile-time folding — nothing here uses the bind_constants=True
default.
Related Files
| File | Purpose |
|---|---|
python/tvm/relax/transform/ti_mmalib_qdq_fusion.py |
Int8 conv2d lowering |
python/tvm/relax/transform/ti_mmalib_qdq_dwconv.py |
Int8 depthwise lowering |
python/tvm/relax/transform/ti_mmalib_qdq_fc.py |
Int8 + int16 FC lowering |
python/tvm/relax/transform/ti_mmalib_qdq_i16_conv.py |
Int16 conv2d lowering |
python/tvm/relax/transform/ti_mmalib_qdq_i16_dwconv.py |
Int16 depthwise lowering |
python/tvm/relax/transform/ti_mmalib_i16_fc.py |
Weight-only LLM int16 FC lowering |
python/tvm/relax/transform/ti_mmalib_legalize.py |
Shared helpers: _float_to_scale_shift, _resolve_constant_tensor |
python/tvm/relax/transform/ti_mmalib_inject_dma.py |
L2 DMA prefetch (TIR pass) |
python/tvm/relax/transform/ti_residual_add.py |
Residual add lowering |
python/tvm/tir/pipeline.py |
TIR pipeline wiring |
src/target/source/codegen_c.cc |
Base PrintCallExtern — turns call_extern into C call |
src/target/c_static_lib/codegen_c_static_lib.cc |
CodeGenCStaticLib::PrintCallExtern override, delegates to the base |