The Neural Processor

An integer machine bolted to a floating-point one

CS 4250 — Computer Architecture

2026-11-04

Roadmap

One lecture. Five parts, then a lab on Friday that assumes you were here.

  1. The machine — what it is, and what it cannot do
  2. The stack — five layers, and why there is no standard
  3. The bring-up — a device that was present and unusable
  4. The numbers — one matmul, measured on both machines
  5. The demo — text to speech, split across two processors

Parts 4 and 5 are the point. Parts 1–3 are what you have to know to believe them.

What We Will Not Do Today

  • No neural networks. No layers, no backprop, no training. This is a computer architecture lecture that happens to be about a chip used for inference.
  • No CUDA-equivalent tutorial. There is nothing to write. That is the subject, not an omission.
  • No camera pipeline. The vendor’s samples all want one; we deliberately do not have one.
  • No toolchain install. The compiler runs on your laptop and it is a six-hour afternoon. Friday’s lab uses models that are already compiled.

Two questions we will answer:

  1. What does a fixed-function accelerator actually compute?
  2. What does it cost you to use one?

Where We Are

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.

Part I — The Machine

Two Ways to Spend a Transistor, Again

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.

The Op List Is the Machine

libcvikernel.so exports 313 symbols. They are not instructions. They are operations, and the list is short enough to read:

convolution            depthwise_convolution    matrix_multiplication
max_pooling            min_pooling              avg_pooling
element_wise_add       element_wise_mul         element_wise_ge
lut / lookup_interp    shuffle                  tensor_copy (g2l, l2g, l2l)

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.

No Divide, No Branch

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.

32 KB

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.

Part II — Precision Is the Interface

int8 and bf16, and Nothing Else

The quantisation enum has four values:

typedef enum { NONE, BF16, INT8_SYM, INT8_ASYM } QuantType;

Ask for FP32 and the compiler does not warn you. It stops:

ASSERT INFO: Not Implemented
  at .../TopLowering.h:366

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.

bf16 Is Not Storage-Only

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.

What We Bought / What We Paid

Bought

  • 0.5 TOPS INT8 on a ~3 W budget, on-die, no bus.
  • Weights compress 4× against fp32, which matters when the whole machine has half a gigabyte of DRAM.
  • Deterministic: fixed shapes, fixed schedule, no branch predictor to mispredict.

Paid

  • No FP32 at all. Every model is a quantisation decision.
  • Static shapes. One compiled model per shape.
  • 32 KB of scratchpad and no cache.
  • No standard. The compiler, the runtime, and the driver are one vendor’s.

The fixed-function bet buys efficiency by giving up generality. The bill arrives as a compiler you did not write and cannot fix.

Part III — Archaeology

Archaeology: The Fixed-Function Idea

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.

Part IV — The Stack, and the Absence of a Standard

Five Layers

  your model            ONNX
        │
  TPU-MLIR              the compiler        ← open source
        │
  .cvimodel             a schedule, not code
        │
  CVI_NN  /  CVI_RT     model API / context API
        │
  cvikernel             the 313 operations
        │
  /dev/cvi-tpu0         the driver
        │
  bm1822                tiu @ 0xc101000, tdma @ 0xc100000

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.

ONNX In, cvimodel Out

model_transform --model_name dec --model_def decode.onnx \
    --input_shapes [[1,128,224],[1,128,224],[1,1,224],[1,128,224],[1]] \
    --mlir dec.mlir

model_deploy --mlir dec.mlir --chip cv181x --quantize BF16 \
    --model dec_bf16.cvimodel

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:

Cvimodel Version: 1.4.0
CviModel Need ION Memory Size: 8.5 MB

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.

Is There a Standard?

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 Samples Are All Camera-Shaped

The board ships about 60 sample binaries in /mnt/system/usr/bin/ai. None of them can run on our image.

$ sudo sample_img_object_detection YOLOV5_DET_COCO80 . bus.jpg
[base_image.cpp:163] [E] allocate memory failed

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.

The only way in is the bare API, and it is five calls:

CVI_NN_RegisterModel(path, &model);
CVI_NN_GetInputOutputTensors(model, &in, &n_in, &out, &n_out);
memcpy(in[0].sys_mem, data, in[0].mem_size);
CVI_NN_Forward(model, in, n_in, out, n_out);
CVI_NN_CleanupModel(model);

Part V — Present and Unusable

The Device That Was Present and Unusable

Everything a working platform looks like:

$ ls /dev/cvi-tpu0              crw------- root root 245, 0
$ lsmod | grep soph_tpu         soph_tpu  28822  0
$ ls /mnt/system/lib/           libcviruntime.so  libcvikernel.so  libcvimath.so
$ nm -D --defined-only libcvikernel.so | wc -l
313

And the first thing it does is die on 4 KiB:

CVI_RT_Init -> rc=0
CVI_RT_MemAlloc(4096) ...
ion ioctl fail:: Cannot allocate memory
python3: cvi_device_mem.cpp:451: mem_alloc_raw: Assertion `0' failed.
Aborted (core dumped)          # exit 134

Not weights. Not a tensor. Not a command buffer. The smallest allocation the API can express.

assert(0)

// cviruntime/src/soc/common/cvi_device_mem.cpp:451
void* CviDeviceMem::mem_alloc_raw(bmctx_t ctx, size_t size) {
    ...
    assert(0);          // ← reached on ENOMEM
}

A recoverable ENOMEM is turned into SIGABRT. Which means:

  • CVI_NN_RegisterModel cannot report out-of-memory to its caller.
  • It cannot return an error code, because it will not return.
  • Every line of error handling above it is unreachable code.

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.

ION, Carveout, and No IOMMU

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.

reserved-memory {
    ion { compatible = "ion-region"; size = <0x00 0x4000000>; };  /* 64 MiB */
};

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.

One Knob, Two Meanings

The zero-sized carveout was not an accident. It was a build-time decision:

# configs/duos/settings.mk
SECOND_CPU ?= camera
ifeq ($(SECOND_CPU),arduino)
ION_SIZE=0

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:

if [ $ion_size != 0 ]; then insmod soph_vi.ko; insmod soph_vpss.ko; ...; fi
insmod soph_tpu.ko        # unconditional
ION_SIZE > 0  ⇒  carveout exists  ⇒  camera stack loads
                                  ⇒  vendor RTOS takes the C906L
                                  ⇒  Arduino personality is dead

The NPU needs the carveout. Arduino needs the C906L. Nothing about those two facts should have been connected.

Coupling Is the Bug

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.

Part VI — The Microbenchmark

How to Measure an Accelerator

You cannot get a clean answer from float data. So we chose data where both representations are exact:

  • Operands are integers in [-8, 7].
  • bf16 represents them exactly (7-bit mantissa, small integers).
  • int8 represents them exactly.
  • The reference is computable in int64, exactly, on the CPU.

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.

The Crossover

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 Ratio That Grows, and Why You Shouldn’t Believe It

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.

Same Answer, Different Speed

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.

The Measurement That Surprised Us

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.

Part VII — The Demo

Text to Speech, Split by Shape

text → espeak-ng (IPA) → token ids → encoder (CPU) → vocoder (NPU) → WAV
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:

  • the encoder’s output length is sum(predicted durations) — data-dependent, so no static graph can express it. Range.cpp:75, UNREACHABLE.
  • the vocoder needs static shapes, which bucketing supplies, and it is the expensive part.

Bucketing

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.

Keep It Resident

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 Bug That Failed Open

The compiler rejected four channel flips:

TgReverseKernel.cpp:18: Assertion `(axis == 0 || axis == 3)` failed

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.

expected:  channel 0 = 127 ... channel 127 = 0     (reversed)
NPU:       channel 0 =   0 ... channel 127 = 127   (identity!)

TPU-MLIR’s Gather on cv181x silently ignores the index vector. It is a pass-through. It fails open.

The Bug That Failed Open (cont.)

The fix exploits what the backend does support — Reverse on axis 0:

Transpose(1,0,2) → Slice(axis 0, step −1) → Transpose(1,0,2)

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.

The Demo

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.

Part VIII — Synthesis

Seven Machines, Seven Answers

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.

What We Actually Learned

  1. Fixed-function means no ISA. The op list is the machine, and an operation that is not on it does not run — not slowly, not at all.
  2. Precision is the interface. There is no FP32 path. bf16 is real compute (fp32 accumulate, bf16 round, bit-exact), and int8 is a different function.
  3. The compiler is the open piece and the bugs live there. Gather fails open, and a pass-through that runs at 7× real time is worse than a rejection.
  4. A device can be present and unusable. Driver loaded, node present, 313 symbols exported, and the first 4 KiB aborts the process. Presence is not usability.
  5. Coupling is the bug. Two independent requirements expressed as one test is the failure mode that costs days, and it is not visible in any log.
  6. Fixed cost sets the crossover. ~0.3 ms per inference means the accelerator loses below N≈64, and the number is a property of the launch, not the arithmetic.
  7. Measure like with like, or you measure nothing. The retracted column in Part VI is on the slide because the mistake is instructive.

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.

Friday

There is a lab on Friday. It assumes Parts I–V of this deck.

  • The boards are already on the image that works. Do not flash, do not edit a device tree, do not reboot.
  • You will run models that are already compiled. The compiler stays on my laptop — the install is a six-hour afternoon and it is not the exercise.
  • Bring a way to listen to a WAV file.

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.

Discussion Prompts

  • int8 is the advertised mode and it is 4.9× slower than bf16 here. Design an experiment that distinguishes “extra requantise stage” from “different tiling” from “measurement artefact”.
  • The NPU has 32 KB of scratchpad and no cache. Why not a cache? Argue both sides, then say what a 4 KB cache would cost you in area and in predictability.
  • A 258-frame sentence pays for 512 frames. What would you change — more buckets, a different bucket ladder, or a dynamic-shape mechanism — and what does each cost?
  • The vendor library 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?
  • The encoder is on the CPU because its output length is data-dependent. Is there a model architecture that would let it move to the NPU?

Every one is answerable from this deck. None has a single right answer.

Refs

Primary sources

  • CVITEK TPU SDK Development Guide — the operator list, QuantType, the runtime API. Distributed by SFTP, not on the web; the copy in docs/ is the one to read.
  • SG200x Software Developer’s Manual — cviruntime, cvikernel, cvibuilder, cvimath. 75,894 lines; the TPU sections are the ones that matter.
  • TPU-MLIR — the compiler, and its CV18xx appendix (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.
  • Kung & Leiserson (1978), Systolic Arrays for VLSI; Chen et al. (2014), DianNao; Jouppi et al. (2017), In-Datacenter Performance Analysis of a TPU.

Measured on our hardware

  • probe.py and ion_probe.py — allocation, heaps, paddr
  • run_model.py — the five-call API, latency, CVI_NN_GetModelVersion
  • mm_cpu.c, mm_peak.c, run_mm.py — the Part VI tables
  • pipeline.py, vocoder.py, server.py — the Part VII numbers
  • rdcycle for the 1050 MHz C906 clock

Companion

  • Reference Notes · ../gpu-slides/ · ../memory-slides/ · ../power-slides/ · ../hints/tpu.md