Skip to content

Quantized Model Sweep

End-to-end tests for INT8-quantized TorchVision and YOLO models (PT2E C7xMMAQuantizer) on the TVM c_static backend, with and without MMALIB offload, on C7x host emulation and real hardware (AM67A and BeagleY-AI). Located at tests/ti-dsp-runtime/quantized/.

Running

cd tests/ti-dsp-runtime
export TI_CGT_C7000_PATH=/opt/ti/c7x/ti-cgt-c7000_5.0.1.LTS

# One model, host emulation
pytest --rootdir=. quantized/test_quantized_resnet.py -v --dsp-mode=c7x_host --mmalib

# One model, AM67A hardware
pytest --rootdir=. quantized/test_quantized_resnet.py -v --dsp-mode=c7x_dload --board j722s-evm --mmalib

# One model, BeagleY-AI hardware
pytest --rootdir=. quantized/test_quantized_yolo.py \
    -v --dsp-mode=c7x_dload --board beagley-ai --mmalib -k yolo26n

# Full TorchVision classification sweep, one model
pytest --rootdir=. "quantized/test_quantized_torchvision.py::test_quantized_torchvision_dsp[resnet50]" \
    -v --dsp-mode=c7x_dload --board j722s-evm --mmalib

# Standalone script
python quantized/test_quantized_resnet.py --dsp-mode c7x_host --mmalib

c7x_dload tests talk to real DSP hardware and require --board <j722s-evm|beagley-ai> (no default -- omitting it is an error, since the codegen target and the SSH deploy host both depend on it): run them one at a time, in the foreground, never in the background or concurrently (single DSP core; conflicts hang the firmware and require a board reboot/power cycle). BeagleY-AI's firmware has no TIDL kernels linked, so its c_static target string needs -tidl-kernels=0; get_target_string() in dsp-cpp/dsp_utils.py adds this automatically whenever --board beagley-ai is passed.

Test files

File Model(s) Status
test_quantized_resnet.py ResNet-18 PASS
test_quantized_resnext101.py ResNeXt-101 (32x8d) PASS
test_quantized_googlenet.py GoogLeNet PASS
test_quantized_inception_v3.py InceptionV3 PASS
test_quantized_mobilenet_v2.py MobileNetV2 PASS
test_quantized_mobilenet_v3.py MobileNetV3-Large PASS
test_quantized_shufflenet_v2.py ShuffleNetV2 (x0.5) PASS
test_quantized_yolo.py YOLOv5n/s, YOLOv8n/s, YOLO26n (object detection) PASS, all 5 (see below)
test_quantized_torchvision.py All 80 TorchVision ImageNet classifiers, via cl_torchvision.py's dynamic loader see sweep below

The first 7 use model_utils.py's per-model create_quantized_*_model functions (hardcoded torchvision import, synthetic random input, PT2E via _pt2e_quantize). test_quantized_torchvision.py instead cross-imports tests/cstatic/cl_torchvision.py (model loading + correct per-model preprocessing) and pt2e-tests/pt2e_utils.py (e2e_quantize_and_import / run_and_check) directly, so it covers whatever TorchVision model cl_torchvision.py can load without needing a dedicated function per model.

test_quantized_yolo.py status

YOLOv5n/s and YOLOv8n/s return a raw per-anchor detection tensor ([1, 4+nc, num_anchors], no NMS/top-k selection applied) — mixing small-magnitude box regression with bounded-range class scores, so pass/fail uses cosine similarity against the PyTorch fake-quantized reference rather than an element-wise tolerance (see run_and_check's tolerance discussion under "Shared infrastructure" below for why whole models need a different bar than single-op unit tests).

YOLO26n instead runs its actual production inference path: the NMS-free "one2one" detection head, which does an internal top-k selection over class scores and returns already-decided [1, 300, 6] detections (x1, y1, x2, y2, confidence, class_idx). Getting there required three fixes, none of them YOLO26-specific — they'd block any model whose graph does a topk/advanced-indexing-heavy postprocess:

  1. aten.div/aten.rsub dtype bug (base_fx_graph_translator.py): dividing an int64 tensor by a Python int built the divisor constant with a hardcoded int32 default, tripping floor_divide's dtype-match check. Fixed to match the tensor operand's dtype, mirroring the existing _binary_op pattern used by add/mul/sub in the same file.
  2. aten.index.Tensor ndim bug (same file): assumed the index list always covers every tensor dimension, but PyTorch allows a shorter list with trailing dimensions implicitly left untouched. Fixed to derive ndim from the tensor itself.
  3. C7xMMAQuantizer over-annotation (c7x_mma_quantizer.py): its quantization-transparent-op annotation (_TRANSPARENT_OPS) is applied purely by op type, with no check for what consumes the result. A flatten sitting between the detection head's score gather and its topk selection was getting quantized with a scale calibrated for the box coordinates it shared a tensor with — crushing every class score toward zero and making topk's selection degenerate to picking by tie-break order instead of by actual score. Fixed with a _feeds_topk reachability check that keeps any region feeding a topk in float, regardless of which transparent/structural ops sit in between.

Beyond the frontend, relax.topk itself had no DSP-compilable implementation at all: its only lowering is a runtime packed-function call (tvm.contrib.sort.topk), which c_static's standalone-C executables have no way to satisfy. A hand-written kernel (src/runtime/ti_dsp/kernels/c7x_topk.cpp, wired in via call_extern the same way MMALIB conv2d is) closes this gap for every C7x target, not just MMALIB builds.

Because YOLO26n's output is a set of already-selected detections rather than a raw tensor, comparing it against the reference is a different problem than for v5/v8: a near-tied class score can legitimately cause the DSP (real kernel execution) and the PyTorch reference (fake-quant simulation of the same graph) to pick a different anchor at the selection boundary — both correct, but producing a totally different row at that index. test_quantized_yolo.py handles this with greedy IoU+class matching (_match_fraction) instead of cosine similarity for this model: each DSP detection is matched to its best same-class, highest-IoU counterpart in the reference set, and the pass bar is a fraction of detections matched above an IoU threshold.

All 5 models pass on c7x_host and on BeagleY-AI hardware (via --board beagley-ai); YOLOv5n/s and YOLOv8n/s were previously verified on AM67A hardware as well, though not re-run there since the MMALIB fix described next. BeagleY-AI's firmware links no TIDL kernels, and bringing up this test on that board independently surfaced a second, model-agnostic bug in the MMALIB QDQ fusion passes: conv bias was resolved via a bare isinstance(x, relax.Constant) check, but PT2E represents conv bias as reshape(Constant), so every MMALIB-offloaded conv across every model in this suite was silently running with zero bias. Fixed with a shared constant-resolution helper in ti_mmalib_legalize.py that unwraps reshape/expand_dims/squeeze/astype down to the constant leaf — this improved accuracy for every existing MMALIB model, not just YOLO.

test_quantized_torchvision.py sweep status

80 candidate TorchVision classification models. 13 are excluded outright (never run): 4 for weight size, 7 for runtime DDR pool exhaustion, 1 for a TVM pass bug hit after quantization, 1 for a genuine MMALIB misclassification (all below). Of the remaining 67, run via c7x_dload --mmalib on real AM67A hardware: all pass — 66 via the elementwise max_diff<=25 bound, 1 (squeezenet1_1) via a top-1 classification match instead (see below).

66 PASS (max_diff <= 25)

alexnet, convnext_base, convnext_tiny, convnext_small, densenet121/161/169/201, efficientnet_b0/b1/b2/b3/b4/b5, efficientnet_v2_s/v2_m/v2_l, googlenet, inception_v3, mnasnet0_5/0_75/1_0/1_3, mobilenet_v2, mobilenet_v3_large/small, all 7 regnet_x_* sizes, all 7 regnet_y_* sizes, resnet18/34/50/101/152, resnext50_32x4d/101_32x8d/101_64x4d, all 4 shufflenet_v2_* sizes, swin_s, swin_t, all 8 vgg variants (11/13/16/19, with and without _bn), vit_b_16, vit_b_32, wide_resnet50_2/101_2.

mnasnet1_0 regressed after e992d3e5b7, was briefly excluded pending root-cause, and is back here after the actual fix — see "mnasnet1_0" below, a separate issue from SqueezeNet's.

Native cl7x cross-compilation for swin_s/swin_t/vit_b_16/vit_b_32 is slow — up to ~12 minutes for vit_b_16, dominated by cl7x's cg7x code generator pegged at 99%+ CPU. This is genuine compute (confirmed via pstree/CPU%, not a hang) — give these enough timeout headroom (15-20 min) rather than treating "no output for N minutes" as a hang.

SqueezeNet — MMALIB accumulation bug, not observer noise

Both SqueezeNet variants fail the elementwise max_diff<=25 bound (squeezenet1_0: 30, squeezenet1_1: 27) on host and hardware, while the identical Q/DQ graph through the generic (non-MMALIB) int8 codegen path gives max_diff=7 — so the divergence is MMALIB-specific, not a quantization-graph issue. The error compounds geometrically with Fire-module depth, and SqueezeNet is the only model in this 66-PASS set with no BatchNorm anywhere to renormalize and reset that per-layer error every other model gets for free. The actual (accum * scale) >> shift requantization runs inside TI's closed-source MMALIB_CNN_convolveBias_row (called from src/runtime/ti_dsp/mmalib/mmalib_wrappers.cpp), which exposes no rounding-mode field — a truncating shift would produce exactly this signature (a small systematic per-layer bias compounding unboundedly without BN to reset it). Not fixable in our own code — vendor kernel internals.

Resolution, since the two variants behave differently in practice:

  • squeezenet1_1: the divergence is benign on the standard test image — top-1 prediction is still correct (258 both sides) despite exceeding max_diff=25. Checked via top-1 classification match instead (see _TOP1_MATCH_ONLY in test_quantized_torchvision.py) — the same bar the non-MMALIB path already uses — rather than loosening max_diff for every model in the sweep.
  • squeezenet1_0: not benign. The MMALIB path picks the wrong class outright (157 instead of the correct 258; top-5 barely overlaps). Excluded (_EXCLUDED_MISCLASSIFY in test_quantized_torchvision.py) rather than loosening its tolerance, which would hide a genuine misclassification.

mnasnet1_0 — fixed: mmalib_conv2d_i8 silently no-op'd for C_out > 1024

A pre-existing latent bug in the regular (non-depthwise) MMALIB conv2d path, unrelated to depthwise conv: conv2d_impl in mmalib_wrappers.cpp (backing mmalib_conv2d_i8, used by ti_mmalib_qdq_fusion.py for every groups==1 MMALIB conv2d) started with a stale guard, if (C_out > 1024) { return -1; }, left over from when default bias/scale/shift used fixed-[1024]-sized stack buffers (see c577e1a894) — those became dynamically-sized (TVMBackendAllocWorkspace) later, but the guard was never removed. The TE/TIR call site never checks this extern's return value, so for C_out > 1024 the kernel returned immediately without writing output, silently leaving it as whatever was already in that memory (all-zero in practice). mnasnet1_0's final feature-expansion conv (a common MobileNet-family pattern: 1x1 conv from 320->1280 channels right before global average pooling) has C_out=1280, so its MMALIB path always computed nothing — misclassifying (428 instead of the correct 258, top-5 disjoint from the reference's).

Fix: conv2d_impl now tiles C_out for the stride==1 case too, the same way it already did for stride>1. The stale guard is removed entirely. Regression coverage: test_mmalib_conv2d_cout_boundary_dsp.py, parametrized at C_out in {512, 1024, 1280, 2048} — the last two exercise the new tiling path; all pass at max_diff<=1 (before the fix, 1280 and 2048 came back with 100% zero output). mnasnet1_0 end-to-end: max_diff 29->2, top-1 258 (correct) restored.

Given mnasnet-family/MobileNet-family models commonly expand to 1280+ channels right before their classifier head, worth checking whether this also explains any other excluded/borderline model in this sweep.

1 EXCLUDED — quantizer bug fixed; now blocked by a separate TVM pass bug

maxvit_t originally failed during quantization itself (AssertionError: Expecting input to have dtype torch.float32, but got dtype: torch.int64), before TVM was even invoked. C7xMMAQuantizer's annotation logic matched view.default/permute.default/ flatten.using_ints/add.Tensor/mm.default purely by op target, with no check that the operand was actually a floating-point tensor — relative_position_index (an int64 lookup-table buffer for windowed attention, reshaped via .view(-1)) got annotated for quantization like any other activation, and convert_pt2e crashed inserting quantize_per_tensor on it.

Fixed in python/tvm/relax/frontend/torch/c7x_mma_quantizer.py: added _is_float_tensor() (checks the FX node's traced meta["val"] dtype) and gated the _TRANSPARENT_OPS/_TIDL_ACT_OPS/_AVG_POOL_OPS/_NORM_OPS/ mm+add branches on it. No regressions — all 27 test_c7x_mma_quantizer* tests and the full pt2e-tests/ quick suite (56 tests) still pass on c7x_host.

With that fixed, maxvit_t now gets past quantization but fails later, inside TVM's own pipeline:

tvm.error.InternalError: Check failed: (opt) is false: The struct info
of Tuple must be TupleStructInfo, but expression lv7 has struct info
R.Tensor((1, 64, 56, 56), dtype="int8")

in python/tvm/relax/transform/ti_eliminate_qdq_transparent.py. Not root-caused yet — excluded (_EXCLUDED_QUANT_BUG in test_quantized_torchvision.py) rather than chased further for now.

4 EXCLUDED — exceed the 256 MiB AM67A DLOAD DDR heap (weight size alone)

regnet_y_128gf, vit_h_14, vit_l_32, vit_l_16 — int8 weight size alone (615 MB, 603 MB, 292 MB, 290 MB respectively) exceeds DDR_C7X_1_LOCAL_HEAP's 256 MiB, before any runtime/workspace overhead. See _EXCLUDED_WEIGHT_SIZE in test_quantized_torchvision.py.

Note: a "DDR watch list" of 5 borderline models (120-190 MB int8 weight — convnext_large, regnet_y_32gf, the vgg family, wide_resnet101_2) was flagged in planning as needing real link-time verification rather than an estimate. All 5 fit and link fine — the int8-weight-size heuristic was overly conservative there. (convnext_large is excluded for a different reason below, unrelated to weight size.)

7 EXCLUDED — NOT SUPPORTED, runtime DDR pool exhaustion

convnext_large, efficientnet_b6, efficientnet_b7, swin_b, swin_v2_b, swin_v2_s, swin_v2_t. Spans both CNN and transformer architectures — not one family's problem. See _EXCLUDED_DDR_OOM in test_quantized_torchvision.py.

All 7 fail identically: c7x: INFER failed: status=-11 return_value=-1 / {"status":"error","stage":"infer","error":"Function call failed"}. This looks segfault-like but isn't — status=-11 is the generic code cg_main_dsp returns whenever any kernel call inside it returns nonzero, discarding the real error. The real error, visible with -profile-layers (see TVMPrintLayerProfile/compute_service.c), is always the same: genuine exhaustion of the DSP's 352 MiB unified DDR pool (DDR_C7X_1_LOCAL_HEAP — weights + DLOAD code/data segments + runtime workspace tensors, one shared pool), hit at a late layer with the pool >99% full:

Model Shortfall
convnext_large requested 602,112 B, free 599,296 B — short by 2,816 B
efficientnet_b6 requested 5,227,200 B, free 2,353,024 B — short by 2,874,176 B (~2.74 MB)
efficientnet_b7 requested 25,920,000 B, free 25,875,328 B — short by 44,672 B (~43.6 KB)
swin_b requested 401,408 B, free 38,016 B — short by ~355 KB
swin_v2_b requested 4,194,304 B, free 3,069,344 B — short by ~1.07 MB
swin_v2_s requested 1,572,864 B, free 1,516,944 B — short by ~54.6 KB
swin_v2_t requested 1,572,864 B, free 1,187,216 B — short by ~376.6 KB

Each is the widest/deepest/largest-input variant in its family (convnext_large vs. convnext_base; efficientnet_b6/b7 vs. b5; swin_b/swin_v2_* vs. the passing swin_s/swin_t) — peak DDR usage lands just past the pool's budget. Confirmed via the bump+free-list allocator in platform/common/memory_pool.c: not a leak (LIFO free list, correct num_allocs/num_frees bookkeeping, auto-reset when the pool fully drains) — genuine peak-usage-over-budget for these 7 models specifically.

Not fixed — documented as unsupported instead. Closing the largest gap (swin_v2_b's ~1.07 MB) would need a further heap extension beyond the current 352 MiB; there's no more room without either (a) freeing an MMU region slot (the C7x's ARMv8 MMU config hard-caps at 16 region descriptors — maxInstances: 16 in the SDK's mmu_armv8.syscfg.js — and all 16 are already used) or (b) re-deriving a matching fix to tvm_dsp_dma.c's virt_to_phys() hardcoded bounds (needed the last time this heap was extended). Given the real hardware risk of another MMU/heap change (a bad region descriptor can hang the DSP, requiring board reboot/power-cycle), these 7 are excluded rather than chased further for now.

Native cl7x compilation for swin_v2_t/s/b (all 3 excluded above) is also slow, same as the passing swin_s/swin_t/vit_b_* noted earlier — but that's incidental; the actual reason they're excluded is the DDR OOM above, not compile time.

swin_v2's TVM-side segfault fix (unrelated to the DDR OOM above; already fixed in this codebase) `swin_v2_t/s/b` crash ~40s into `relax.build`, well before cl7x, with `Fatal Python error: Segmentation fault`, unless the fix below is present. Root cause: `fold_constant.cc`'s `ConstantFolder::ConstEvaluateCallTIR` tries to eagerly evaluate a `call_tir` node via a host `"llvm"` JIT whenever all its args happen to be constants. Build succeeds (LLVM ORC JIT resolves symbols lazily, so no exception at build time), but the later `CallPacked` fails with `JIT session error: Symbols not found: [ c7x_dequantize_vecmatmul ]` — a segfault instead of a catchable exception, because `c7x_dequantize_vecmatmul` is a real DSP-only kernel with no host symbol. That kernel is emitted by `FuseDequantizeMatmul`'s C7x path (`python/tvm/relax/transform/fuse_dequantize_matmul.py`) for weight-only-quantized `dequantize -> matmul` patterns. swin_v2's continuous-relative-position-bias MLP (`cpb_mlp` in `ShiftedWindowAttentionV2`) is applied to a **fixed coordinate buffer**, not the image — so its matmul's activation operand is itself compile-time-constant, unlike every other matmul in the network. `FuseDequantizeMatmul` intentionally runs before `FoldConstant` (to avoid expanding int8 weights back to float32 in weights.bin), so it can't just check `isinstance(act, relax.Constant)` — at that point the whole cpb_mlp chain is still plain `Var`s. Fixed by `python/tvm/relax/transform/ti_c7x_const_reachability.py` (`ConstReachability`: walks a Var's producer chain back to `relax.Constant` leaves), used to skip the DSP-extern path when the activation is transitively constant, in `fuse_dequantize_matmul.py` (fall back to the existing portable TE path) and `ti_fuse_qdq_c7x_relu.py` (leave the composite un-lowered so `LegalizeOps`/`FoldConstant` handle it safely) — both are needed, since fixing only the matmul surfaces the identical crash one step later on `c7x_int8_relu` (cpb_mlp's ReLU is also constant-fed). This is a *systemic* shape of bug across the whole `FuseQDQToC7x*` pass family (concat, avgpool, layernorm, activation, TIDL maxpool, MMALIB QDQ variants all intercept a QDQ pattern into a DSP `call_extern` before `FoldConstant` runs, none of them check for all-constant inputs) — only the two instances swin_v2 hits are fixed; `ConstReachability` is there to reuse if another model triggers one of the others.

Shared infrastructure

Function Location Purpose
_pt2e_quantize model_utils.py Export → prepare_pt2e → calibrate (random noise or real images) → convert_pt2e; used by the 7 per-model files
load_model_with_preprocessing, load_image, get_all_classification_models tests/cstatic/cl_torchvision.py Dynamic model loading with correct per-model preprocessing; cross-imported by test_quantized_torchvision.py
e2e_quantize_and_import, run_and_check tests/ti-dsp-runtime/pt2e-tests/pt2e_utils.py Full quantize→import pipeline and MMALIB compile+run+assert; cross-imported by test_quantized_torchvision.py

run_and_check's default tolerance (max_diff=2, ±1 LSB) is calibrated for single-op unit tests (see PT2E Quantizer Suite) — whole models compound int8 rounding error across many layers, so test_quantized_torchvision.py passes max_diff=25 explicitly instead of relying on the default.

Prerequisites

  • TVM built with the c_static backend (TVM_HOME set, PYTHONPATH includes python/)
  • TI_CGT_C7000_PATH for DSP tests
  • For c7x_dload: firmware deployed on the target board (deploy-c7x.sh --board <j722s-evm|beagley-ai>), and the matching pytest --board <j722s-evm|beagley-ai> -- both required, no default
  • --mmalib fixture/flag (from conftest.py) selects the MMALIB target; omitting it runs the generic (non-MMALIB) int8 codegen path instead