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:
aten.div/aten.rsubdtype bug (base_fx_graph_translator.py): dividing an int64 tensor by a Python int built the divisor constant with a hardcoded int32 default, trippingfloor_divide's dtype-match check. Fixed to match the tensor operand's dtype, mirroring the existing_binary_oppattern used byadd/mul/subin the same file.aten.index.Tensorndim 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 derivendimfrom the tensor itself.C7xMMAQuantizerover-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. Aflattensitting between the detection head's score gather and itstopkselection was getting quantized with a scale calibrated for the box coordinates it shared a tensor with — crushing every class score toward zero and makingtopk's selection degenerate to picking by tie-break order instead of by actual score. Fixed with a_feeds_topkreachability check that keeps any region feeding atopkin 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 (258both sides) despite exceedingmax_diff=25. Checked via top-1 classification match instead (see_TOP1_MATCH_ONLYintest_quantized_torchvision.py) — the same bar the non-MMALIB path already uses — rather than looseningmax_difffor every model in the sweep.squeezenet1_0: not benign. The MMALIB path picks the wrong class outright (157instead of the correct258; top-5 barely overlaps). Excluded (_EXCLUDED_MISCLASSIFYintest_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_staticbackend (TVM_HOMEset,PYTHONPATHincludespython/) TI_CGT_C7000_PATHfor DSP tests- For
c7x_dload: firmware deployed on the target board (deploy-c7x.sh --board <j722s-evm|beagley-ai>), and the matchingpytest --board <j722s-evm|beagley-ai>-- both required, no default --mmalibfixture/flag (fromconftest.py) selects the MMALIB target; omitting it runs the generic (non-MMALIB) int8 codegen path instead