An integer machine bolted to a floating-point one
2026-11-04
One lecture. Five parts, then a lab on Friday that assumes you were here.
Parts 4 and 5 are the point. Parts 1–3 are what you have to know to believe them.
Two questions we will answer:
We have spent the term on the CPUs. The die has more than CPUs:
| Block | What it is |
|---|---|
| C906 × 1 | 1 GHz RV64GCV, runs Linux 1050 MHz measured |
| C906L × 1 | the little core, handed to remoteproc for Arduino |
| 8051 | always-on domain, no firmware shipped |
| TPU | Cvitek bm1822, 0.5 TOPS INT8 |
| VPU | H.264/H.265 encode and decode |
| ISP, VPSS | camera front end |
Half a gigabyte of DRAM, ~3 W, and an accelerator with a clock of its own: fpll/3 = 500 MHz, independent of the CPU’s 1050.
The GPU deck had two columns. This machine is the third point of the triangle.
| CPU | GPU | NPU | |
|---|---|---|---|
| Area goes to | not stalling | having something else to run | the one operation |
| Programmable | yes | yes | no |
| Programmer supplies | an algorithm | parallelism | a graph |
| Breaks when | working set outgrows cache | work diverges | the op is not on the list |
| Degrades | gracefully | catastrophically | absolutely |
A CPU hides latency. A GPU exposes it and makes you fix it. An NPU deletes the question — there is no latency to hide because there is no instruction stream.
libcvikernel.so exports 313 symbols. They are not instructions. They are operations, and the list is short enough to read:
That is the machine. There is no thirteenth thing.
Compare with the C906 next to it: RV64GCV, and the “G” alone is dozens of instructions before you get to the vector unit. This is a machine whose entire vocabulary fits on one slide.
What is missing from that list is more interesting than what is on it.
| Missing | Consequence |
|---|---|
| any scalar unit | no scalar code at all |
| branch / control flow | no data-dependent control flow |
| divide | no division, anywhere |
| exp, log, sqrt, tanh | nonlinearities are table lookups |
lookup_interp_table is how you get exp. You precompute a table on the host, the hardware interpolates, and that is your sigmoid.
So softmax is not a primitive — it is a chain of max, subtract, LUT-exp, sum, divide. And divide does not exist, so it is a reciprocal LUT and a multiply.
The entire on-chip memory is 32 KB, in 8 banks of 4 KB at 0x0C000000.
| Size | |
|---|---|
| TPU local memory (lmem) | 32 KB |
| C906 L1 data cache | 32 KB |
| C906 L2 | 64 KB |
| The vocoder’s smallest bucket | 7.6 MB |
The accelerator’s entire memory is the size of one CPU cache. And unlike a cache, it is software-managed: it is a scratchpad, there is no cache controller, and if you want something in it you copy it there.
That copy is tdma at 0xc100000 — a DMA engine with its own registers. The memory schedule is part of the program. On a GPU the hardware hides this; here you write it.
The quantisation enum has four values:
Ask for FP32 and the compiler does not warn you. It stops:
So quantisation is not an optimisation you apply at the end. It is the interface. There is no path from your float model to this chip that does not pass through a conversion, and the conversion is where the accuracy goes.
An easy and wrong conclusion: “bf16 is just a compact weight format, the maths happens in int8.” It is not.
| Operand format | bf16 (1 sign, 8 exponent, 7 mantissa) |
| Accumulator | fp32 |
| Output | rounded to bf16 |
Test: multiply two matrices of small integers in bf16 on the NPU, and compare against bf16_round(exact_int64_result) computed on the host.
0 of 65,536 elements differ.
Max absolute error against the exact answer is 4.0, near a value of 1473 — which is exactly half a bf16 ULP at that magnitude. That is not a bug. That is what bf16 costs.
Bought
Paid
The fixed-function bet buys efficiency by giving up generality. The bill arrives as a compiler you did not write and cannot fix.
Three dates, and the same argument each time.
| Year | The claim | |
|---|---|---|
| Kung & Leiserson, systolic arrays | 1978 | Put the dataflow in the wires; let values meet in the middle |
| DianNao | 2014 | A fixed-function neural accelerator beats a general core on energy per op |
| Google TPU v1 | 2017 | Ship it: 256 × 256 MAC array, int8 only, no caches |
The idea is fifty years old. What changed is not the idea — it is that the workload finally became regular enough to schedule at compile time.
Above the line you own. Below the line you do not. The line sits in an unusual place: the compiler is open and everything under it is a blob.
A .cvimodel is not a program. It is a Model → Program → Routine → Tensor graph plus a command buffer: a pre-computed list of tensor operations with their DMA schedule. model_tool --info reports what it will need:
That memory line is the number to read before you run anything. It is the working set the board has to find, and Part V is about what happens when it cannot.
No. And it is worth being precise about what that means.
| Attempt | What happened |
|---|---|
| NNEF (Khronos) | a standard exchange format, no runtime. Effectively dead |
| ONNX Runtime | has execution providers — there is none for cv181x |
| OpenCL / Vulkan compute | not supported by this runtime at all |
| Caffe, TFLite | Caffe yes, TFLite no — check model_deploy --help |
Every vendor has the same shape: RKNN (Rockchip), QNN (Qualcomm), Edge TPU (Google), Ethos-U (Arm). A compiler, a blob format, a runtime, a lock-in.
ONNX is the only standardised piece, and it is standardised at the input to a compiler that belongs to one vendor.
The board ships about 60 sample binaries in /mnt/system/usr/bin/ai. None of them can run on our image.
Every one of them goes through the TDL SDK, which builds a VPSS frame before it loads a model. Our image deliberately does not load the camera stack, so there is no VPSS, so there is no entry point.
Everything a working platform looks like:
And the first thing it does is die on 4 KiB:
Not weights. Not a tensor. Not a command buffer. The smallest allocation the API can express.
A recoverable ENOMEM is turned into SIGABRT. Which means:
CVI_NN_RegisterModel cannot report out-of-memory to its caller.A library that asserts on a recoverable condition is not a library you can write defensively against. It is a library you can only avoid provoking.
A fixed-function accelerator addresses memory by physical address. There is no IOMMU on this part, so the memory it touches must be physically contiguous — and physically contiguous memory cannot be conjured at runtime.
So the carveout is taken before the kernel’s page allocator exists, and reserved-memory is the only API. There is no module parameter, no sysfs knob, no runtime override. cvitek_ion.c reads the size at probe time and that is that.
And “a heap that allocates” is not “a heap that works”:
| heap | mmap + write | paddr |
|---|---|---|
ion_system_heap |
ok | 0x0 |
| unnamed | ok | 0x0 |
| unnamed | ENOMEM |
— |
Two of three heaps hand back memory a DMA engine cannot use.
The zero-sized carveout was not an accident. It was a build-time decision:
The SG2000 has one little C906L that can either run the vendor camera stack or be handed to Arduino via remoteproc. S00kmod decided which by asking how big the carveout was:
The NPU needs the carveout. Arduino needs the C906L. Nothing about those two facts should have been connected.
The fix was four small changes to the image build, not to the board:
| file | change |
|---|---|
configs/duos/settings.mk |
new SECOND_CPU=arduino-tpu, ION_SIZE=64 |
configs/chip/sg200x/chip.mk |
inject the rproc fragment for the new personality |
scripts/addons/load-systemko/S00kmod |
gate the camera stack on /boot/arduino |
scripts/addons/preconfigure/addon.mk |
write /boot/arduino for both flavours |
The condition became “is this an Arduino build”, which is what it always meant, instead of “is the carveout non-zero”, which was a proxy that happened to correlate.
Caution. The first attempt edited the ion size in the .dtb on the running board and rebooted it. The board never came back and had to be reflashed. A boot-time reservation is not something to patch on a machine you cannot put a console on.
You cannot get a clean answer from float data. So we chose data where both representations are exact:
And only compare like with like:
| NPU | CPU |
|---|---|
| bf16 | bfv (RVV) / bfs (scalar) |
| int8 | i8v (RVV) / i8s (scalar) |
An earlier version of this work had an “NPU bf16 vs CPU int8” column. It was wrong, and its conclusions were withdrawn. bf16 and int8 compute different functions — one rounds to bf16 on output, the other quantises to int8.
N × N matrix multiply, milliseconds measured:
| N | NPU bf16 | CPU bfv | NPU int8 | CPU i8v | ||
|---|---|---|---|---|---|---|
| 32 | 0.25 | 0.18 | CPU 1.4× | 0.34 | 0.05 | CPU 6.8× |
| 64 | 0.28 | 1.49 | NPU 5.3× | 0.56 | 0.38 | CPU 1.5× |
| 128 | 0.39 | 11.87 | NPU 30× | 1.36 | 3.93 | NPU 2.9× |
| 256 | 0.95 | 114.41 | NPU 120× | 4.65 | 26.43 | NPU 5.7× |
| 512 | 4.49 | 2295.38 | NPU 511× | 18.25 | 220.76 | NPU 12× |
It goes both ways. The accelerator loses below N ≈ 64 (bf16) and N ≈ 128 (int8), because it pays a fixed cost before it computes anything.
The bf16 column ends at 511×. That number is real and it is also partly a lie.
| N | NPU bf16 | CPU bfv | ratio |
|---|---|---|---|
| 256 | 0.95 | 114.41 | 120× |
| 512 | 4.49 | 2295.38 | 511× |
| growth 256→512 | 4.6× | 20× |
N³ predicts 8× for both. The NPU did 4.6×. The CPU did 20×.
The CPU did not get slower because the problem got harder — it fell out of its cache. Part of the widening ratio is the CPU’s problem, not the NPU’s triumph.
The reason we could use integer data: the outputs are checkable.
| Implementation | Reference | Result |
|---|---|---|
CPU i8s / i8v |
exact int64 A@B |
exact, max err 0 |
CPU bfs / bfv |
bf16_round(exact) |
exact, max err 0 |
| NPU bf16 | bf16_round(exact) |
exact, max err 0 |
| NPU int8 | exact int64 A@B |
max err 11.0 |
All five implementations produce bit-identical output at N=256 and N=512 (for int8, exact; for bf16, bf16_round(exact)).
The NPU is not less accurate. It computes the same function, 120× faster.
The exception is int8: max error 11.0 against an output quantisation step of 11.49 — about one step. A second int8 stage (AC_MatMul_si8 → C_MatMul, qscale 11.49) sits between the matmul and the fp32 output.
int8 is slower than bf16 on this NPU.
| N | bf16 | int8 | penalty |
|---|---|---|---|
| 32 | 0.25 | 0.34 | 1.4× |
| 64 | 0.28 | 0.56 | 2.0× |
| 128 | 0.39 | 1.36 | 3.5× |
| 256 | 0.95 | 4.65 | 4.9× |
| 512 | 4.49 | 18.25 | 4.1× |
This inverts the marketing. INT8 is the headline “0.5 TOPS” mode and here it is the slow path. The consequence is large: the NPU’s advantage over the CPU is 511× in bf16 and only 12× in int8.
Hypotheses, none confirmed: an extra requantise routine (the tensor map shows two routines for int8, one for bf16); a different tiling; a different accumulator path. The requantise stage is over 65k elements and should be cheap, so I do not believe that explanation. Cause not identified.
| Stage | Where | ms |
|---|---|---|
phonemise (espeak-ng) |
CPU | ~140 |
encoder (duration.onnx) |
CPU, onnxruntime | ~230 |
| vocoder (bucketed, resident) | NPU | ~384 |
| total | ~760 |
For 1.82 s of audio. That is 2.4× faster than real time, and the compute-heavy stage is the one on the accelerator.
The split is not a design choice. It is forced by the shapes:
sum(predicted durations) — data-dependent, so no static graph can express it. Range.cpp:75, UNREACHABLE.One model per shape is useless when the length depends on the text. The answer is a small set of compiled shapes, and pick the smallest that fits.
| mel_len | ms | audio | real time | model |
|---|---|---|---|---|
| 128 | 192.6 | 1.37 s | 7.1× | 7.6 MB |
| 256 | 379.3 | 2.73 s | 7.2× | 8.5 MB |
| 512 | 800.2 | 5.46 s | 6.8× | 11.3 MB |
Linear in mel_len, so the vocoder is compute-bound and the fixed cost is amortised by 128 frames.
The cost is at the boundary. A 258-frame sentence lands in the 512 bucket: 2× the work for 1.01× the audio. Measure it yourself — the same sentence through the live server shows mel_len 258, vocoder 807 ms.
The first version shelled out to a runner per request. Same model, same board:
| ms | |
|---|---|
| per-request (shell out, register, run, exit) | 3127 |
| first call with a resident handle | 519 |
| second call, resident | 385 |
CVI_NN_RegisterModel allocates the weights and the command buffer in ION, every time you call it. So a per-request registration pays for the load on every request.
An 8× difference from a purely structural change — no arithmetic changed, no model changed. Per-request registration makes the demo feel broken.
The compiler rejected four channel flips:
They came from /flow/flows.{1,3,5,7}/Slice — steps=[-1] on axis 1. The steps tensor comes from a Constant node, not an initializer, so scanning initializers misses it.
The mistake that cost a day. Replace each flip with Gather(axis=1, reversed_indices). It compiles. It runs at 7× real time.
TPU-MLIR’s Gather on cv181x silently ignores the index vector. It is a pass-through. It fails open.
The fix exploits what the backend does support — Reverse on axis 0:
Verified in isolation first (ch0=127, ch127=0), then applied to the vocoder.
| test | wave corr | spectrum corr |
|---|---|---|
| constant inputs | +0.9983 | 0.9993 |
| random inputs | +0.9311 | 0.9819 |
| full text pipeline | +0.9468 | 0.9774 |
rms 0.0604 (reference) vs 0.0625 (NPU). What remains is bf16 rounding noise — which is what bf16 should cost.
Same sentence, same encoder output, same padding, two vocoders. The only difference is bf16.
fp32 on the C906 (onnxruntime) |
116,248 ms |
| bf16 on the TPU | 807 ms |
| wave correlation | 0.9252 |
| spectrum correlation | 0.9675 |
Reference — fp32 on the CPU
NPU — bf16 vocoder
“This is what a bf16 accelerator costs you, audibly.” If you cannot tell them apart, that is the answer — and it is a better answer than the correlation number.
| Machine | Accelerator | Attach | Precision | Programmable |
|---|---|---|---|---|
| sg2000 | bm1822 TPU · 0.5 TOPS |
on-die | int8, bf16 | no |
| celedyr | Polaris 10 · 2016 | PCIe slot | fp32 | yes |
| kraken | MI50 · 2018 | PCIe slot | fp32 | yes |
| typhon | 8 × Gaudi2 · 2022 | PCIe + switch | bf16 | yes |
| unicorn | RX 7600 · 2023 | PCIe slot | fp32 | yes |
| vampire | Radeon 8060S · 2025 | on-die, still a PCIe endpoint | fp32 | yes |
| pegasus | Arc Battlemage · 2025 | PCIe slot | fp32 | yes |
Read the last column down. One machine in this table cannot be programmed, and it is the one on your desk.
The sg2000 counterfactual, in one line: to put a programmable accelerator on this die you would need a bus (it has none above USB 2.0), address space (a 4 GB map cannot hold a 16 GB BAR), and coherency (it has neither). It would be a different chip.
Gather fails open, and a pass-through that runs at 7× real time is worse than a rejection.The interesting engineering is not in the accelerator. It is in the two interfaces on either side of it: the compiler above, and the memory below.
There is a lab on Friday. It assumes Parts I–V of this deck.
The lab is not “make the NPU go”. It goes. The lab is “find the fixed cost, find the crossover, and explain both” — the measurements in Parts VI and VII, reproduced by you, on hardware you can touch.
asserts on ENOMEM. Design the API you would have written instead, and say what the caller can now do that it could not.Gather fails open. How would you test a compiler to find that class of bug before it reaches a customer?Every one is answerable from this deck. None has a single right answer.
Primary sources
QuantType, the runtime API. Distributed by SFTP, not on the web; the copy in docs/ is the one to read.cviruntime, cvikernel, cvibuilder, cvimath. 75,894 lines; the TPU sections are the ones that matter.Appx.02_cv18xx_guide.rst) which documents this exact chip family.cvimodel.fbs — the format. Version 1.4.0, and the schema is the clearest documentation of what a cvimodel actually is.Measured on our hardware
probe.py and ion_probe.py — allocation, heaps, paddrrun_model.py — the five-call API, latency, CVI_NN_GetModelVersionmm_cpu.c, mm_peak.c, run_mm.py — the Part VI tablespipeline.py, vocoder.py, server.py — the Part VII numbersrdcycle for the 1050 MHz C906 clockCompanion
../gpu-slides/ · ../memory-slides/ · ../power-slides/ · ../hints/tpu.mdCS 4250 · The Neural Processor