The Neural Processor — Reference Notes
Condensed notes for the slides. Everything here is either measured on duos-frog-9f7f (SG2000, SECOND_CPU=arduino-tpu) or quoted from a vendor document; the two are marked.
Numbers marked measured came from a command on the board. Numbers marked spec are from a datasheet or manual and were not reproduced.
Part I — The Machine
The part
| Vendor name | Cvitek TPU |
| Internal name | bm1822 (useless as a search term — it collides with materials science and finance) |
| Claimed peak | 0.5 TOPS INT8 (spec) |
| Clock | fpll/3 = 500 MHz, independent of the CPU |
| Registers | tiu @ 0xc101000, tdma @ 0xc100000 |
| Local memory | 32 KB, 8 banks × 4 KB @ 0x0C000000 |
| Device node | /dev/cvi-tpu0 |
| Driver | soph_tpu.ko |
| Runtime | libcviruntime.so, libcvikernel.so, libcvimath.so |
Symbols in libcvikernel |
313 (measured) |
| CPU next to it | C906, RV64GCV, 1050 MHz measured with rdcycle |
The C906 is RVV 0.7.1, not 1.0. Debian gcc-14 with -march=rv64gcv emits RVV 1.0 and gives you SIGILL. Use the Xuantie toolchain with -march=rv64imafdcv0p7 -mabi=lp64d. Intrinsics are compiler builtins, not library functions; GCC 10 does not auto-vectorise here.
The operation list
This is the machine. There is no thirteenth thing.
convolution depthwise_convolution matrix_multiplication
max_pooling min_pooling avg_pooling
element_wise_add element_wise_mul element_wise_ge
lut / lookup_interp_table shuffle
tensor_copy (g2l, l2g, l2l) tensor_copy_stride
The 313 exported symbols are variants of these: per-dtype, per-layout, per-shape entry points.
What is missing
| Missing | Consequence |
|---|---|
| any scalar unit | no scalar code |
| branch / control flow | no data-dependent control flow |
| divide | none, anywhere |
exp, log, sqrt, tanh |
nonlinearities are interpolated table lookups |
softmax is therefore not a primitive. It decomposes into max, subtract, LUT-exp, sum, and a reciprocal — and the reciprocal is a LUT and a multiply, because there is no divide.
Memory
32 KB of software-managed scratchpad. No cache.
There is no tag array, no replacement policy, and no cache miss — because there is no cache. The compiler emits g2l (global to local) and l2g copies around each operation, and the memory schedule is part of the compiled artifact.
The comparison that matters: the C906 next door has a 32 KB L1 and a 64 KB L2. The accelerator’s entire memory is the size of one CPU cache. Every model of any size is therefore a streaming problem — tile the computation so each tile’s working set fits in 32 KB, and DMA tiles in and out.
This is the GPU deck’s “expose or hide” decision taken to its conclusion, with two orders of magnitude less memory than a compute unit’s LDS.
CV181X_HW_NPU_SHIFT = 3 and EU_SHIFT = 4 give 8 × 16 = 128 lanes. That does not obviously produce 0.5 TOPS at 500 MHz and the discrepancy is unresolved; do not quote a reconciliation.
The three-way comparison
| CPU | GPU | NPU | |
|---|---|---|---|
| Area goes to | not stalling | something else to run | the one operation |
| Programmable | yes | yes | no |
| Programmer supplies | an algorithm | parallelism | a graph |
| Breaks when | cache too small | work diverges | op not on the list |
| Degrades | gracefully | catastrophically | absolutely |
The last row is the one that matters. A slow CPU program is slow. A badly shaped GPU kernel is 16× slow. An NPU given an operation it does not have does not run at all — no emulation, no fallback, no “it will just be slower”.
Part II — Precision
The quantisation enum
typedef enum { NONE, BF16, INT8_SYM, INT8_ASYM } QuantType;There is no FP32. --quantize F32 fails at lowering:
ASSERT INFO: Not Implemented
at .../TopLowering.h:366
Note that it is a lowering failure, not a parse failure: the graph type-checks and then cannot be emitted.
So quantisation is not an optimisation applied at the end. It is the interface.
bf16 is real compute
| Operand | bf16 — 1 sign, 8 exponent, 7 mantissa |
| Accumulator | fp32 |
| Output | rounded to bf16 |
Evidence. Multiply matrices of small integers in bf16 on the NPU and compare against bf16_round(exact_int64) computed on the host: 0 of 65,536 elements differ (measured). 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.
The test is the argument. If the accumulator is fp32 and rounding happens once, the output must equal bf16_round(exact). If it accumulated in bf16, or rounded per partial sum, it would drift. It matches exactly.
An earlier version of these notes said bf16 was storage-only. That was wrong.
What you pay
- No FP32. Every model is a quantisation decision.
- Static shapes. One compiled model per shape.
- 32 KB of scratchpad, no cache.
- No standard. Compiler, runtime and 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
Not examinable. Three dates, one argument.
| Year | The claim | |
|---|---|---|
| Kung & Leiserson, Systolic Arrays for VLSI | 1978 | Put the dataflow in the wires; values meet in the middle |
| DianNao (Chen et al., ASPLOS) | 2014 | A fixed-function accelerator beats a general core on energy per op |
| TPU v1 (Jouppi et al., ISCA) | 2017 | Ship it: 256 × 256 MAC array, int8 only, no caches |
Kung & Leiserson is the origin: values flow between processing elements on a fixed rhythm, so each element does a multiply-add per beat and never touches memory. The name is a physiology metaphor.
DianNao is the paper that made the argument in energy terms rather than FLOPs, and its central observation — that for a neural accelerator the memory system dominates energy by an order of magnitude, so the design work is buffering and reuse, not arithmetic — is exactly the 32 KB slide.
TPU v1 is the same design point as bm1822, four years earlier and ~400× bigger.
The idea waited fifty years for a workload regular enough to schedule at compile time. That is a statement about workloads, not architectures — the same reason Itanium’s static scheduling failed and Groq’s works.
Part IV — The Stack
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 it you do not. The line is in an unusual place: the compiler is open and everything under it is a blob.
On a GPU the ISA is public and the driver is closed. Here the compiler is the hackable piece, so debugging means reading the emitted schedule and inferring what the hardware did.
The compiler
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- Input: ONNX or Caffe. Not TFLite. PyTorch goes via
jit.trace. - Quantisation: INT8 (symmetric or
--asymmetric), BF16, F16. No FP32. - Shapes: static only.
--merge_weightplusmodel_tool --combineshares weights across several shapes. - Chips:
cv180x,cv181x(ours),cv182x,cv183x,cv184x,cv186x.
model_tool --info reports what you need before you run anything:
Cvimodel Version: 1.4.0
CviModel Need ION Memory Size: 8.5 MB
Version 1.4.0 is emitted by TPU-MLIR 1.30.2 and accepted by the board’s 2024.12 runtime — the format has been stable across a two-year gap. CVI_NN_GetModelVersion on the board tells you what the runtime accepts.
A .cvimodel is Model → Program → Routine → Tensor plus a command buffer: a pre-computed list of tensor operations with their DMA schedule. It is not a program. There is no branch, no loop, no call.
Is there a standard?
No.
| Attempt | Outcome |
|---|---|
| NNEF (Khronos) | an exchange format with no runtime. Effectively dead |
| ONNX Runtime | has execution providers — none for cv181x |
| OpenCL / Vulkan compute | not supported by this runtime |
| Caffe | supported |
| TFLite | not supported, despite what the marketing material says |
Every vendor has the same shape: RKNN (Rockchip), QNN (Qualcomm), Edge TPU (Google), Ethos-U (Arm). A compiler, a blob format, a runtime, and 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
About 60 binaries in /mnt/system/usr/bin/ai. Every one goes through the TDL SDK, which builds a VPSS frame before loading a model. Our image does not load the camera stack, so none of them can run.
The only entry point is the bare API:
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);Five calls. examples/npu/run/run_model.py is exactly this in ~150 lines of ctypes.
There is no compiler on the board — no gcc. Probes are written in Python against the runtime library with hand-declared structs. That is a feature: a ctypes probe is a text file you can scp and edit.
Part V — Present and Unusable
The symptom
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 it dies on the smallest allocation the API can express:
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. 4 KiB.
The library asserts
// cviruntime/src/soc/common/cvi_device_mem.cpp:451
assert(0); // ← reached on ENOMEMA recoverable ENOMEM becomes SIGABRT. Therefore:
CVI_NN_RegisterModelcannot report out-of-memory — it will not return.- Every line of error handling above it is unreachable.
- You cannot write a program that gracefully degrades when the NPU is unavailable, because asking kills you. You must know out of band.
This is why the fix had to be a build-time decision rather than a runtime check.
ION and the carveout
A fixed-function accelerator addresses memory by physical address. There is no IOMMU, so the memory must be physically contiguous, and physically contiguous memory cannot be conjured at runtime.
reserved-memory {
ion { compatible = "ion-region"; size = <0x00 0x4000000>; }; /* 64 MiB */
};
The carveout is taken before the kernel’s page allocator exists. reserved-memory is the only API: no module parameter, no sysfs knob, no runtime override. cvitek_ion.c reads the size at probe time.
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 — mappable, writable, and not physically contiguous.
Cvitek’s ION is not the upstream ION uapi.
ion_allocation_datahas noalignfield and carriesfd/paddr/name;ION_IOC_HEAP_QUERYdoes not implement the “ask for zero to learn the count” convention. Guess the struct and you getENOTTY.
One knob, two meanings
# configs/duos/settings.mk
SECOND_CPU ?= camera
ifeq ($(SECOND_CPU),arduino)
ION_SIZE=0
The SG2000 has one 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 # unconditionalION_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.
The soph_tpu.ko line is the tell: the TPU module loads unconditionally, so the system looks like it has a TPU regardless of the carveout.
The fix
Four changes to the image build, not 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” — what it always meant — instead of “is the carveout non-zero”, which was a proxy that happened to correlate.
A new SECOND_CPU value rather than ION_SIZE=64 on the command line, because SECOND_CPU is part of the docker build-volume key. Overriding ION_SIZE alone reuses the same build tree, and $(BUILDDIR)/$(BOARD)-$(VARIANT)/memmap.py has no prerequisites — so make does not regenerate it and you get a stale memory map compiled into a fresh-looking image.
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.
reserved-memory is consumed before the kernel’s allocator exists, so a wrong size does not fail gracefully. There is no recovery path that does not involve removing the card. Never patch a device tree on a board without a console, and never reboot one.
Part VI — The Microbenchmark
Method
Operands are integers in [-8, 7], so:
- bf16 represents them exactly (7-bit mantissa),
- int8 represents them exactly,
- the reference is computable in int64, exactly.
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.
Results
All times in ms, N × N multiply (measured).
| N | NPU bf16 | NPU int8 | CPU i8v | CPU i8s | CPU bfv | CPU bfs |
|---|---|---|---|---|---|---|
| 32 | 0.25 | 0.34 | 0.05 | 0.15 | 0.18 | 0.23 |
| 64 | 0.28 | 0.56 | 0.38 | 1.22 | 1.49 | 1.96 |
| 128 | 0.39 | 1.36 | 3.93 | 9.91 | 11.87 | 14.40 |
| 256 | 0.95 | 4.65 | 26.43 | 94.24 | 114.41 | 133.53 |
| 512 | 4.49 | 18.25 | 220.76 | 916.86 | 2295.38 | 1976.07 |
bf16, NPU vs CPU bfv (bit-identical output)
| N | NPU | CPU | who wins |
|---|---|---|---|
| 32 | 0.25 | 0.18 | CPU 1.4× |
| 64 | 0.28 | 1.49 | NPU 5.3× |
| 128 | 0.39 | 11.87 | NPU 30× |
| 256 | 0.95 | 114.41 | NPU 120× |
| 512 | 4.49 | 2295.38 | NPU 511× |
int8, NPU vs CPU i8v
| N | NPU | CPU | who wins |
|---|---|---|---|
| 32 | 0.34 | 0.05 | CPU 6.8× |
| 64 | 0.56 | 0.38 | CPU 1.5× |
| 128 | 1.36 | 3.93 | NPU 2.9× |
| 256 | 4.65 | 26.43 | NPU 5.7× |
| 512 | 18.25 | 220.76 | NPU 12× |
Crossover: N ≈ 64 (bf16), N ≈ 128 (int8). Set by a fixed per-inference cost of ~0.25–0.35 ms — the same number as the 898-parameter convnet’s 0.28 ms.
The ratio that grows
N³ predicts 8× for a doubling of N. Measured 256 → 512:
| growth | |
|---|---|
| NPU bf16 | 4.6× (better than N³ — amortising the fixed cost) |
| CPU bfv | 20× (worse than N³ — fell out of cache) |
At N=512 each matrix is 1 MB, three matrices is 3 MB, and the C906 has 32 KB L1 and 64 KB L2. The naive kernel walks a column with a 2 KB stride. So part of the widening ratio is the CPU’s problem, not the NPU’s triumph.
Correctness
| pairing | 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 (int8 exact; bf16 bf16_round(exact)).
NPU int8 is not exact: max error 11.0 against an output quantisation step of 11.49 — about one step. Input and weight thresholds were forced to 127 so the int8 operands would be exact and the accumulator scale 1.0, but a second int8 stage (AC_MatMul_si8 → C_MatMul, qscale 11.49) sits between the matmul and the fp32 output.
The anomaly
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× |
Reproducible across all five sizes and over repeated runs. This inverts the marketing: INT8 is the headline “0.5 TOPS” mode and it is the slow path here. The consequence is large — the NPU’s advantage over the CPU is 511× in bf16 and 12× in int8, because the C906’s int8 path (16 lanes, widening MAC) is genuinely good.
Cause not identified. Hypotheses:
- An extra requantise routine — the tensor map shows two routines for int8 (matmul → int8 intermediate → requantise to fp32) and one for bf16. But the extra stage is 65,536 elements and should cost microseconds, not 3.7 ms, so this explanation does not fit the magnitude.
- Different tiling in the int8 matmul.
- A different accumulator path.
Distinguishing these requires reading the emitted TPU dialect, not the ONNX graph. That is the next experiment.
Caveats
The CPU kernels are hand-written and unoptimised: blocked int8 scalar with 4×4 register blocking, blocked int8 RVV, blocked fp32 scalar, blocked fp32 RVV. They reach 1.27 GOPS (int8 RVV) and 0.29 GFLOP/s (fp32 RVV), far from what the hardware should manage. Several attempts to improve them made things worse.
So: the crossover points are solid (set by NPU fixed cost, not CPU kernel quality); the large-N ratios are an upper bound.
Part VII — The Demo
The pipeline
text → espeak-ng (IPA) → token ids → encoder (CPU) → vocoder (NPU) → WAV
Steady state, measured through the web API:
| stage | ms |
|---|---|
phonemise (espeak-ng) |
~140 |
encoder (duration.onnx, onnxruntime on C906) |
~230 |
| vocoder (NPU, bucketed, resident) | ~384 |
| total | ~760 |
For 1.82 s of audio — 2.4× real time, with the compute-heavy stage on the accelerator.
The split is forced by shapes
- The encoder’s output length is
sum(predicted durations)— data-dependent, so no static graph can express it. TPU-MLIR givesUNREACHABLE at .../Interfaces/Range.cpp:75. - The vocoder needs static shapes (bucketing supplies them) and is the expensive part.
python3-onnxruntime is packaged for riscv64 in Debian (1.21.0), and the encoder is 3.6 MB. So: shape-dynamic part on the CPU, expensive static part on the NPU.
Board dependencies:
sudo apt install -y python3-numpy python3-onnxruntime espeak-ngphonemizer is not packaged for Debian, but it is a thin wrapper over espeak-ng, so espeak-ng -q --ipa does the same job.
The model
owensong/Inflect-Nano-v2-ONNX, Apache-2.0:
duration.onnx3.6 MB — encoder + duration predictordecode.onnx12.6 MB — flow + HiFi-GAN vocoder- Both graphs are RNG-free. VITS’s stochastic sampling is externalised as a
zp_noiseinput, so feeding zeros is deterministic. (This is what killed Piper:RandomNormalLike→UNREACHABLEinRandnLike.cpp.) - It ships the text frontend, which the vendor’s own TTS sample omits.
Shapes: text_len = 2·n_phonemes + 1, wav_len = 256·mel_len, 24 kHz.
Bucketing
| 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 — the vocoder is compute-bound and the fixed cost is amortised by 128 frames.
Zero-pad the mel along the time axis but keep y_mask at 1.0 over the padding, so the vocoder sees no hard edge and does not click. Trim the output to mel_len × 256 samples.
Boundary cost is real: a 258-frame sentence lands in the 512 bucket — 2× the work for 1.01× the audio. Measured: mel_len 258, vocoder 807 ms instead of the ~390 ms a 256-frame model would take.
Keep it resident
| ms | |
|---|---|
| per-request (shell out, register, run, exit) | 3127 |
| first call with resident handle | 519 |
| second call, resident | 385 |
CVI_NN_RegisterModel allocates the weights and the command buffer in ION on every call. An 8× difference from a purely structural change.
The Gather bug
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 — starts=[-1], ends=[INT64_MIN], axes=[1], steps=[-1]. The steps tensor comes from a Constant node, not an initializer, so scanning initializers misses it.
The mistake. Replace each flip with Gather(axis=1, reversed_indices). It compiles, runs at 7× real time, and is completely wrong:
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 diagnostic that separated precision from structure: feed constant inputs (0.5, 0.1, 1.0, 0.0 — all exact in bf16). Precision cannot change a constant-input result. The NPU produced rms 0.0294 against the reference’s 0.00415, so the fault was structural.
The fix — exploit 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.
| 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.
The rule: any substitution made to work around a backend limitation needs its own isolated test before it is trusted. A compiler that accepts an operation and silently ignores part of it is worse than one that rejects it.
The A/B on the slide
Same sentence, same encoder output, same padding and trimming:
fp32 on the C906 (onnxruntime) |
116,248 ms |
| bf16 on the TPU | 807 ms |
| wave correlation | 0.9252 |
| spectrum correlation | 0.9675 |
Both files are 66,048 samples at 24 kHz (2.75 s), aligned at lag 0.
The 116 s is a real measurement and not a fair one. onnxruntime on riscv64 has no tuned kernels for this core, so the generic CPU path is doing something much worse than 116 s worth of arithmetic. A hand-written fp32 kernel would be far faster and has not been written. What the number establishes is narrower: fp32 inference of a 12.6 MB model on this board, through the packaged runtime, is not a deployment option. Do not quote 144× as a speedup.
Part VIII — Synthesis
Seven machines
| Machine | Accelerator | Attach | Precision | Programmable |
|---|---|---|---|---|
| sg2000 | bm1822 · 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, PCIe endpoint | fp32 | yes |
| pegasus | Arc Battlemage · 2025 | PCIe slot | fp32 | yes |
One machine in this table cannot be programmed, and it is the one on the desk.
The sg2000 counterfactual
To put a programmable accelerator on this die you would need:
- A bus. The sg2000’s best off-chip link is USB 2.0 at 60 MB/s. One PCIe 4.0 ×16 slot is 31,500 MB/s — 525× more. There is nothing to attach a GPU to.
- Address space. A 4 GB physical map cannot hold a 16 GB BAR. You would get the 256 MiB paging window that
celedyrhas. - Coherency. The C906’s caches do not snoop a DMA engine, and PCIe adds a non-coherent link with an IOMMU in between.
- Power and pins. A PCIe PHY and SerDes on a ~3 W part.
- Amdahl. A 1 GHz dual-issue C906 could not feed a GPU anyway.
Each is solvable. Together they describe a different chip.
The seven claims
- Fixed-function means no ISA. The op list is the machine; an operation that is not on it does not run — not slowly, not at all.
- Precision is the interface. No FP32 path. bf16 is real compute; int8 is a different function.
- The compiler is the open piece and the bugs live there.
Gatherfails open. - A device can be present and unusable. Presence is not usability.
- Coupling is the bug. Two requirements expressed as one test costs days and leaves no trace in any log.
- Fixed cost sets the crossover. ~0.3 ms per inference; the accelerator loses below N≈64.
- Measure like with like, or you measure nothing.
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.
Reproducing
# host: toolchain (gitignored, ~6 GB, Python 3.10 via uv)
./examples/npu/setup-tools.sh
./examples/npu/smoke/smoke.sh # tiny ONNX -> cvimodel, no board
# board: is the NPU there?
cat /sys/kernel/debug/ion/cvi_carveout_heap_dump/total_mem # 67108864
lsmod | grep -E 'soph_tpu|soph_vi' # tpu only
sudo python3 examples/npu/probe/probe.py # MemAlloc + paddr
# board: run a model
sudo python3 examples/npu/run/run_model.py model.cvimodel 20
# board: the matmul tables
sudo python3 examples/npu/mmul/run_mm.py model.cvimodel A.bin C.bin 30
# board: text to speech
python3 examples/npu/tts/pipeline.py "hello from the neural processor"Known open items
- int8 slower than bf16 on the NPU, 4.9× at N=256. Unexplained; needs the emitted TPU dialect.
- The CPU matmul kernels are naive. RVV fp32 reaches only 1.3× over scalar where 3–4× is expected.
CV181X_HW_NPU_SHIFT/EU_SHIFTarithmetic does not reconcile with 0.5 TOPS. Unresolved.- The A/B audio has not been judged by ear. Correlation 0.9252 could still be audibly wrong.