MMALIB Integration
C wrappers that let TVM's c_static backend offload compute-intensive
Relax ops to the C7x MMA (Matrix Multiply Accelerator) coprocessor on
AM67A (J722S) via TI's MMALIB library, plus the glue that lets the
firmware and codegen link MMALIB without also requiring TIDL. Located
at src/runtime/ti_dsp/mmalib/.
Target string: c_static -mcpu=c7x -mmalib=1
Files
| File | Purpose |
|---|---|
mmalib_wrappers.{h,cpp} |
C wrappers for the 8 MMALIB kernels (conv2d/depthwise-conv2d/matmul/matmul_bias × int8/int16), linked into the C7x firmware and exported via DLOAD |
tidl_maxpool_wrapper.{h,cpp} |
max_pool2d wrapper — TIDL-backed (c7x_int8_max_pool_tidl) when the firmware links TIDL, with a scalar fallback (c7x_int8_max_pool) for --tidl OFF builds (e.g. BeagleY-AI) |
Why MMALIB
A single 64ch 56×56 int8 conv2d layer takes ~45M cycles on the C7x scalar pipeline. The same layer takes ~1.67M cycles via the MMA coprocessor through MMALIB — a 27× speedup — and drops to ~477K cycles when input data is staged into L2 SRAM via DMA before the MMA call (96× speedup).
Compiler-Side: QDQ Offload Pipeline
TVM generates a single C source file; each eligible Relax op is replaced
by a call_extern to an MMALIB wrapper, with quantization scale/shift/
bias folded in at compile time. There are three code paths, distinguished
by quantization scheme and dtype:
Relax IR (R.matmul, R.nn.conv2d)
│
├─ Int8 QDQ path: FuseMMALIBQDQ{Conv2d,DwConv2d,FC} + FuseInt8ResidualAdd
│ (runs BEFORE FuseQDQToInt8Conv2D)
│ → Matches PT2E pattern: dequant(data)→op(_, dequant(w))→[bias]→[relu]→quantize
│ → All quant params folded into integer bias/scale/shift at compile time
│ → TIR: call_extern("mmalib_conv2d_i8", ..., bias_i32, scale_u8, shift_u8)
│
├─ Int16 QDQ path: FuseMMALIBQDQ{Conv2dI16,DwConv2dI16,FCI16} + FuseInt16ResidualAdd
│ (runs AFTER int8 QDQ passes, BEFORE FuseQDQToInt8Conv2D)
│ → Same PT2E pattern structure; d_zp and o_zp must be 0 (symmetric only)
│ → Bias int64 (wider accumulator); same uint8 scale_u8/shift_u8 requant
│ → TIR: call_extern("mmalib_conv2d_i16" / "mmalib_matmul_bias_i16" / ...)
│
├─ Int16 legalize path: LegalizeOps(customize_legalize_map)
│ → Float32 ops with no quantization → call_extern("mmalib_conv2d_i16" / "mmalib_matmul_i16")
│ → Used for weight-only-quantized LLM inference (LegalizeMLPToMMALIBInt16)
│
▼ CodeGenCStatic
Generated C code calling wrapper functions
│
▼ Link against MMALIB
Executable (c7x_host or c7x_dload)
All ops use NCHW (planar channel-first) when MMALIB is enabled — the
pipeline skips ConvertLayoutNHWC when -mmalib=1 is set, since
MMALIB's conv kernel (convolveBias_row) expects each input channel as
a contiguous H×W block, which maps directly to NCHW storage. Layout
conversion happens at network I/O boundaries only.
The QDQ fusion passes must run before FuseQDQToInt8Conv2D and
EliminateQDQRoundTrip — those elimination passes remove intermediate
quantize→dequantize pairs between consecutive quantized layers,
destroying the pattern the MMALIB passes need to match. Running first
means the passes see the intact PT2E graph where every layer boundary
has explicit QDQ nodes. All pass instantiation is centralized in
get_mmalib_qdq_passes() in ti_mmalib_passes.py; pipeline.py calls
this function and is never edited when new MMALIB passes are added.
Entry point: C7xMMAQuantizer
from tvm.relax.frontend.torch import C7xMMAQuantizer, from_exported_program
quantizer = C7xMMAQuantizer(dtype="int8") # or "int16"
prepared = prepare_pt2e(model, quantizer)
# ... calibrate ...
quantized = convert_pt2e(prepared)
ep = torch.export.export(quantized, example_inputs)
mod = from_exported_program(ep, keep_params_as_input=True)
# → mod now contains the QDQ pattern that the MMALIB passes will match
All QDQ fusion passes match this PT2E pattern (4 variants per op: with/without bias × with/without relu):
dequantize(data, d_scale, d_zp)
→ op(float, dequantize(weight, w_scale, w_zp=0))
→ [add(float_bias)] → [relu]
→ quantize(output, o_scale, o_zp)
The entire chain is replaced by a single call_extern with compile-time
integer parameters — scale, shift, and bias are all computed once during
TVM compilation, nothing at inference time.
Supported Operations
| Op | Dtype | Path | Constraints |
|---|---|---|---|
| matmul | int8, int16 | legalize | 2D, dims multiples of 64 (i8) or 32 (i16) |
| conv2d | int16 | legalize | N=1, symmetric stride, dilation=1, groups=1, C_out%32==0 |
| conv2d (QDQ fused) | int8 | QDQ | N=1, symmetric stride, dilation=1, groups=1, C_out%64==0 |
| conv2d (QDQ fused) | int16 | QDQ | N=1, symmetric stride, dilation=1, groups=1, C_out%32==0 |
| depthwise conv2d (QDQ fused) | int8 | QDQ | N=1, groups=C_in, kernel 3x3/5x5/7x7, stride 1-2, dilation=1 |
| depthwise conv2d (QDQ fused) | int16 | QDQ | N=1, groups=C_in, 3×3 only (MMALIB-882), stride 1-2, dilation=1 |
| matmul_bias (FC, QDQ fused) | int8 | QDQ | K%64==0, N%64==0, weight [N,K] transposed internally |
| matmul_bias (FC, QDQ fused) | int16 | QDQ | K%32==0, N%32==0, d_zp/o_zp must be 0, bias int64 |
| residual add (QDQ fused) | int8 | QDQ | Both add(x,skip) and add(skip,x) operand orders; with/without relu |
| residual add (QDQ fused) | int16 | QDQ | Same as int8; d_zp/skip_zp/o_zp must all be 0 (symmetric only) |
Non-eligible ops fall through to the default loop-based legalization.
Firmware: Decoupling MMALIB from TIDL
BeagleY-AI firmware is built --tidl OFF --mmalib ON:
firmware/c7x/dsp/CMakeLists.txthas two independent CMake options,USE_TIDL_RUNTIMEandUSE_TI_MMALIB.--tidl <ON|OFF>and--mmalib <ON|OFF>onbuild.shforward to them.--tidl ONstill forces--mmalib ON(TIDL's own algo lib has unresolvedMMALIB_CNN_*/MMALIB_LINALG_*symbols at link time), but the reverse isn't true:--tidl OFF --mmalib ONlinks MMALIB without TIDL.dyn_loader.cguards TIDL-only symbols (c7x_int8_max_pool_tidl,TIDL_VISION_FXNS) behind#ifdef USE_TIDL_RUNTIMEand MMALIB symbols behind#ifdef USE_TI_MMALIB, so a no-TIDL firmware's export table never references a TIDL symbol that isn't linked.- Codegen:
FuseQDQToTIDLMaxPool— the one pass that unconditionally emitted a TIDL-backed kernel even outside TIDL-offload paths — now reads atidl-kernelstarget attr (defaulttrue, preserving prior behavior). Passing-tidl-kernels=0in thec_statictarget string makes it emitcall_extern("c7x_int8_max_pool", ...)(the scalar fallback intidl_maxpool_wrapper.cpp) instead ofc7x_int8_max_pool_tidl, so a model withmax_pool2dstill links against a no-TIDL firmware. Every BeagleY-AI compile must pass-tidl-kernels=0explicitly — codegen has no way to detect what the firmware actually linked, so without this flagFuseQDQToTIDLMaxPoolemits a call to a symbol that doesn't exist there, which fails only at DLOAD load time on the board, not at compile time.
Key Files (Full Pipeline)
| File | Purpose |
|---|---|
python/tvm/relax/transform/ti_mmalib_passes.py |
Central registry: get_mmalib_qdq_passes(), all MMALIB pass config |
python/tvm/relax/transform/ti_mmalib_qdq_fusion.py |
QDQ conv2d pattern matching + lowering (int8) |
python/tvm/relax/transform/ti_mmalib_qdq_i16_conv.py |
QDQ conv2d pattern matching + lowering (int16) |
python/tvm/relax/transform/ti_mmalib_qdq_dwconv.py |
QDQ depthwise conv2d — int8 check + shared _check_dwconv2d_geometry |
python/tvm/relax/transform/ti_mmalib_qdq_i16_dwconv.py |
QDQ depthwise conv2d pattern matching + lowering (int16) |
python/tvm/relax/transform/ti_mmalib_qdq_fc.py |
QDQ FC/matmul_bias pattern matching + lowering (int8 + int16) |
python/tvm/relax/transform/ti_mmalib_legalize.py |
Float→int16 legalization + _float_to_scale_shift helper |
python/tvm/relax/transform/ti_mmalib_inject_dma.py |
L2 DMA prefetch injection (TIR pass, all MMALIB kernels) |
python/tvm/relax/transform/ti_residual_add.py |
Residual add fusion — int8 + int16, both operand orders |
python/tvm/relax/backend/cpu_generic/pipeline.py |
Pipeline wiring |
src/runtime/ti_dsp/mmalib/mmalib_wrappers.{h,cpp} |
C wrappers (8 entry points) |
src/runtime/ti_dsp/mmalib/tidl_maxpool_wrapper.{h,cpp} |
max_pool2d wrapper (TIDL-backed + scalar fallback) |
src/runtime/ti_dsp/kernels/c7x_residual_add.{cpp,h} |
Fixed-point residual add kernels (int8 + int16) |
src/runtime/ti_dsp/dma/tvm_dsp_dma.c |
EDMA runtime (virt_to_phys for staging buffer) |
src/target/target_kind.cc |
mmalib and tidl-kernels target attributes |
src/runtime/ti_dsp/firmware/c7x/dsp/CMakeLists.txt |
USE_TIDL_RUNTIME / USE_TI_MMALIB build options |
src/runtime/ti_dsp/firmware/c7x/dsp/src/dyn_loader.c |
Exports 8 MMALIB symbols + guards TIDL-only symbols |
tests/ti-dsp-runtime/mmalib-tests/ |
Unit tests (conv2d, dwconv2d, FC, residual add — i8 and i16) |
tests/ti-dsp-runtime/quantized/ |
Full model tests (ResNet, MobileNet, GoogLeNet, ShuffleNet) |
Testing
cd tests/ti-dsp-runtime
# Quick unit tests — all MMALIB kernels, host emulation
pytest --rootdir=. mmalib-tests/ -m quick --dsp-mode=c7x_host -v
# Full unit test suite (includes non-quick tests)
pytest --rootdir=. mmalib-tests/ -v --dsp-mode=c7x_host
# ResNet-18 int8 with MMALIB (host emulation)
pytest --rootdir=. dsp-tests/test_quantized_resnet_dsp.py -v --dsp-mode=c7x_host --mmalib
# ResNet-18 int8 with MMALIB (AM67A hardware)
pytest --rootdir=. dsp-tests/test_quantized_resnet_dsp.py -v \
--dsp-mode=c7x_dload --use-cpp-api --mmalib --profile
# PT2E int8 models (MobileNet V2)
pytest --rootdir=. pt2e-tests/test_mobilenet_v2_pt2e_dsp.py -v \
--dsp-mode=c7x_host --mmalib
# PT2E int8 + int16 single-layer e2e (C7xMMAQuantizer → DSP)
pytest --rootdir=. pt2e-tests/ -m quick --dsp-mode=c7x_host -v
Performance (AM67A C7x @ 1 GHz)
Single conv2d layer (int8, 64ch 56x56, 3x3 kernel, stride=1):
| Path | Cycles | Time | Speedup |
|---|---|---|---|
| MMALIB + L2 DMA prefetch | 477K | 0.48ms | 96x |
| MMALIB (MMA, DDR-resident data) | 1.67M | 1.67ms | 27x |
| TVM loop-based (C7x scalar) | 45.2M | 45.2ms | baseline |
Full model end-to-end (all layers, int8 quantized):
| Model | MMALIB Offloaded Layers | Cycles | Time |
|---|---|---|---|
| ResNet-18 | 20 conv2d, 8 res_add, 1 FC | 100M | 100ms |
| ShuffleNet V2 | 1 conv2d, 19 dwconv2d, 1 FC | 315M | 315ms |
| MobileNet V2 | 19 conv2d, 17 dwconv2d, 10 res_add, 1 FC | 2,386M | 2.4s |
| MobileNet V3 | 6 conv2d, 15 dwconv2d, 1 FC, 10 res_add | 2,369M | 2.4s |
| GoogLeNet | 33 conv2d, 3 res_add, 1 FC | 6,790M | 6.8s |
ResNet-18 baseline (no MMALIB, scalar loops): 4,705M cycles (4.7s) → 47x speedup.
L2 DMA prefetch provides 3.5x speedup per conv2d layer by staging input (and weights when they fit) into L2 SRAM before the MMALIB call — the MMA coprocessor reads from fast L2 scratchpad instead of slow DDR.
Limitations
- Weight quantization must be symmetric (w_zp=0)
- Output-channel tiling for stride>1 adds init/exec overhead per chunk
- Intermediate activation alignment: 128-byte (exceeds MMA's 64-byte requirement)
- L2 DMA requires guard allocation (128 bytes) to prevent SE prefetch page fault
- Weights exceeding L2 budget remain in DDR (512ch layers: 2.3 MB > 1.25 MB L2)
- Depthwise conv2d requires 64-byte aligned output row stride; wrapper allocates a padded buffer and compacts if W_out is not 64-aligned
- MobileNet V2/V3 dominated by non-MMALIB layers (dequantize, hardswish, clip, adaptive_avg_pool) which still run as scalar loops
- INT16 depthwise conv2d: only 3×3 kernels supported (
mmalib_depthwise_conv2d_i16); 5×5 and 7×7 returnMMALIB_ERR_NOT_IMPLEMENTED(tracked as MMALIB-882) - INT16 QDQ activation quantization: always symmetric — d_zp and o_zp must be 0. Asymmetric patterns are rejected by the i16 check functions and fall through to float