Getting an LSTM Sentiment Model Onto SiMa's Modalix MLA
July 16, 2026 · by ThinkRobotics
A step-by-step walkthrough - and exact terminal commands - for taking a PyTorch LSTM from training all the way to real-time inference on-device.
The MLA on SiMa's Modalix chip is fast, but it only speaks a fairly narrow dialect of ONNX - and the ONNX
LSTM operator isn't in that dialect. Getting a trained sentiment model running
on-device means taking it apart: splitting the embedding lookup off onto the CPU, unrolling the LSTM
recurrence into a plain directed graph of MatMul/Add/Sigmoid
nodes, approximating Tanh with a piecewise-linear stand-in, and then quantizing
and packaging the result for the accelerator.
Below is the full path from a PyTorch checkpoint to a running inference loop on the DevKit, with every command written out exactly as you'd type it into a terminal - no orchestration layer, no agent, just the commands themselves and which machine each one runs on.
The Six Phases
Two decomposition tricks make this possible: the embedding table stays on the host CPU (a Gather over a 20,000×200 table isn't MLA-friendly), and the LSTM itself gets
unrolled across all 150 timesteps into a static graph small enough for the compiler to import.
LSTM and Tanh with supported ops; unroll the recurrence.Phase 1: Train the Model & Export to ONNX
Run on: HostThe model is a 2-layer unidirectional LSTM (vocab 20,000, sequence length 150, embedding dim 200, hidden dim 128) trained on IMDB reviews. Install the training dependencies, run the training script, then split the exported ONNX graph so the embedding lookup stays separate from the LSTM/classifier.
# 1. Install training dependencies pip install torch>=2.0 onnx>=1.14 onnxruntime>=1.16 numpy>=1.24 datasets>=2.14 scikit-learn>=1.3 # 2. Train the LSTM on IMDB reviews -> best_sentiment_model.pt python3 sentiment_lstm.py # 3. Split the exported ONNX into a host embedding model + device LSTM/classifier python3 decompose_sentiment.py
Two files come out of this step: sentiment_host_embedding.onnx
(a single Gather node, stays on the CPU) and
sentiment_device.onnx (2 LSTM nodes + classifier, headed for the MLA).
The vocabulary (word2idx) isn't saved automatically here - it gets rebuilt
in Phase 6.
Phase 2: Graph Surgery - Swap LSTM for Supported Ops
Run on: HostThe MLA's supported-operator list has no LSTM and no Tanh. The fix: fit a piecewise-linear hinge-ReLU approximation for tanh, then
unroll the LSTM recurrence over all 150 timesteps into a static graph built entirely from
MatMul, Add, Sigmoid,
Mul, Sub, and Relu.
# 1. Fit the tanh(x) ≈ c0 + c1*x + Σ ak·ReLU(x - bk) approximation # (least-squares fit over x in [-4, 4], 6 hinge points) python3 fit_tanh_hinge.py # -> tanh_hinge_coeffs.npz # 2. Replace the LSTM nodes with the unrolled, hinge-tanh subgraph python3 lstm_surgery.py # -> sentiment_decomposed.onnx (~46,000 nodes - too large to compile as-is)
The naive unroll produces roughly 46,000-49,000 nodes for the stacked LSTM, which is well past what the
SiMa Model Compiler's TVM importer can handle (a practical ceiling of ~5,000-10,000 nodes). The working
path prunes this down to a compilable sentiment_clean.onnx at 3,905 nodes by
removing unused weight initializers, swapping Squeeze/Unsqueeze for Reshape, and exporting at ONNX opset
14:
# Produce the pruned, compilable model # (lstm_surgery.py adapted with --output to emit the cleaned graph directly) python3 lstm_surgery.py --output sentiment_clean.onnx
If you hit "node count exceeds limit" from the TVM importer, you're pointing the
compiler at sentiment_device.onnx or
sentiment_decomposed.onnx - both are the pre-pruning, 46K+ node versions.
Use sentiment_clean.onnx (3,905 nodes) instead.
Phase 3: Validate Against the Original Model
Run on: HostBefore spending time quantizing and compiling, confirm the decomposed ONNX graph actually reproduces the original PyTorch model's outputs.
python3 validate_sentiment.py
What a healthy run looks like: max logit difference under roughly 1e-5 across 100 random inputs and classification agreement above 99.9% on
the full 25,000-sample IMDB test set. If the max diff comes back above 0.01,
increase the number of hinge points (N_K) in Phase 2's tanh fit and re-run
the surgery step.
Phase 4: Quantize & Compile for the Modalix MLA
Run on: Host, inside the SDK containerThis is the one step that runs inside SiMa's SDK container (image sih2024/sima-neat-quantize), where the Model Compiler lives. Enter the
container, activate the compiler, and confirm the versions before running the quantization script.
# Enter the SDK container
docker exec -it sima-neat-quantize bash
# Activate the Model Compiler source /sdk-ext/model-compiler/bin/activate # Sanity-check the SDK version python3 -c "from afe.apis.release_v1 import get_model_sdk_version; print(get_model_sdk_version())" # Expected: afe 2.1.0, jax 0.4.30, numpy 1.26.4 # Run quantization + compilation # (expects sentiment_clean.onnx and sentiment_host_embedding.onnx in /workspace/graphsurgery_lstm/) python3 /workspace/graphsurgery_lstm/quantize_clean.py # Leave the container once it finishes exit
The script quantizes to int8 (per-tensor asymmetric activations, per-channel symmetric weights, MSE
calibration) and compiles, writing everything to
build_quantized_clean_v2/. The two files that matter for deployment are
sentiment_clean_mpk.tar.gz (the deployable package) and
sentiment_clean_int8.sima (the raw quantized model inside it).
The default calibration data is random noise - 64 samples uniform in
[-3.0, 3.0]. That's enough to get a model that compiles, but if the MLA
output comes back as a near-constant value (e.g. always around -0.037
regardless of input), swap in real IMDB embeddings as calibration data instead of random noise - see
the calibration note below.
Phase 5: MPK Packaging (usually automatic)
Run on: HostThe MPK is normally produced automatically as part of Phase 4's compile step. You only need the commands
below if you're repackaging an existing .sima model manually, or want to
double-check what's inside the archive before shipping it to the DevKit.
# Only needed if sima-cli isn't already available in the container pip install sima-cli # Manually repackage a .sima model into an MPK, if needed sima-cli mpk create \ --model sentiment_clean_int8.sima \ --name sentiment_clean \ --output sentiment_clean_mpk.tar.gz # Verify the archive contents tar -tzf sentiment_clean_mpk.tar.gz # Expected: manifest.json, share/sentiment_clean_stage1_mla.elf, share/pipeline_config.json
Phase 6: Transfer to the DevKit & Set Up the Runtime
Run on: Host → DevKit (192.168.0.28)Three files need to land on the DevKit: the MPK package, the host embedding ONNX model, and the vocabulary.
Copy them over with scp, then SSH in to set up the Python environment.
# Copy the model, embedding, and vocab over to the DevKit # (enter the password "edgeai" when prompted for each file) scp sentiment_clean_mpk.tar.gz sima@192.168.0.28:~/sentiment_model/ scp sentiment_host_embedding.onnx sima@192.168.0.28:~/sentiment_model/ scp word2idx.json sima@192.168.0.28:~/sentiment_model/
# SSH into the DevKit (password: edgeai)
ssh sima@192.168.0.28
# One-time environment setup python3 -m venv ~/pyneat source ~/pyneat/bin/activate pip install pyneat onnxruntime numpy # Verify the versions match what the pipeline expects python3 -c "import pyneat; print(pyneat.__version__)" # expected: 0.2.2 python3 -c "import onnxruntime; print(onnxruntime.__version__)" # expected: 1.26.0
The DevKit itself runs eLxr 12 on aarch64. Once ~/pyneat
is activated, every command in Phase 7 below runs directly on the DevKit, in that same SSH session.
Phase 7: Run Inference on the DevKit
Run on: DevKitWith the environment set up, run the inference script directly. Text goes in on the CPU (tokenize →
embed via onnxruntime), the embedding tensor crosses over to the MLA for the
LSTM + classifier, and a single logit comes back.
# Run the end-to-end test script
python3 ~/sentiment_model/test_real_text.py
The first load is slow and that's expected: pyneat.Model(MODEL_MPK) takes roughly 32 seconds to load (this isn't
cached between runs), and the first graph.build() warm-up takes about 2
seconds, dropping to ~0.36s on later builds within the same process. Budget for that startup cost in
any script or service that wraps this.
Optional: Improve Quantization With Real Calibration Data
Run on: HostIf the deployed int8 model outputs a near-constant logit for every input, the random calibration data from Phase 4 is the likely cause. Generating calibration samples from real IMDB embeddings instead usually fixes it - real train-time activations produce meaningfully better scale and zero-point values than uniform noise.
# Generate calibration embeddings from real IMDB training data # (run your calibration-generation script - it loads sentiment_host_embedding.onnx, # encodes 256 real IMDB reviews, and saves the resulting embeddings) python3 generate_calibration_data.py # -> calib_embeddings.npy # Re-run quantization, now pointed at the real calibration data # (update quantize_clean.py first so it loads calib_embeddings.npy instead of random noise) python3 quantize_clean.py
Troubleshooting Common Failure Modes
sentiment_clean.onnx
(3,905 nodes) from Phase 2 instead..sima build and compare logits against CPU to rule out a bad MPK
input spec.runner.run(). The input tensor shape doesn't match what the
MPK expects. Use the pyneat.Graph() pipeline approach (input node
→ model graph → output node) rather than calling model.run()
directly.SIMA_ALLOW_INPUTSTREAM_CPU_TO_EV74_COPY=1 before running your script,
or create tensors directly with memory=pyneat.TensorMemory.EV74.Command Quick Reference
All seven phases, one command block per machine, in the order you'd actually run them.
# ── HOST ────────────────────────────────────────────── pip install torch>=2.0 onnx>=1.14 onnxruntime>=1.16 numpy>=1.24 datasets>=2.14 scikit-learn>=1.3 python3 sentiment_lstm.py python3 decompose_sentiment.py python3 fit_tanh_hinge.py python3 lstm_surgery.py --output sentiment_clean.onnx python3 validate_sentiment.py python3 build_vocab.py # ── HOST, ENTER CONTAINER ──────────────────────────── docker exec -it sima-neat-quantize bash # ── INSIDE THE CONTAINER ───────────────────────────── source /sdk-ext/model-compiler/bin/activate python3 /workspace/graphsurgery_lstm/quantize_clean.py exit # ── HOST -> DEVKIT ─────────────────────────────────── scp sentiment_clean_mpk.tar.gz sima@192.168.0.28:~/sentiment_model/ scp sentiment_host_embedding.onnx sima@192.168.0.28:~/sentiment_model/ scp word2idx.json sima@192.168.0.28:~/sentiment_model/ ssh sima@192.168.0.28 # ── ON THE DEVKIT ──────────────────────────────────── source ~/pyneat/bin/activate python3 ~/sentiment_model/test_real_text.py
| Phase | Runs On | Key Output |
|---|---|---|
| 1. Train & Export | Host | best_sentiment_model.pt, sentiment_host_embedding.onnx |
| 2. Graph Surgery | Host | sentiment_clean.onnx (3,905 nodes) |
| 3. Validate | Host | PyTorch vs ONNX agreement report |
| 4. Quantize & Compile | Host (SDK container) | sentiment_clean_mpk.tar.gz |
| 5. MPK Packaging | Host | Verified/rebuilt MPK archive |
| 6. Transfer | Host → DevKit | Artifacts on DevKit, pyneat env ready |
| 7. Inference | DevKit | Sentiment label + confidence, ~20ms |
Deploying to an Accelerator Is a Graph-Rewriting Problem First
Almost none of the work here is about the sentiment model itself - a
2-layer LSTM is about as standard as it gets. The real work is translating a graph built for a
general-purpose runtime into one that only uses the narrow set of ops a specific accelerator actually
supports: splitting off the embedding table, replacing LSTM with an
unrolled subgraph, and swapping Tanh for a fitted piecewise-linear
approximation. Every phase after that - quantization, packaging, transfer - exists to get that rewritten
graph safely onto the device without losing the accuracy the rewrite was careful to preserve.
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