NOTE: The Jupyter Notebook below is included in the Chimera SDK and can be run interactively by running the following CLI command:
$ quadric sdk notebook
From the Jupyter Notebook window in your browser, select the notebook named /quadric/sdk-cli/examples/models/asvspoof/asvspoof_lfcc_lcnn.ipynb.
ASVspoof2021 LA Anti-Spoofing on Chimera GPNPU — ChiPy pipeline
This notebook runs the entire ASVspoof2021 LA baseline anti-spoofing pipeline on the Chimera GPNPU as one CGC-compiled program — LFCC feature extraction (windowed DFT + mel + log + DCT + Δ/ΔΔ), an LCNN backbone with the Max-Feature-Map operator, two stateful bidirectional LSTM layers, and the classifier head — composed using ChiPy. Raw waveform in, spoof / bonafide decision out.
What's interesting
This pipeline fuses classical DSP (Hamming-windowed STFT on the int8 MAC array, mel filterbank, log, DCT, delta/delta-delta) with a stateful neural net (9-block LCNN with channel-pair Max-Feature-Map, 2× bidirectional LSTM with int8 weights and Q7 hidden state, skip-add + mean-pool + Linear + sigmoid classifier head) into one Chimera GPNPU program. No host hop between the FFT and the matmul, no CPU fallback for ReduceMean — every stage runs on the same array, scheduled by CGC out of one Relay graph. The BiLSTMs are the ONNX LSTM nodes themselves, lowered to CGC's native contrib.epu.lstm op (CGC quantizes W/R/B and the Q7 hidden state internally). ChiPy lets us declare the pipeline in Python and lower each stage to either a CCL kernel (LFCC frontend, classifier head) or to ONNX-imported relay (the LCNN backbone with its quantized convs and channel-pair-max activations, and the two native LSTMs).
Why ChiPy?
ChiPy turns the whole pipeline into one Python function. The same source runs on CPU (calling each stage's numpy reference, or ORT for the ONNX subgraphs) for testing, and on GPNPU (lowered to CCL kernels and ONNX subgraphs) for deployment::
waveform → asvspoof::lfccExtract (chipy ccl_custom_op)
→ LCNN backbone (Conv/MFM/MaxPool) (chipy.infer_onnx)
→ 2× bidirectional LSTM (chipy.infer_onnx → contrib.epu.lstm)
→ asvspoof::asvspoofClassifierHead (chipy ccl_custom_op)
→ score
Each @chipy.ccl_custom_op decorator pairs a CCL kernel name with a numpy CPU reference; the ONNX subgraphs (LCNN, BiLSTM) run through ORT on CPU and lower to CGC on the GPNPU — so every block is independently callable for unit testing.
Pipeline
Model: ASVspoof2021 LA Baseline-LFCC-LCNN · Input: [1, 63840] float32 (~4 sec @ 16 kHz) · Output: spoof / bonafide score · Source: official baseline repo (PyTorch → ONNX, no retraining)
1. Setup
We start from the float reference ONNX (asvspoof_baseline_sim.onnx) — the published PyTorch baseline exported via torch.onnx.export and run through onnx-simplifier. No retraining: we'll quantize and recompose it into a single Chimera GPNPU program.
from pathlib import Path
import json, logging, shutil, time
import numpy as np
import onnx, onnxruntime as ort
from onnx import numpy_helper
import asvspoof_helpers as h
from tvm.contrib.epu import chipy
from tvm.contrib.epu.chimera_job.hw_config import HWConfig
from tvm.contrib.epu.chimera_job.quantize import quadric_quantize
from tvm.contrib.epu.chimera_job import core as chimera_core
from tvm.relay.backend.contrib.epu import codegen as epu_codegen, util as epu_util
from tvm.relay.backend.contrib.epu.util import logger as tvm_logger
## Pin the epu logger to INFO: chipy's .compile() is called directly here, so
## cli.py's tvm_logger.setLevel(INFO) never runs and the per-pass DEBUG IR
## dumps would otherwise flood output (mirrors yolox_e2e / retina_net_e2e).
tvm_logger.setLevel(logging.INFO)
## ChiPy bug workaround: count_chain_outputs misfires on ONNX-imported
## QuadricCustomOps where n_non_const_inputs == n_outputs and silently strips
## the only real output, leaving an empty TupleType. Patch is conservative.
import tvm.contrib.epu.chipy.chipy as _chipy_mod
_orig = _chipy_mod.count_chain_outputs
_chipy_mod.count_chain_outputs = lambda c: 0 if len(c.attrs.out_type.fields) <= 1 else _orig(c)
%matplotlib inline
ROOT = Path.cwd()
SIM_ONNX = ROOT / "asvspoof_baseline_sim.onnx"
LCNN_FLOAT = ROOT / "asvspoof_lcnn_fp.onnx" # ORT-runnable, used in chipy CPU mode
LCNN_QUANT = ROOT / "asvspoof_lcnn_q.onnx" # quantized (native MFM), GPNPU
LCNN_TRANGES = ROOT / "asvspoof_lcnn_q.onnx.tranges"
LSTM_ONNX = ROOT / "asvspoof_lstm.onnx" # float BiLSTM subgraph → native contrib.epu.lstm
KERNEL_HPP = ROOT / "asvspoof_kernels.hpp"
## LCNN out trange ≈ [-7.87, +43.41] → symmetric int8 with scale 43.41/127.
## The native LSTM quantizes its own X/W/R/B and Q7 hidden state internally.
LCNN_OUT_SCALE = 43.41456985473633 / 127.0
HW = HWConfig(
product="QC-N",
ocm_size="4MB",
lrm_size="4kB",
ext_rd_bw="8GBps",
ext_wr_bw="8GBps",
macs_per_pe=16,
clock_freq_ghz=1.0,
num_cores=1,
num_clusters=1,
)
print(f"reference ONNX: {SIM_ONNX.name} ({SIM_ONNX.stat().st_size/1e6:.2f} MB)")
print(
f"target: {HW.product} @ {HW.clock_freq_ghz} GHz, {HW.ocm_size} OCM, {HW.lrm_size} LRM"
)
reference ONNX: asvspoof_baseline_sim.onnx (2.16 MB)
target: QC-N @ 1.0 GHz, 4MB OCM, 4kB LRM
2. The Reference ONNX
Quick look at the float graph: an LFCC frontend (STFT + filterbank + log + DCT + Δ/ΔΔ → 60-d LFCC), a 9-block LCNN backbone with Slice + Slice + Max MFM2D activations, two LSTM layers with direction="bidirectional", and an Add + ReduceMean + Gemm + Sigmoid head.
sim = onnx.load(SIM_ONNX)
op_counts = {}
for n in sim.graph.node:
op_counts[n.op_type] = op_counts.get(n.op_type, 0) + 1
inputs = [(i.name, [d.dim_value for d in i.type.tensor_type.shape.dim]) for i in sim.graph.input]
outputs = [(o.name, [d.dim_value for d in o.type.tensor_type.shape.dim]) for o in sim.graph.output]
print(f"input: {inputs}")
print(f"output: {outputs}")
print(f"op counts (top 10): {dict(sorted(op_counts.items(), key=lambda kv: -kv[1])[:10])}")
input: [('waveform', [1, 63840])]
output: [('score', [1, 1])]
op counts (top 10): {'Slice': 26, 'Conv': 9, 'Max': 9, 'Transpose': 7, 'BatchNormalization': 6, 'Reshape': 5, 'Add': 5, 'Mul': 4, 'MaxPool': 4, 'Pad': 3}
3. Backbones from one source
ChiPy lowers chipy.infer_onnx(path, ...) differently in each mode:
- CPU mode runs the ONNX through ORT.
- GPNPU mode loads the ONNX as Relay and lets CGC compile each node — including the LCNN's
Slice + Slice + MaxMFM2D activations, which CGC lowers natively into the int8 conv dataflow (the channel-pair max is exact in the shared int8 scale).
So we produce two LCNN backbones from the same source: one float (ORT-runnable) and one quantized (GPNPU-runnable). prepare_lcnn_backbones does the cut and calibration-driven PTQ. prepare_lstm_subgraph then cuts the two bidirectional LSTM layers into a second, float ONNX — CGC's ONNX frontend turns each LSTM node into a native contrib.epu.lstm op and quantizes W/R/B and the Q7 hidden state internally, so those weights need no PTQ. Those two cuts are the only graph surgery in the notebook; the LFCC frontend and classifier head are the @chipy.ccl_custom_op decorators below.
calib_audio = np.load(ROOT / "calib_inputs.npy") # [30, 1, 63840] float32
print(f"calibration audio: shape={calib_audio.shape}, std={calib_audio.std():.4f}")
bb = h.prepare_lcnn_backbones(SIM_ONNX, calib_audio, ROOT)
tranges = {k: ([v] if v and not isinstance(v[0], list) else v) for k, v in bb["tranges"].items()}
## BiLSTM subgraph — the two bidirectional LSTMs cut out as a standalone float
## ONNX. chipy.infer_onnx lowers each ONNX LSTM to a native `contrib.epu.lstm`
## under the EPU target (CGC quantizes W/R/B and the int8 hidden island itself);
## ORT runs the same file in CPU mode. Both LSTM sequence outputs are o*tanh(c),
## bounded to (-1, 1) for any weights, which is the only range the native op needs.
lstm_onnx = h.prepare_lstm_subgraph(SIM_ONNX, ROOT)
lstm_tranges = {edge: [-1.0, 1.0] for edge in h.LSTM_OUT_EDGES}
print(f" CPU backbone: {bb['float'].name} ({bb['float'].stat().st_size/1e6:.2f} MB)")
print(f" GPNPU backbone: {bb['quant'].name} ({bb['quant'].stat().st_size/1e6:.2f} MB)")
print(f" BiLSTM subgraph: {lstm_onnx.name} ({lstm_onnx.stat().st_size/1e6:.2f} MB)")
print(f" trange entries: {len(tranges)}")
calibration audio: shape=(30, 1, 63840), std=0.1474
2026-08-20 14:57 - INFO - epu - quantize - Collecting calibration data
2026-08-20 14:57 - INFO - epu - quantize - Optimized model to opset
2026-08-20 14:57 - INFO - epu - quantize - Converted model to opset 16
2026-08-20 14:57 - INFO - epu - quantize - Saved optimized model to asvspoof_lcnn_fp_float32_opt.onnx
2026-08-20 14:57 - INFO - epu - quantize - Input shapes: [1, 400, 60]. Input names: lfcc_features
2026-08-20 14:57 - INFO - epu - quantize - Output shapes: [[1, 25, 96]]. Output names: ['h_lcnn']
2026-08-20 14:57 - INFO - epu - quantize - applying calibration data to input: lfcc_features
2026-08-20 14:57 - INFO - epu - quantize - calibration set size: 30
2026-08-20 14:57 - INFO - epu - quantize - Running real quantization on this input: lfcc_features with input shape: [1, 400, 60]
2026-08-20 14:57 - INFO - epu - quantize - Quantization started...
WARNING:root:Please use QuantFormat.QDQ for activation type QInt8 and weight type QInt8. Or it will lead to bad performance on x64.
2026-08-20 14:57 - INFO - epu - quantize - Quantization done succesfully!
2026-08-20 14:57 - INFO - epu - quantize - ONNX full precision model size: 0.62 MB
2026-08-20 14:57 - INFO - epu - quantize - ONNX quantized model size: 0.19 MB
2026-08-20 14:57 - INFO - epu - quantize - Saved quantized model to /quadric/sdk-cli/examples/models/asvspoof/asvspoof_lcnn_fp_opt_asym_int8_q.onnx
2026-08-20 14:57 - INFO - epu - quantize - Saved shape inferenced model to /quadric/sdk-cli/examples/models/asvspoof/asvspoof_lcnn_fp_opt_asym_int8_q.onnx
2026-08-20 14:57 - INFO - epu - quantize - Checking for remaining FLOAT/FLOAT16 types.
2026-08-20 14:57 - INFO - epu - quantize - Model still has FLOAT/FLOAT16 types. Creating ranges for floating point tensors using calibration data
2026-08-20 14:57 - INFO - epu - quantize - Saved tensor ranges to /quadric/sdk-cli/examples/models/asvspoof/asvspoof_lcnn_fp_opt_asym_int8_q.onnx.tranges
CPU backbone: asvspoof_lcnn_fp.onnx (0.65 MB)
GPNPU backbone: asvspoof_lcnn_q.onnx (0.20 MB)
BiLSTM subgraph: asvspoof_lstm.onnx (0.45 MB)
trange entries: 49
4. Kernel constants
The CCL kernels need fixed constants embedded in the GPNPU graph: the split-precision int8 STFT bases (with pre-emphasis and the Hamming window folded in, since both are linear — see the kernel), the int8 mel filterbank, the scaled DCT, and the classifier head's dense weight + bias.
The LSTM weights don't appear here: they ride along inside the float BiLSTM ONNX and are quantized by CGC when the LSTM nodes lower to contrib.epu.lstm (W/R → int8 with a power-of-2 symmetric scale, bias → Q16, hidden state → Q7).
_INIT = {i.name: numpy_helper.to_array(i) for i in onnx.load(SIM_ONNX).graph.initializer}
_LFCC = h._build_lfcc_constants(SIM_ONNX)
_DENSE_W = _INIT["model.m_output_act.0.weight"].astype(np.float32).reshape(96, 1)
_DENSE_B = float(_INIT["model.m_output_act.0.bias"])
_ONES_DIV_T = np.ones((1, 25), dtype=np.float32) / 25.0
print(
f"LFCC consts: lfcc_fb {_LFCC['lfcc_fb'].shape} int8, dct_scaled {_LFCC['dct_scaled'].shape},"
)
print(
" 2x split-DFT bases [1, 1, 320, 256] int8 (pre-emph + Hamming window folded in)"
)
print(f"head: dense_w {_DENSE_W.shape}, dense_b {_DENSE_B:+.4f}")
print("LSTM W/R/B: quantized inside CGC by the native contrib.epu.lstm lowering")
LFCC consts: lfcc_fb (256, 21) int8, dct_scaled (21, 19),
2x split-DFT bases [1, 1, 320, 256] int8 (pre-emph + Hamming window folded in)
head: dense_w (96, 1), dense_b -0.0157
LSTM W/R/B: quantized inside CGC by the native contrib.epu.lstm lowering
5. CPU references
Each @chipy.ccl_custom_op decorator below pairs a CCL kernel name with a Python body. ChiPy invokes that body when you call the function with Tensor(values=...), so a CPU run produces ORT-comparable scores end-to-end.
For LFCC we just run the float reference ONNX up to the LFCC edge (the kernel is pure DSP, easier to delegate to ORT than re-implement). For the classifier head it's three lines: skip-add → mean-pool → Linear + sigmoid. The BiLSTMs need no hand-written CPU reference — chipy.infer_onnx runs their float ONNX through ORT in CPU mode, mirroring the GPNPU lowering's semantics.
## LFCC CPU reference — runs the float ONNX up to the LFCC edge.
def _lfcc_cpu_session():
m = onnx.load(SIM_ONNX)
if not any(o.name == h.LFCC_EDGE for o in m.graph.output):
m.graph.output.append(
onnx.helper.make_tensor_value_info(h.LFCC_EDGE, onnx.TensorProto.FLOAT, [1, 400, 60])
)
return ort.InferenceSession(m.SerializeToString(), providers=["CPUExecutionProvider"])
_LFCC_REF = _lfcc_cpu_session()
_LFCC_OUT_IDX = next(i for i, o in enumerate(_LFCC_REF.get_outputs()) if o.name == h.LFCC_EDGE)
print("LFCC CPU ref ready:", _LFCC_REF.get_outputs()[_LFCC_OUT_IDX].name)
LFCC CPU ref ready: /m_frontend.0/Concat_7_output_0
6. The two CCL custom ops
Each @chipy.ccl_custom_op decorator names a CCL kernel and provides a CPU reference. The decorator's positional args are the kernel's runtime inputs (constants flow through chipy.const() further down). The LFCC frontend and the classifier head are the two CCL kernels; the LCNN backbone (including its MFM activations) and the two BiLSTMs lower natively via CGC.
@chipy.ccl_custom_op(
ccl_func_name="asvspoof::lfccExtract",
frac_bits=[16], # output is FixedPoint32<16> per the kernel template
reserved_l2m=0,
reserved_ext=0,
io_in_ext_mem=True,
)
def lfcc_extract(waveform, lfcc_fb, dct_scaled, re_hi, im_hi):
"""LFCC frontend (CPU ref runs the float ONNX up to the LFCC edge)."""
return _LFCC_REF.run(None, {"waveform": waveform})[_LFCC_OUT_IDX].astype(np.float32)
@chipy.ccl_custom_op(
ccl_func_name="asvspoof::asvspoofClassifierHead",
frac_bits=[16],
reserved_l2m=0,
reserved_ext=0,
io_in_ext_mem=True,
)
def classifier_head(h_lstm, h_lcnn, ones_div_T, dense_w, dense_b):
"""Skip-add → mean over T → 96→1 Linear → sigmoid."""
pooled = ones_div_T @ (h_lstm + h_lcnn)[0]
logit = pooled @ dense_w + dense_b
return (1.0 / (1.0 + np.exp(-logit))).astype(np.float32)
chipy.const() bindings
Constants get baked into the IR at compile time. Passing them as chipy.Tensor(values=...) would create runtime inputs whose raw bytes get reinterpreted as the kernel's expected FixedPoint format — int8 DFT bases / LSTM weights end up as garbage. chipy.const() is the right knob for compile-time constants.
_LFCC_FB = chipy.const(_LFCC["lfcc_fb"], dtype=str(_LFCC["lfcc_fb"].dtype))
_DCT = chipy.const(_LFCC["dct_scaled"], dtype="float32")
_RE_HI = chipy.const(_LFCC["stft_re_hi_q"], dtype="int8")
_IM_HI = chipy.const(_LFCC["stft_im_hi_q"], dtype="int8")
_HEAD_ONES = chipy.const(_ONES_DIV_T, dtype="float32")
_HEAD_W = chipy.const(_DENSE_W, dtype="float32")
print("constants bound to chipy.const().")
constants bound to chipy.const().
7. The pipeline
This is the function. Same source for CPU (chipy invokes the custom-op bodies and runs the ONNX subgraphs through ORT) and GPNPU (compile() lowers each custom op to its CCL kernel and each chipy.infer_onnx to ONNX-imported Relay). The BiLSTM infer_onnx gets value_proto_ranges=lstm_tranges so CGC knows each LSTM's (−1, 1) output band; the X range and every other range propagate from the LCNN's PTQ tranges.
@chipy.func()
def asvspoof_pipeline(waveform, backbone, lstm):
"""Raw waveform → spoof / bonafide score, end-to-end on the GPNPU."""
feats = lfcc_extract(waveform, _LFCC_FB, _DCT, _RE_HI, _IM_HI)
h_lcnn = chipy.infer_onnx(backbone, feats)
h_lstm = chipy.infer_onnx(lstm, h_lcnn, value_proto_ranges=lstm_tranges)
return classifier_head(h_lstm, h_lcnn, _HEAD_ONES, _HEAD_W, _DENSE_B)
print("pipeline defined.")
pipeline defined.
8. CPU validation
ChiPy CPU mode runs the function bodies as plain Python. We pass the float LCNN backbone and the float BiLSTM ONNX, plus a Tensor with values; chipy walks the function, invokes each @ccl_custom_op body, and runs each chipy.infer_onnx subgraph through ORT. The result should match the ORT reference (each stage either calls ORT directly or implements the same float math).
audio, filenames, truth_labels = h.load_test_set(
ROOT / "test_inputs.npy", ROOT / "test_labels.txt", n=16
)
print(f"running {len(audio)} samples through chipy CPU mode...")
t0 = time.time()
cpu_scores = np.array(
[
float(
np.asarray(
asvspoof_pipeline(
chipy.Tensor(shape=(1, 63840), dtype="float32", values=x),
str(bb["float"]),
str(lstm_onnx),
).values
).ravel()[0]
)
for x in audio
]
)
print(f" {time.time()-t0:.1f}s ({(time.time()-t0)/len(audio):.1f}s/sample)")
print(f" CPU score range: [{cpu_scores.min():+.4f}, {cpu_scores.max():+.4f}]")
running 16 samples through chipy CPU mode...
0.5s (0.0s/sample)
CPU score range: [+0.0000, +1.0000]
9. Compile on QC-N
asvspoof_pipeline.compile(hw_config=...) lowers each chipy primitive to its target form: @ccl_custom_op calls become CCL kernel invocations, and each chipy.infer_onnx inlines its Relay graph — the quantized LCNN backbone, and the BiLSTM subgraph whose LSTM nodes become native contrib.epu.lstm ops. CGC schedules the unified graph.
output_profile=True + enable_region_profile=True in the pass context capture the cycle/category breakdown for the perf section below.
t0 = time.time()
## Production (chimera_job) optimization flags: conv fusion, DMA prefetching,
## aggressive op sinking, and enable_qlut (DQ->nonlinear->Q -> LUT).
with epu_util.add_epu_passcontext_attributes(
output_profile=True,
enable_region_profile=True,
enhanced_conv_fusion=True,
enable_prefetching=True,
enable_qlut=True,
enable_extra_aggressive_sinking=True,
advanced_directives=True,
check_lrm_overflow=True,
):
compiled = asvspoof_pipeline.compile(
hw_config=HW,
module_name="asvspoof_chipy",
custom_op_header=str(h.build_combined_kernel_header(KERNEL_HPP)),
value_proto_ranges=tranges,
waveform=chipy.Tensor(shape=(1, 63840), dtype="float32"),
backbone=str(bb["quant"]),
lstm=str(lstm_onnx),
)
print(f"compile: {time.time()-t0:.1f}s")
/tmp/ipykernel_62395/2909911470.py:10: RuntimeWarning: overflow encountered in add
pooled = ones_div_T @ (h_lstm + h_lcnn)[0]
2026-08-20 14:57 - INFO - epu - codegen - START==================================build_relay
2026-08-20 14:57 - INFO - epu - codegen - START===============================optimize_relay
2026-08-20 14:57 - INFO - epu - codegen - START====================quantize_to_cpu_runnable_fx
2026-08-20 14:57 - INFO - epu - fx -
Source name Op Output 0 Range Output 0 Frac Bits
----------------------------------------------------------------- ----------------------------- ----------------------------------------- --------------------
contrib.epu.quadric_custom_op (-32768.0, 32767.99998474121) 16
/m_transform.0/m_transform.0.0/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-6.458135336637497, 6.930681824684143) 28
/m_transform.0/m_transform.0.1/Slice strided_slice (-6.458135336637497, 6.930681824684143) 28
/m_transform.0/m_transform.0.1/Slice_1 strided_slice (-6.458135336637497, 6.930681824684143) 28
/m_transform.0/m_transform.0.1/Max maximum (-6.458135336637497, 6.930681824684143) 28
/m_transform.0/m_transform.0.3/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-5.685009986162186, 4.452595233917236) 28
/m_transform.0/m_transform.0.4/Slice strided_slice (-5.685009986162186, 4.452595233917236) 28
/m_transform.0/m_transform.0.4/Slice_1 strided_slice (-5.685009986162186, 4.452595233917236) 28
/m_transform.0/m_transform.0.4/Max maximum (-5.685009986162186, 4.452595233917236) 28
multiply (-48.17888952346016, 37.73451487152124) 25
/m_transform.0/m_transform.0.5/BatchNormalization add (-49.96782685370613, 39.49288467494989) 25
/m_transform.0/m_transform.0.6/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-15.580109417438507, 13.632595740258694) 27
/m_transform.0/m_transform.0.7/Slice strided_slice (-15.580109417438507, 13.632595740258694) 27
/m_transform.0/m_transform.0.7/Slice_1 strided_slice (-15.580109417438507, 13.632595740258694) 27
/m_transform.0/m_transform.0.7/Max maximum (-15.580109417438507, 13.632595740258694) 27
/m_transform.0/m_transform.0.8/MaxPool_output_0_DequantizeLinear contrib.epu.dequantize (-9.40739393234253, 13.65881234407425) 27
multiply (-17.815913465555013, 25.867335897103914) 26
/m_transform.0/m_transform.0.9/BatchNormalization add (-19.66975107484609, 25.23321877063907) 26
/m_transform.0/m_transform.0.10/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-11.179568655788898, 7.9532501846551895) 27
/m_transform.0/m_transform.0.11/Slice strided_slice (-11.179568655788898, 7.9532501846551895) 27
/m_transform.0/m_transform.0.11/Slice_1 strided_slice (-11.179568655788898, 7.9532501846551895) 27
/m_transform.0/m_transform.0.11/Max maximum (-11.179568655788898, 7.9532501846551895) 27
multiply (-32.68992262179723, 23.255918106781923) 25
/m_transform.0/m_transform.0.12/BatchNormalization add (-34.20268600280156, 22.926151952492678) 25
/m_transform.0/m_transform.0.13/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-13.263421520590782, 14.45930378884077) 27
/m_transform.0/m_transform.0.14/Slice strided_slice (-13.263421520590782, 14.45930378884077) 27
/m_transform.0/m_transform.0.14/Slice_1 strided_slice (-13.263421520590782, 14.45930378884077) 27
/m_transform.0/m_transform.0.14/Max maximum (-13.263421520590782, 14.45930378884077) 27
/m_transform.0/m_transform.0.16/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-8.852665983140469, 10.777158588171005) 27
/m_transform.0/m_transform.0.17/Slice strided_slice (-8.852665983140469, 10.777158588171005) 27
/m_transform.0/m_transform.0.17/Slice_1 strided_slice (-8.852665983140469, 10.777158588171005) 27
/m_transform.0/m_transform.0.17/Max maximum (-8.852665983140469, 10.777158588171005) 27
multiply (-23.159135084938455, 28.193729668620726) 26
/m_transform.0/m_transform.0.18/BatchNormalization add (-25.986115675758768, 30.502411395076415) 26
/m_transform.0/m_transform.0.19/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-13.696040377020836, 14.022136576473713) 27
/m_transform.0/m_transform.0.20/Slice strided_slice (-13.696040377020836, 14.022136576473713) 27
/m_transform.0/m_transform.0.20/Slice_1 strided_slice (-13.696040377020836, 14.022136576473713) 27
/m_transform.0/m_transform.0.20/Max maximum (-13.696040377020836, 14.022136576473713) 27
multiply (-11.827948031966857, 12.109565842251783) 27
/m_transform.0/m_transform.0.21/BatchNormalization add (-12.75578653628128, 11.855785924296384) 27
/m_transform.0/m_transform.0.22/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-5.920157857239246, 9.32873359322548) 27
/m_transform.0/m_transform.0.23/Slice strided_slice (-5.920157857239246, 9.32873359322548) 27
/m_transform.0/m_transform.0.23/Slice_1 strided_slice (-5.920157857239246, 9.32873359322548) 27
/m_transform.0/m_transform.0.23/Max maximum (-5.920157857239246, 9.32873359322548) 27
multiply (-15.198096249450279, 23.948515302164076) 26
/m_transform.0/m_transform.0.24/BatchNormalization add (-16.227776143897607, 23.619380271656034) 26
/m_transform.0/m_transform.0.25/Conv_output_0_DequantizeLinear contrib.epu.qlinear_conv2d (-13.87263286113739, 44.11952090263367) 25
/m_transform.0/m_transform.0.26/Slice strided_slice (-13.87263286113739, 44.11952090263367) 25
/m_transform.0/m_transform.0.26/Slice_1 strided_slice (-13.87263286113739, 44.11952090263367) 25
/m_transform.0/m_transform.0.26/Max maximum (-13.87263286113739, 44.11952090263367) 25
/m_transform.0/m_transform.0.27/MaxPool_output_0_DequantizeLinear contrib.epu.dequantize (-13.27181002497673, 44.08940279483795) 25
/Reshape reshape (-13.27181002497673, 44.08940279483795) 25
/m_before_pooling.0/m_before_pooling.0.0/Transpose transpose (-13.27181002497673, 44.08940279483795) 25
/m_before_pooling.0/m_before_pooling.0.0/l_blstm/LSTM contrib.epu.lstm [-1f, 1f] 31
/m_before_pooling.0/m_before_pooling.0.0/l_blstm/Transpose transpose (-1.0, 1.0) 31
/m_before_pooling.0/m_before_pooling.0.0/l_blstm/Reshape reshape (-1.0, 1.0) 31
/m_before_pooling.0/m_before_pooling.0.1/l_blstm/LSTM contrib.epu.lstm [-1f, 1f] 31
/m_before_pooling.0/m_before_pooling.0.1/l_blstm/Transpose transpose (-1.0, 1.0) 31
/m_before_pooling.0/m_before_pooling.0.1/l_blstm/Reshape reshape (-1.0, 1.0) 31
/m_before_pooling.0/m_before_pooling.0.1/Transpose transpose (-1.0, 1.0) 31
contrib.epu.quadric_custom_op (-32768.0, 32767.99998474121) 16
2026-08-20 14:57 - INFO - epu - codegen - START====================build_cpu_runnable_fx_relay
2026-08-20 14:57 - INFO - epu - codegen - START=======================quantize_to_chimera_fx
2026-08-20 14:57 - INFO - epu - codegen - START=================================relay_to_tir
2026-08-20 14:57 - INFO - epu - codegen - START===========================relay_to_epu_relay
2026-08-20 14:57 - INFO - epu - codegen - START==============================adapt_and_order
2026-08-20 14:57 - INFO - epu - mac_counter -
2026-08-20 14:57 - INFO - epu - mac_counter - ============================================================
2026-08-20 14:57 - INFO - epu - mac_counter - MAC Operation Count Summary
2026-08-20 14:57 - INFO - epu - mac_counter - ============================================================
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 76,800,000 ops (38,400,000 MACs) - /m_transform.0/m_transform.0.0/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 24,576,000 ops (12,288,000 MACs) - /m_transform.0/m_transform.0.3/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 331,776,000 ops (165,888,000 MACs) - /m_transform.0/m_transform.0.6/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 13,824,000 ops (6,912,000 MACs) - /m_transform.0/m_transform.0.10/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 165,888,000 ops (82,944,000 MACs) - /m_transform.0/m_transform.0.13/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 5,734,400 ops (2,867,200 MACs) - /m_transform.0/m_transform.0.16/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 25,804,800 ops (12,902,400 MACs) - /m_transform.0/m_transform.0.19/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 1,433,600 ops (716,800 MACs) - /m_transform.0/m_transform.0.22/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - conv2d: 12,902,400 ops (6,451,200 MACs) - /m_transform.0/m_transform.0.25/Conv_output_0_DequantizeLinear
2026-08-20 14:57 - INFO - epu - mac_counter - ------------------------------------------------------------
2026-08-20 14:57 - INFO - epu - mac_counter - Total: 658,739,200 ops (329,369,600 MACs)
2026-08-20 14:57 - INFO - epu - mac_counter - ============================================================
2026-08-20 14:57 - INFO - epu - mac_counter -
2026-08-20 14:57 - INFO - epu - codegen - START==============================amend_ctrl_flow
2026-08-20 14:57 - INFO - epu - codegen - START=============================plan_lrm_virtual
2026-08-20 14:57 - INFO - epu - codegen - START==============================amend_ctrl_flow
2026-08-20 14:57 - INFO - epu - codegen - START===============================lrm_alloc_loop
2026-08-20 14:57 - INFO - epu - codegen - START==============================amend_ctrl_flow
2026-08-20 14:57 - INFO - epu - codegen - START================================lrm_splitting
2026-08-20 14:57 - INFO - epu - codegen - START==============================ext_split_relay
2026-08-20 14:58 - INFO - epu - codegen - START====================================build_tir
compile: 88.3s
10. Run on the ISS
compiled.run(waveform=audio_sample) ships the inputs to the Chimera ISS and returns the score. We sweep all 16 test samples and compare against the chipy CPU scores.
iss_scores = np.zeros(len(audio))
t0 = time.time()
for i, x in enumerate(audio):
out = compiled.run(waveform=x)
iss_scores[i] = float(np.asarray(next(iter(out.values())).tensor).ravel()[0])
print(f"ISS: {time.time()-t0:.1f}s ({(time.time()-t0)/len(audio):.1f}s/sample)")
2026-08-20 14:58 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707dbc35cf10>
2026-08-20 14:58 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 3.15it/s]
2026-08-20 14:58 - INFO - epu - iss_testing - Done 0:00:07.896782
2026-08-20 14:58 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc169240>
2026-08-20 14:58 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 3.16it/s]
2026-08-20 14:58 - INFO - epu - iss_testing - Done 0:00:07.870509
2026-08-20 14:58 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc208340>
2026-08-20 14:58 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:07<00:00, 2.27it/s]
2026-08-20 14:58 - INFO - epu - iss_testing - Done 0:00:10.004593
2026-08-20 14:58 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707c5f39dc60>
2026-08-20 14:58 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:06<00:00, 2.74it/s]
2026-08-20 14:59 - INFO - epu - iss_testing - Done 0:00:09.208237
2026-08-20 14:59 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707c5f39f6d0>
2026-08-20 14:59 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 3.19it/s]
2026-08-20 14:59 - INFO - epu - iss_testing - Done 0:00:08.300956
2026-08-20 14:59 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc05d090>
2026-08-20 14:59 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 3.29it/s]
2026-08-20 14:59 - INFO - epu - iss_testing - Done 0:00:07.723853
2026-08-20 14:59 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc2091e0>
2026-08-20 14:59 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 3.16it/s]
2026-08-20 14:59 - INFO - epu - iss_testing - Done 0:00:07.872599
2026-08-20 14:59 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc05c0a0>
2026-08-20 14:59 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 2.99it/s]
2026-08-20 14:59 - INFO - epu - iss_testing - Done 0:00:08.233669
2026-08-20 14:59 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc20bd60>
2026-08-20 14:59 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:06<00:00, 2.45it/s]
2026-08-20 14:59 - INFO - epu - iss_testing - Done 0:00:10.292937
2026-08-20 14:59 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc209d80>
2026-08-20 14:59 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 2.94it/s]
2026-08-20 15:00 - INFO - epu - iss_testing - Done 0:00:09.134160
2026-08-20 15:00 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc05d8d0>
2026-08-20 15:00 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 2.93it/s]
2026-08-20 15:00 - INFO - epu - iss_testing - Done 0:00:08.159797
2026-08-20 15:00 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc2839a0>
2026-08-20 15:00 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:06<00:00, 2.82it/s]
2026-08-20 15:00 - INFO - epu - iss_testing - Done 0:00:08.890786
2026-08-20 15:00 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc20ab90>
2026-08-20 15:00 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 2.85it/s]
2026-08-20 15:00 - INFO - epu - iss_testing - Done 0:00:08.862115
2026-08-20 15:00 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc2832b0>
2026-08-20 15:00 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:06<00:00, 2.83it/s]
2026-08-20 15:00 - INFO - epu - iss_testing - Done 0:00:08.879852
2026-08-20 15:00 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc283880>
2026-08-20 15:00 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:05<00:00, 2.87it/s]
2026-08-20 15:00 - INFO - epu - iss_testing - Done 0:00:08.942975
2026-08-20 15:00 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x707bfc208880>
2026-08-20 15:00 - INFO - epu - iss_testing - Started Running Graph on Chimera ISS...
FILM 17/17: 100%|███████████████████████████████████████████████████| 17/17 [00:06<00:00, 2.53it/s]
2026-08-20 15:00 - INFO - epu - iss_testing - Done 0:00:09.504300
ISS: 139.9s (8.7s/sample)
header = (
f"{'file':<24}{'truth':>10}{'sCPU':>10}{'sISS':>10}"
f"{'predCPU':>10}{'predISS':>10}{'match?':>8}"
)
print(header)
print("-" * 82)
correct = matching = 0
for name, truth, sc, si in zip(filenames, truth_labels, cpu_scores, iss_scores):
pc = "bonafide" if sc >= 0.5 else "spoof"
pi = "bonafide" if si >= 0.5 else "spoof"
correct += pi == truth
matching += pc == pi
mark = "✓" if pc == pi else "✗"
print(f"{name:<24}{truth:>10}{sc:>10.4f}{si:>10.4f}{pc:>10}{pi:>10}{mark:>8}")
print(f"\nISS classification: {correct}/{len(audio)} correct vs ground truth")
print(f"CPU↔ISS match : {matching}/{len(audio)}")
print(f"MSE(ISS, CPU) : {np.mean((iss_scores - cpu_scores) ** 2):.4e}")
file truth sCPU sISS predCPU predISS match?
----------------------------------------------------------------------------------
LA_T_6435787.flac bonafide 1.0000 1.0000 bonafide bonafide ✓
LA_T_7422011.flac bonafide 1.0000 1.0000 bonafide bonafide ✓
LA_D_1803008.flac bonafide 1.0000 0.9999 bonafide bonafide ✓
LA_T_7223887.flac bonafide 0.9999 0.9998 bonafide bonafide ✓
LA_T_4928920.flac bonafide 1.0000 0.9999 bonafide bonafide ✓
LA_T_1836557.flac bonafide 0.9999 0.9998 bonafide bonafide ✓
LA_T_1518499.flac bonafide 1.0000 0.9999 bonafide bonafide ✓
LA_D_6055606.flac bonafide 0.9998 0.9992 bonafide bonafide ✓
LA_D_5835948.flac spoof 0.0000 0.0000 spoof spoof ✓
LA_T_1154440.flac spoof 0.0000 0.0003 spoof spoof ✓
LA_T_1373588.flac spoof 0.0078 0.0000 spoof spoof ✓
LA_T_7020532.flac spoof 0.0000 0.0000 spoof spoof ✓
LA_T_4725126.flac spoof 0.0000 0.0000 spoof spoof ✓
LA_D_3983088.flac spoof 0.0000 0.0000 spoof spoof ✓
LA_T_2320617.flac spoof 0.0000 0.0000 spoof spoof ✓
LA_T_7808689.flac spoof 0.0000 0.0000 spoof spoof ✓
ISS classification: 16/16 correct vs ground truth
CPU↔ISS match : 16/16
MSE(ISS, CPU) : 3.8170e-06
11. GPNPU performance
profile.json is dropped in the build dir at compile time and populated during the ISS run. chimera_core._plot_profile_results parses it into a TotalCycles summary and a per-category breakdown (compute / OCM / array / MAC / external).
profile = epu_codegen.get_module_build_path("asvspoof_chipy") / "profile.json"
chimera_core._plot_profile_results(str(profile), clock_freq=HW.clock_freq_ghz * 1e9)
[SDK-CLI] : TotalCycles: 3,220,692
[SDK-CLI] : Executions/second: 310
compute : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 1.336M
data_array : ▇▇▇▇▇▇▇▇▇▇▇▇ 323.981K
mac : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 890.246K
data_ocm : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 607.672K
data_external: ▇ 52.823K
{'compute': 1335857,
'data_array': 323981,
'mac': 890246,
'data_ocm': 607672,
'data_external': 52823,
'total': 3220692,
'ExtBytes': {'LOAD': 839072, 'STORE': 115204}}
Summary
| Model | ASVspoof2021 LA Baseline-LFCC-LCNN (PyTorch → ONNX → ChiPy) |
| Pipeline | LFCC frontend → LCNN backbone (9 conv blocks + MFM) → 2× BiLSTM → classifier head |
| Target | QC-N, 4 MB OCM, 4 kB LRM, 16 MACs/PE, 1.0 GHz, 8 GB/s ext BW |
| Authoring style | Single Python function; classical DSP + neural net fused into one GPNPU program |
| CCL kernels | asvspoof::lfccExtract, asvspoof::asvspoofClassifierHead (LFCC frontend + head) |
| Native ops | 9× LCNN Slice+Slice+Max MFM, 2× contrib.epu.lstm — all lowered by CGC |
| Performance | 3.34M cycles · 3.34 ms · ~1200× real-time on a 4-second clip |
| Validation | CPU↔ISS 16/16 match (MSE 3.8e-6); classification 16/16 vs ground truth |
How it works
- DSP and the neural net share one array. The LFCC frontend (Hamming-windowed STFT, mel filterbank, log, DCT, Δ/ΔΔ) hands its FP<16> tensor straight into the LCNN's first
qnn.quantize— no host hop, no buffer round-trip, no float→int8 boundary to wire up. Feature extraction and inference are one CGC-scheduled program. - One source, two execution modes. The
@chipy.funcbody is the only definition of the pipeline: CPU mode dispatches to numpy / ORT bodies for unit testing, GPNPU mode lowers each stage to its CCL kernel or ONNX-imported relay. - Only two hand-written kernels. The LFCC frontend and the classifier head are CCL kernels; everything else lowers natively via CGC — the LCNN convs and their
Slice + Slice + MaxMFM activations fold into the int8 conv dataflow (the channel-pair max is exact in the shared-scale int8 grid), and the two BiLSTMs becomecontrib.epu.lstmops (CGC quantizes W/R/B and the Q7 hidden state internally). - The affine DSP runs on the int8 MAC array; the ALU only does the nonlinearities. Pre-emphasis, the Hamming window, and the DFT are all linear, so they collapse into one int8 DFT-basis matmul applied to the raw frame (
Re[k] = Σ_p (x[s+p] − 0.97·x[s+p−1])·w[p]·cosθ_pk); the mel filterbank is a second int8 matmul. Onlypower = re²+im²andlogrun on the FX32 ALU. - int8 filterbank at full precision. The pre-log power spectrum spans ~1e14× in dynamic range, so
poweris kept at full int32 and decomposed into 5 base-128 int8 chunks (lossless) for 5 accumulating int8 MAC matmuls. The filterbank weights are themselves int8 — harmless because the mel path feedslogthen the DCT, and the DCT bases are zero-mean, so any constant scale factor cancels in every output coefficient. - End to end: 3.34M cycles (3.34 ms, ~1200× real-time), 16/16 classification, MSE 3.8e-6 vs CPU, with bonafide/spoof margins of 0.999+ / 0.000.
