Getting an LSTM Sentiment Model Onto SiMa's Modalix MLA

July 16, 2026 · by ThinkRobotics

Share

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.

SiMa Modalix DevKit
pyneat 0.2.2
onnxruntime 1.26.0
~20ms end-to-end

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.

1 · Train & Export
Train the 2-layer LSTM on IMDB reviews, export to ONNX, and split off the embedding layer.
Host
2 · Graph Surgery
Replace LSTM and Tanh with supported ops; unroll the recurrence.
Host
3 · Validate
Confirm the decomposed ONNX graph matches the original PyTorch model's outputs.
Host
4 · Quantize & Compile
Run the SiMa Model Compiler inside the SDK container to produce an int8 MLA binary.
Container
5 · Package
Confirm (or manually build) the MPK archive the DevKit runtime expects.
Host
6 · Transfer & Run
Copy artifacts to the DevKit over SSH and run the inference script on-device.
DevKit

Phase 1: Train the Model & Export to ONNX

Run on: Host

The 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.

Terminal · Host machine
# 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: Host

The 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.

Terminal · Host machine
# 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:

Terminal · Host machine
# 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: Host

Before spending time quantizing and compiling, confirm the decomposed ONNX graph actually reproduces the original PyTorch model's outputs.

Terminal · Host machine
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 container

This 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.

Terminal · Host machine
# Enter the SDK container
docker exec -it sima-neat-quantize bash
Terminal · Inside the container
# 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: Host

The 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.

Terminal · Host machine
# 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.

Terminal · Host machine
# 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/
Terminal · Host machine
# SSH into the DevKit (password: edgeai)
ssh sima@192.168.0.28
Terminal · On the DevKit, after logging in
# 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: DevKit

With 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.

Terminal · On the DevKit (inside the ~/pyneat environment)
# Run the end-to-end test script
python3 ~/sentiment_model/test_real_text.py
~5ms
CPU Embedding
onnxruntime, warm run
~10ms
MLA Inference
LSTM + classifier, warm run
~20ms
Total Per Inference
CPU + MLA combined

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: Host

If 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.

Terminal · Host machine
# 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

⚠️
"Node count exceeds limit" during compilation. You're compiling the raw 46K-49K node unrolled graph. Use the pruned sentiment_clean.onnx (3,905 nodes) from Phase 2 instead.
⚠️
MLA outputs a constant value for every input. Almost always int8 quantization degrading the tanh-based LSTM activations. Re-quantize with real calibration data (above), or try bfloat16 if your target supports it. If that doesn't fix it, run the FP32 .sima build and compare logits against CPU to rule out a bad MPK input spec.
⚠️
"Width exceeds effective max" from 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" errors. The PyNeat tensor isn't in EV74/MLA memory. Set 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.

Full sequence, machine by machine
# ── 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

KEY TAKEAWAY

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.

PyTorch ONNX SiMa Model Compiler pyneat Modalix MLA

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