Netron: the model viewer I open before every on-device deployment
A quantized model that loads without an error is not the same as a quantized model that's actually quantized. Netron is how I check.
The first time I shipped an on-device model into an Android app, I did everything the guide told me to. Ran post-training quantization, got a .tflite file that was roughly a quarter the size of the original, dropped it in assets/, wired up the interpreter, and it worked. Inference time barely moved. I spent two days assuming the phone was thermally throttling.
It wasn't. Half the graph had silently stayed in float32, because a couple of ops in the middle didn't have integer kernels and the converter had quietly inserted quantize/dequantize pairs around them. Nothing failed. Nothing warned me. The file was smaller and the compute was the same.
I found it by opening the model in Netron and looking at it. Ten minutes. Since then it's the first thing I do with any model that's going on a device.
What Netron is
Netron is a viewer for neural network and machine learning models, written by Lutz Roeder. You give it a model file, it renders the graph — every node, every tensor, every attribute — and lets you click through it.
That's it. It doesn't train, convert, optimise or run anything. It's a read-only window into a binary you'd otherwise be treating as a black box, and that turns out to be enormously useful when the thing you're debugging is the model itself rather than your code around it.
The format coverage is what makes it practical. It handles ONNX, TensorFlow Lite, PyTorch, torch.export, ExecuTorch, TorchScript, TensorFlow, Core ML, OpenVINO, Keras, Caffe, Darknet, Safetensors and NumPy, with experimental support for MLIR, JAX, GGUF, RKNN, ncnn, MNN, PaddlePaddle and scikit-learn.
For on-device work that list is almost exactly the set of formats you touch. TFLite and ExecuTorch for Android, Core ML for iOS, ONNX as the interchange format everything passes through, GGUF if you're putting an LLM on the phone, and ncnn / MNN / RKNN if you're on a vendor runtime.
Getting it running
Four ways, and I use three of them regularly.
Browser — netron.app. Drag the file in. Fastest option for something quick. I don't use it for client models on principle, and neither should you if the model isn't yours to share.
Desktop app — what I actually use day to day:
# macOS
brew install --cask netron
# Windows
winget install -s winget netron
Linux gets a .deb or .rpm from the releases page.
Python — this is the one worth knowing about, because it hooks into a conversion script:
pip install netron
# from the shell
netron model.tflite
Or from inside the script that just produced the model:
import netron
netron.start('model_int8.tflite')
I put that behind a flag at the end of my conversion pipeline. Convert, then look at what came out, immediately, before it gets committed. Catching a bad conversion at the moment it happens is much cheaper than catching it after the APK is built.
Where it earns its keep: quantization
This is the whole reason I care about the tool.
Quantization is a lossy transformation applied by a converter that makes a lot of decisions on your behalf and reports almost none of them. You hand it a float model and a representative dataset, it hands you back a file. Whether that file is actually integer end to end, what the ranges look like, whether ops got fused the way you expected — none of that is in the output log. It's in the graph.
Is the model actually quantized
Click any tensor in Netron and the properties panel shows its type. In a properly full-integer TFLite model, the weights and activations show as int8 or uint8, and the quantization parameters — scale and zero point — are listed alongside them.
What you're looking for is what I missed the first time: a run of QUANTIZE and DEQUANTIZE nodes sitting in the middle of the graph. Every one of those is a conversion back to float and out again, which means the op between them is running in float, which means you paid the accuracy cost of quantization and got none of the speed.
A handful at the input and output boundary is normal and expected. A cluster in the middle of your backbone is a bug.
Reading scale and zero point
These two numbers are how an int8 value maps back to a real one:
real_value = (int8_value - zero_point) * scale
Netron shows them per tensor, and for weights it'll show per-channel scales where the converter used them. Two things I check:
- The input and output tensors. You need these exact numbers in your Android code to feed the interpreter correctly. Getting them from the model file rather than hardcoding what you think they are has saved me from a very stupid class of bug — where the model is fine and the preprocessing is silently wrong, so accuracy is bad but not obviously broken.
- Absurd scale values. If a layer's scale is enormous relative to its neighbours, the calibration dataset probably didn't represent that layer's real activation range. That's usually the layer where accuracy is dying.
Per-tensor vs per-channel matters. Weight quantization per output channel keeps far more accuracy than a single scale across the whole tensor, especially for depthwise convolutions. Netron shows you which one you got. If you asked for per-channel and see one scale value, your converter didn't do what you asked.
Did the fusion happen
Conversion is supposed to fold BatchNorm into the preceding convolution and fuse activations into the op. When it works, you see a single CONV_2D node with fused_activation_function: RELU6 in its attributes. When it doesn't, you see conv, then a separate mul, then an add, then a relu — four dispatches where there should be one.
This usually means something upstream in the export was unusual. It's a five-second check in Netron and a long afternoon to discover by profiling.
Where it earns its keep: getting a model onto a device
Knowing your input and output signature
Before I write a single line of inference code I open the model and note down, for every input and output: name, shape, dtype, and quantization params.
NHWC or NCHW. Whether the batch dimension is 1 or dynamic. Whether the model expects [0,255] uint8 or normalised float. How many outputs there actually are and in what order — detection models in particular often have four output tensors and the ordering is not always what the docs claim.
Reading it off the graph takes two minutes and removes all the guesswork from the Kotlin side.
Checking delegate and accelerator compatibility
This is the one that most directly affects whether your model runs fast on a phone. GPU delegates, NNAPI, NPU runtimes — they all support a subset of operators, and anything outside that subset falls back to CPU.
The fallback isn't just slower. If the unsupported op sits in the middle of the graph, execution has to bounce off the accelerator, run on CPU, and come back, and the transfer cost can make the whole thing slower than pure CPU would have been.
So I list the operators in the graph and compare against the delegate's supported set. If there's one odd op — a custom layer, an exotic resize mode, a gather nobody needed — it's often possible to replace it at the model level and unlock the accelerator for the entire network. Netron is how you find that op.
Sizing before shipping
The properties panel shows weight tensor shapes and types, which tells you where the file size actually lives. Nine times out of ten it's one or two large dense layers at the head, and it's worth knowing that before you argue with someone about the APK size budget.
Working with GGUF and on-device LLMs
The GGUF support is experimental but good enough for what I need it for: checking which quantization scheme a downloaded model actually uses. There's a real difference between Q4_K_M and Q8_0 in both memory footprint and quality, and filenames on model hubs are not always accurate. Open it, look at the tensor types, believe the file rather than its name.
Comparing before and after
My most common actual workflow is two windows side by side. Original float model on the left, converted or quantized model on the right. Walk the graph and see what changed — what got fused, what got split, what got inserted, what disappeared.
Every conversion tool transforms the graph in ways it doesn't document. Seeing both versions at once is the only reliable way I've found to know what a converter actually did to my model.
What it won't do
Being fair about the limits, because I've seen people expect too much of it:
- It's static. No profiling, no per-layer latency, no memory numbers. For that you need the runtime's own benchmark tooling.
- It won't tell you whether accuracy dropped. Only your eval set does that. Netron explains why it dropped, once you already know it did.
- Very large graphs get sluggish and hard to navigate. Transformer models with hundreds of repeated blocks are a lot of scrolling.
- Experimental formats are exactly that. Some fields don't render, and you'll occasionally need a format-specific tool for the last detail.
None of that bothers me much, because it does the one thing I need it to do and does it instantly.
The short version
If you're putting models on phones, the gap between "the converter ran" and "the model is correct and fast" is wider than the tutorials suggest, and almost everything in that gap is visible in the graph. Netron is free, installs in one command, and turns a binary you were trusting on faith into something you can actually check.
Convert your model, open it, and look at it before you ship it. That's the whole habit.
github.com/lutzroeder/netron · netron.app
← All posts