Profiler and device simulator
Answer "will it fit on chip X?" for every model, then prove the whole device story (install, run, report, update) with a simulated chip in a notebook. No server, no hardware.
Where Where to implement
Profiler
sutra_core/profile.py: flash, peak RAM, operation count, latency- Output table:
reports/profile.md
Device simulator
sutra_core/device_sim.py:FakeDevice- This is exactly what the SOC API will serve over HTTP in Month 2
Do Do it
1Profiler helpers
Microcontroller numbers here are estimates. They're good enough to rule a chip in or out; Month 3 replaces them with numbers measured on real boards.
import time, numpy as np, torch, onnxruntime as ort
from thop import profile as thop_profile
TARGETS = { # rough MCU settings: clock in MHz, multiply-accumulates per cycle (estimate)
"ESP32-S3 (240 MHz)": (240, 1.0),
"Silicon Labs xG24 (78 MHz)": (78, 1.0),
"Cortex-M4F (80 MHz)": (80, 0.5),
"Cortex-M7 (480 MHz)": (480, 1.0),
}
def peak_activation_bytes(model, sample, bytes_per_value=1):
"""int8 tensor-arena estimate: the largest (inputs + output) of any single layer. Add ~40% margin."""
peak = 0
def hook(m, inp, out):
nonlocal peak
n = sum(t.numel() for t in inp if torch.is_tensor(t)) + (out.numel() if torch.is_tensor(out) else 0)
peak = max(peak, n * bytes_per_value)
hooks = [m.register_forward_hook(hook) for m in model.modules() if not list(m.children())]
model.eval()
with torch.no_grad(): model(sample)
for h in hooks: h.remove()
return peak
def macs(model, sample):
m, _ = thop_profile(model, inputs=(sample,), verbose=False)
return int(m)
def onnx_latency_ms(onnx_path, sample, provider="CPUExecutionProvider", runs=50):
sess = ort.InferenceSession(str(onnx_path), providers=[provider])
feed = {sess.get_inputs()[0].name: sample.numpy()}
for _ in range(5): sess.run(None, feed)
t = time.perf_counter()
for _ in range(runs): sess.run(None, feed)
return (time.perf_counter() - t) / runs * 10002NB50: one table for every model
import sys, torch
from pathlib import Path
ROOT = Path.home() / "sutraai"; sys.path.insert(0, str(ROOT)); EXP = ROOT / "exports"
from sutra_core.profile import TARGETS, peak_activation_bytes, macs, onnx_latency_ms
RUNTIME_ALLOWANCE_KB = 100 # placeholder for TFLite Micro + your ops; replace with a real build size in Month 3
def row(name, model, sample, onnx_file, tflite_file):
flash_kb = Path(tflite_file).stat().st_size / 1024 + RUNTIME_ALLOWANCE_KB
ram_kb = peak_activation_bytes(model, sample) * 1.4 / 1024
m = macs(model, sample)
cpu_ms = onnx_latency_ms(onnx_file, sample)
est = {t: m / (mhz * 1e6 * per_cycle) * 1000 for t, (mhz, per_cycle) in TARGETS.items()}
return name, flash_kb, ram_kb, m, cpu_ms, est
# fill with your models from Steps 05–09, for example:
# rows = [row("audio_v1", audio_model, torch.randn(1, 50, 13), EXP/"audio_v1.onnx", EXP/"audio_v1_tf/audio_v1_full_integer_quant.tflite"), ...]lines = ["| model | flash KB (est.) | RAM KB (est.) | MACs | Jetson CPU ms (measured) | " + " | ".join(f"{t} ms (est.)" for t in TARGETS) + " |",
"|---|---|---|---|---|" + "---|" * len(TARGETS)]
for name, flash, ram, m, cpu, est in rows:
lines.append(f"| {name} | {flash:.0f} | {ram:.0f} | {m:,} | {cpu:.2f} | " + " | ".join(f"{v:.0f}" for v in est.values()) + " |")
(ROOT / "reports/profile.md").write_text("\n".join(lines))
print("\n".join(lines))3The device simulator
It behaves like an ESP32 running SutraAI firmware: it installs a model from a manifest, refuses corrupted files, runs int8 inference and keeps telemetry. Updating the model means installing a new manifest, which is the same "over-the-air" flow the SOC API will offer.
import json, hashlib, numpy as np
from ai_edge_litert.interpreter import Interpreter
def make_manifest(version, artifact_path, labels, pipeline, out_path):
blob = open(artifact_path, "rb").read()
m = {"version": version, "artifact_path": str(artifact_path), "sha256": hashlib.sha256(blob).hexdigest(),
"size": len(blob), "labels": list(labels), "pipeline": pipeline}
json.dump(m, open(out_path, "w"), indent=2)
return m
class FakeDevice:
def __init__(self, manifest_path):
self.telemetry = []
self.install(manifest_path)
def install(self, manifest_path): # "OTA update"
m = json.load(open(manifest_path))
blob = open(m["artifact_path"], "rb").read()
if hashlib.sha256(blob).hexdigest() != m["sha256"]:
raise ValueError("corrupt download: sha256 mismatch, keeping the old model")
self.m = m
self.itp = Interpreter(model_content=blob); self.itp.allocate_tensors()
self.inp, self.out = self.itp.get_input_details()[0], self.itp.get_output_details()[0]
def infer(self, features): # features: output of the SAME DSP as training
s, z = self.inp["quantization"]
q = np.clip(np.round(features / s + z), -128, 127).astype(np.int8)
self.itp.set_tensor(self.inp["index"], q.reshape(self.inp["shape"])); self.itp.invoke()
so, zo = self.out["quantization"]
scores = (self.itp.get_tensor(self.out["index"])[0].astype(np.float32) - zo) * so
e = np.exp(scores - scores.max()); probs = e / e.sum() # softmax (outputs are logits)
label, conf = self.m["labels"][int(probs.argmax())], float(probs.max())
self.telemetry.append({"model_version": self.m["version"], "label": label, "confidence": round(conf, 3)})
return label, conf4NB51: run the full story
- Make
manifest_v1.jsonfor the audio model from Step 07 and install it. - Stream the test windows through
infer(). Collect the ones with confidence under 0.6 into a review list. - Label the review list (you know the true labels), add those windows to training, retrain (Step 07 notebook or NB90), export v2.
- Make
manifest_v2.json, install it on the same device, and show accuracy went up. - Corrupt a manifest on purpose and show the device refuses it.
import sys, json, yaml, numpy as np
from pathlib import Path
ROOT = Path.home() / "sutraai"; sys.path.insert(0, str(ROOT)); EXP = ROOT / "exports"
from sutra_core.device_sim import make_manifest, FakeDevice
d = np.load(ROOT / "data/processed/audio_v1.npz") # saved from NB32: Xte, yte, labels
labels, Xte, yte = list(d["labels"]), d["Xte"], d["yte"]
pipeline = yaml.safe_load(open(ROOT / "configs/audio.yaml"))
make_manifest(1, EXP / "audio_v1_tf/audio_v1_full_integer_quant.tflite", labels, pipeline, EXP / "manifest_v1.json")
dev = FakeDevice(EXP / "manifest_v1.json")
results = [dev.infer(x) for x in Xte]
acc_v1 = np.mean([labels.index(lab) == t for (lab, _), t in zip(results, yte)])
review = [i for i, (_, c) in enumerate(results) if c < 0.6]
print(f"v1 on-device accuracy {acc_v1:.3f} | {len(review)} windows sent for review")
json.dump(dev.telemetry, open(ROOT / "reports/telemetry.json", "w"))# after retraining and exporting audio_v2 ...
make_manifest(2, EXP / "audio_v2_tf/audio_v2_full_integer_quant.tflite", labels, pipeline, EXP / "manifest_v2.json")
dev.install(EXP / "manifest_v2.json")
acc_v2 = np.mean([labels.index(dev.infer(x)[0]) == t for x, t in zip(Xte, yte)])
print(f"v1 {acc_v1:.3f} → v2 {acc_v2:.3f}")
bad = json.load(open(EXP / "manifest_v2.json")); bad["sha256"] = "0" * 64
json.dump(bad, open(EXP / "manifest_bad.json", "w"))
try:
dev.install(EXP / "manifest_bad.json"); rejected = False
except ValueError as e:
rejected = True; print("✔", e)NB51 needs data/processed/audio_v1.npz. Add one line at the end of NB32 to save it: np.savez(ROOT/"data/processed/audio_v1.npz", Xte=Xte, yte=yte, labels=np.array(labels)). Test windows are raw MFCC; normalization is inside the model.
Test Test it
assert (ROOT / "reports/profile.md").exists()
assert acc_v2 >= acc_v1, "v2 should be at least as good as v1"
assert rejected, "a corrupted model must be refused"
assert len(json.load(open(ROOT / "reports/telemetry.json"))) == len(Xte)
print("✅ device story works without any API")Done Done when
- profile.md has flash, RAM, operation count and latency for image, sensor, audio and detection models
- MCU numbers clearly marked "estimated"
- FakeDevice installs v1 from a manifest and runs int8 inference
- Low-confidence review list collected and used for retraining
- v2 installed; accuracy not worse
- Corrupted model refused; telemetry.json saved
If it breaks
| Symptom | Fix |
|---|---|
| thop reports 0 MACs for some layers | thop doesn't count every layer type (attention, for example). Treat Transformer MAC numbers as a lower bound. |
| On-device accuracy far below notebook accuracy | The DSP or normalization differs: the device must receive exactly what the training data looked like. |
| Input shape mismatch in infer() | Print dev.inp["shape"]; onnx2tf may have changed the layout, so transpose your features to match. |
Learn more
- thop (pytorch-OpCounter): counting operations in PyTorch models
- LiteRT for microcontrollers: overview
- The SOC API section of the 30-day plan: Month 2 puts this exact flow behind HTTP