Getting MiniLM-L6-v2 Onto SiMa's Modalix MLA
July 16, 2026 · by ThinkRobotics
Exporting a HuggingFace BERT model, rewriting its ONNX graph for hardware compatibility, and deploying it to run sentence embeddings in ~7 milliseconds.
MiniLM-L6-v2 is a small, fast sentence-embedding model - the kind of thing that powers semantic search, duplicate-question detection, and document similarity. On its own it already runs in tens of milliseconds on a CPU. Point it at SiMa's Modalix Machine Learning Accelerator instead, and that drops to under 7ms - but only after the model gets translated into a dialect the chip actually understands.
The MLA doesn't run arbitrary ONNX. It supports a fixed set of roughly 60 int8 operators, and a stock BERT
export from HuggingFace is full of things outside that set - dynamic shapes, Gather-based embedding lookups, Gemm, NaN guards
that never actually fire. Getting from a HuggingFace checkpoint to a working MLA binary means exporting to
ONNX, surgically rewriting the graph operator-by-operator, quantizing to int8, compiling to a hardware
binary, and shipping the result to the DevKit.
The Six Steps
Two ideas do most of the work: freeze every shape to static values at export time (the MLA compiler requires fixed input shapes), and move anything the chip can't run - the embedding table, the attention mask construction - off-chip onto the CPU.
Gemm,
remove NaN guards - six surgeries in total..elf
binary and MPK archive.pyneat and run
sentence embeddings on-device.How the Chip Is Actually Organized
The Modalix DevKit isn't a single accelerator - it's three compute domains working together, and knowing which one does what explains most of the surgery decisions later.
| Domain | Component | Role |
|---|---|---|
| APU | Cortex-A65 CPU | Control logic, tokenization, dequantization |
| CVU (EV74) | Computer Vision Unit | Quantization scaling, tessellation (tiling tensors for the MLA) |
| MLA | Machine Learning Accelerator | All 6 BERT transformer layers - MatMul, Conv2D, Softmax |
The MLA has no random-access memory for large lookup tables. That single hardware fact is why the embedding layer gets moved off-chip in surgery, rather than optimized to fit - a 119,547×384 embedding table simply has nowhere to live on the accelerator.
Prerequisites & Environment
Run on: HostYou'll need an x86_64 Linux dev machine with Docker, the SiMa DevKit reachable over Ethernet, and the SDK container already running with the Model Compiler inside it.
# Enter the running SDK container
docker exec -it <container_id> bash
# Activate the Model Compiler virtual environment source /sdk-extensions/model-compiler/bin/activate # Confirm the version python3 -c "import afe; print('AFE version:', afe.__version__)" # Expected: AFE version: 2.1.0
# SSH into the DevKit (password: edgeai)
ssh sima@192.168.0.28
# Confirm pyneat is available python3 -c "import pyneat; print('pyneat version:', pyneat.__version__)" # Expected: pyneat version: 0.2.0
Step 1: Export to ONNX
Run on: HostThe Model Compiler doesn't take HuggingFace .bin or PyTorch .pt files directly - it wants ONNX (its most mature import path). Every shape
has to be frozen at export time; the MLA compiler has no tolerance for dynamic dimensions.
# Run the export script (see export_minilm.py for the full source) # Loads sentence-transformers/all-MiniLM-L6-v2, exports with static # shapes (batch=1, seq_len=128), opset 17, and simplifies with onnxsim python3 export_minilm.py # -> minilm-l6-v2.onnx (~90 MB) # -> minilm-l6-v2.sim.onnx (~90 MB, after onnxsim simplification)
| Parameter | Value | Why |
|---|---|---|
| dynamic_axes | None | The MLA compiler requires fixed shapes - dynamic axes pull in unsupported ops |
| opset_version | 17 | Newer opsets have better operator coverage |
| do_constant_folding | True | Bakes LayerNorm weights/biases in at export time |
| batch, seq_len | 1, 128 | Fixed dimensions the compiled binary will expect |
Step 2: Graph Surgery
Run on: Host, inside the SDK containerThis is where most of the real work happens. The exported ONNX graph has to be audited against the MLA's supported-operator list, and every unsupported op removed, replaced, or moved off-chip.
# Audit the model for unsupported operators
sima-neat-sdk_audit_model --model_path minilm-l6-v2.sim.onnx --dtype int8 --json_output
Six surgeries turn the audit results into a compilable graph:
Gather for word embedding lookup isn't supported - no
random-access memory on the MLA for a large table. The 44.7 MB embedding table moves to the
CPU; the model's new input becomes precomputed hidden_states.
Unsqueeze + Gather + Where isn't well-supported on MLA int8 paths.
The attention bias tensor [1,12,128,128] is precomputed on the
CPU and passed in directly as a model input.
IsNan + Where pairs get removed entirely. Static analysis of the
data flow shows the pipeline never produces NaN between the embedding layer and the
transformer, so the guard is dead weight.
The pooler's first-token selection via Gather(0) becomes
Slice(0,1) + Reshape - both supported, same result.
Gemm isn't in the supported set. It decomposes cleanly into
Transpose(B) + MatMul(A, Bᵀ) + Add(bias).
Once the embedding layer is gone, its ~183 MB of initializers are still sitting in the file, unused. Stripping every initializer with no connected graph node drops the model from ~230 MB to ~41 MB.
# Run the surgery script (see surgery_minilm.py for the full source) python3 surgery_minilm.py # -> minilm-l6-v2.surg.onnx (~41 MB) # Validate: compare FP32 outputs of the original vs. surgically modified model python3 validate_surgery.py # Expected: max absolute difference < 1e-5
| Metric | Before Surgery | After Surgery |
|---|---|---|
| Model file size | ~90 MB | ~41 MB |
| Node count | ~450+ | 233 |
| Unique op types | ~16 | 11 (all int8-supported) |
| Numerical accuracy | - | 2.1e-6 max diff vs. baseline |
Step 3: Quantization
Run on: Host, inside the SDK containerQuantization converts weights and activations from float32 down to int8 - the MLA's native compute precision. That's a 4× reduction in memory and a meaningful speedup, at a cost of roughly 0.1% accuracy loss on sentence similarity tasks for this model.
Calibration only uses 10 samples here, not the 50-100 you might expect. Transformer activations have fairly stable ranges, so 10 dummy samples get calibration done in ~30 seconds instead of ~5 minutes - a deliberate tradeoff, not an oversight.
# quantize_clean.py — quantization config for this model from afe.apis.defines import ( default_quantization, quantization_scheme, RequantizationMode, CalibrationMethod ) # 8-bit activations (symmetric, per-tensor) act_scheme = quantization_scheme(quantize_activations=True, quantize_weights=False, num_bits=8) # 8-bit weights (asymmetric, per-channel) wt_scheme = quantization_scheme(quantize_activations=False, quantize_weights=True, num_bits=8) quant_config = default_quantization \ .with_activation_quantization(act_scheme) \ .with_weight_quantization(wt_scheme) \ .with_requantization_mode(RequantizationMode.sima) \ .with_calibration(CalibrationMethod.from_str("mse")) quant_model = loaded_net.quantize( calibration_data=calib_data, quantization_config=quant_config, any_shape_on_mla=True, # BERT's 3D tensors, not 4D image tensors automatic_layout_conversion=True, # ONNX is NCHW, MLA expects NHWC model_name="minilm-l6-v2", )
any_shape_on_mla=True for non-4D
tensors. BERT works in (batch, seq, hidden) - 3D - not the 4D image-style tensors the
MLA path defaults to expecting.Step 4: Compilation
Run on: Host, inside the SDK containerCompilation schedules operators across the three compute domains, tiles tensors to fit the MLA's 128 KB
local SRAM, maps memory addresses, and produces .elf binaries the MLA can
execute directly.
Pass a directory, not a file path, to compile(). A file
path like "model.sima" produces a unified .sima zip that pyneat 0.2.0 can't load. A
directory path produces individual .elf binaries inside an MPK archive -
what the DevKit runtime actually expects.
# THE KEY INSIGHT: pass a DIRECTORY path, not a file path quant_model.compile( BUILD_DIR, # Directory path -> produces model_mpk.tar.gz batch_size=1, )
Compile time is roughly 3 minutes (quantize + compile combined). The output directory contains the
deployable .tar.gz alongside the raw .elf
binary and a set of pipeline-stage config JSONs (quantization/tessellation, buffer concat, MLA
processing, post-processing).
Step 5: Deploy to the DevKit
Run on: Host → DevKit (192.168.0.28)Pull the MPK archive out of the container, then copy it onto the DevKit over SSH.
# Copy the archive out of the container onto the host docker cp <container>:/workspace/build_minilm_v5/minilm-l6-v2_mpk.tar.gz \ /host/path/minilm-l6-v2_mpk_nopooler.tar.gz # Copy it onto the DevKit (password: edgeai) scp minilm-l6-v2_mpk_nopooler.tar.gz sima@192.168.0.28:/home/sima/models/
# Verify the archive - it MUST contain at least one .elf file
tar tzf /home/sima/models/minilm-l6-v2_mpk_nopooler.tar.gz
If the archive is missing a .elf file, pyneat will reject it outright with
invalid_archive: archive missing model binary artifacts. That almost
always traces back to Step 4's file-vs-directory distinction - re-run compile() with a directory path.
Step 6: Run Inference on the DevKit
Run on: DevKitWith the archive in place, load the model and push tensors through it directly.
# Run the inference script (see minilm_inference.py for the full source) # Loads the MPK with mla_only=True, pushes hidden_states + attention_bias, # pulls the raw int8 output, and dequantizes it manually on the CPU python3 minilm_inference.py
inference_terminal.mla_only = True is a deliberate
workaround, not an oversight. AFE 2.1.0's combined detessellate+dequantize post-processing
stage has a geometry bug for non-4D tensors like BERT's. Setting this flag truncates the pipeline at
the raw MLA output; the script then dequantizes manually using the scale (31.62) and zero point (-76) pulled from
0_postproc.json.
| Metric | Value | Notes |
|---|---|---|
| Load time | ~200 ms | One-time model load + runner build |
| Inference (mean) | 6.8 ms | 20 runs, after 10 warmup |
| Min / Max | 6.1 ms / 7.6 ms | Std dev 0.3 ms - very stable |
| Throughput | ~147 / sec | Single stream |
For context, the same model takes ~80ms on one Xeon CPU core, ~15ms across 8 cores, and ~5ms on a T4 GPU - the GPU is marginally faster but draws roughly 75W against the MLA's ~5W.
Troubleshooting Common Failure Modes
.sima file instead of individual
.elf binaries. Pass a directory path to compile(), not a file path.opts.inference_terminal.mla_only = True and dequantize manually
using the scale/zp from 0_postproc.json.export SIMA_ALLOW_INPUTSTREAM_CPU_TO_EV74_COPY=1. Proper fix:
allocate tensors with memory=pyneat.TensorMemory.EV74.[1,128,384]
array. Explicitly pass layout=pyneat.TensorLayout.HWC when building
the tensor.0_postproc.json
against what's hardcoded in your inference script, and compare against an FP32 reference - the
int8 output should land within ~2% of it.Command Quick Reference
All six steps, one command block per machine, in the order you'd actually run them.
# ── HOST, ENTER CONTAINER ──────────────────────────── docker exec -it <container_id> bash # ── INSIDE THE CONTAINER ───────────────────────────── source /sdk-extensions/model-compiler/bin/activate python3 export_minilm.py sima-neat-sdk_audit_model --model_path minilm-l6-v2.sim.onnx --dtype int8 --json_output python3 surgery_minilm.py python3 validate_surgery.py python3 compile_v2.py exit # ── HOST -> DEVKIT ─────────────────────────────────── docker cp <container>:/workspace/build_minilm_v5/minilm-l6-v2_mpk.tar.gz . scp minilm-l6-v2_mpk_nopooler.tar.gz sima@192.168.0.28:/home/sima/models/ ssh sima@192.168.0.28 # ── ON THE DEVKIT ──────────────────────────────────── tar tzf /home/sima/models/minilm-l6-v2_mpk_nopooler.tar.gz python3 minilm_inference.py
| Step | Runs On | Key Output |
|---|---|---|
| 1. Export | Host | minilm-l6-v2.sim.onnx (~90 MB) |
| 2. Graph Surgery | Container | minilm-l6-v2.surg.onnx (~41 MB, 233 nodes) |
| 3. Quantization | Container | int8 model, calibrated on 10 samples |
| 4. Compilation | Container | minilm-l6-v2_mpk.tar.gz |
| 5. Deploy | Host → DevKit | MPK archive on /home/sima/models/ |
| 6. Inference | DevKit | Sentence embedding, ~6.8ms |
Quick Glossary
- MLA - Machine Learning Accelerator, the neural network compute engine on Modalix.
- CVU / EV74 - Computer Vision Unit, handles tensor pre- and post-processing.
- Tessellation - slicing large tensors into tiles that fit the MLA's local memory.
-
MPK - Model Pack, the deployable archive containing
.elfbinaries and pipeline config JSONs. -
Scale / Zero Point - the two numbers defining int8↔float32 mapping:
float = (int8 - zp) × scale. - Orphaned initializer - a weight tensor left in the graph file after surgery disconnected it from any node.
The Model Barely Changes - the Graph Around It Does
MiniLM-L6-v2 itself is untouched mathematically - it's still the same
6-layer transformer producing the same embeddings. Nearly every line of engineering effort here goes
into the graph surrounding it: freezing shapes, moving the embedding table off-chip, decomposing
Gemm, and working around a genuine compiler bug in the post-processing
stage. That's the recurring shape of accelerator deployment work - the model is rarely the hard part,
the translation to hardware-native operations is.
Want to Run This on Real Hardware?
Everything in this walkthrough targets the SiMa Modalix DevKit - the same board used throughout this guide.
Get the Modalix DevKit 3.0