KEA-1 is a fully specified inference-accelerator research stack: a frozen integer ISA, functional and cycle-approximate simulators, an MLIR compiler from quantized TOSA to scheduled machine code, a runtime and artifact format, and a post-training quantization frontend.
The checked-in MobileNetV2 int8 graph lowers and executes all 183 nodes across two KEA programs. The primary bit-exact path keeps the final global-average pool on the host; the all-NPU path exposes a documented frontend rounding boundary (±1 on 11.4% of logits across four golden inputs, with 4/4 argmax agreement). Nothing here runs on silicon, and simulator cycle counts are a lower bound rather than hardware measurements.
| Current scope | |
|---|---|
| Machine | Custom branchless ISA, systolic/depthwise/vector units, explicit DMA and semaphore synchronization |
| Compiler | Quantized TOSA → two-level KEA MLIR → tiled, allocated, scheduled text assembly → .keaf |
| Execution | Bit-exact functional semantics, cycle-approximate timing, runtime loader, assembler and disassembler |
| Evidence | Native/compiler/frontend suites, golden-vector validation, scheduler A/B, and a reproducible simulator roofline |
Every number in this file is generated by a checked-in command. The exact limitations and reproduction boundary appear alongside the results below.
KEA-1 is a statically scheduled, in-order, integer-only accelerator. It has
no branches, no loops, no caches, and no hardware dependency tracking. The
compiler is responsible for correctness: every cross-unit dependency is an
explicit SIGNAL / WAIT on one of 32 counting semaphores.
| Clock | 1 GHz (nominal) |
| MXU | 16×16 systolic array, 256 int8 MAC/cycle (512 int4), two weight banks |
| DWU | 16 lanes × 8 MACs, 128 int8 MAC/cycle, 3×3 and 5×5 depthwise only |
| VPU | 16 elem/cycle — requantize, quantized add, pool, strided copy |
| DMA0 / DMA1 | two engines sharing one 16 B/cycle DRAM port |
| Scratchpads | SPM_A 256 KiB, SPM_W 256 KiB, ACC 32768 × int32 (word-addressed) |
| IMEM | 1 MiB = 32768 instructions, fixed 32 B each. There is no loop instruction — a network is fully unrolled |
| Dispatch | one instruction per cycle, program order, into five depth-16 in-order queues |
Two consequences shape everything above it. First, a full queue stalls the dispatcher, so a badly ordered instruction stream serialises the whole machine. Second, IMEM is a hard capacity limit on network size, not a performance knob — see §4.
Peak arithmetic: 512 GOPS int8 (1 MAC = 2 ops). Peak bandwidth: 16 GB/s. The ridge point is therefore 32 ops/byte — a layer below that is memory bound no matter how well it is scheduled.
docs/ISA.md is normative; docs/MICROARCH.md freezes the timing model.
model.py ──frontend──▶ model.kgraph.json + .npz
│ kea_frontend.tosa_emit
▼
model.tosa.mlir
│
kea-opt (tosa/linalg ─▶ kea ─▶ fuse ─▶ tile ─▶ alloc ─▶ schedule)
│
kea-translate
│
┌─────────────┼─────────────┐
▼ ▼ ▼
model.kasm model.weights.bin model.map.json
(assembly) (constants) (DRAM map, I/O
│ │ tensors, metadata)
└─────────────┼─────────────┘
▼
kea-as
▼
model.keaf ──▶ kea-rt / kea-sim
(from ADR-0001;
keac is a thin driver that runs the whole chain.)
The text-assembly boundary is the load-bearing design decision. The MLIR
half never writes binary and never links isa.h; the native half never links
MLIR. The payoff is that the scheduled, allocated, double-buffered instruction
stream is readable. §6 shows what that buys.
The kea dialect is deliberately two-level (ADR-0002):
Level 1 is value-semantic tensors with quantization as structured attributes;
Level 2 is buffers, addresses, events and machine ops. The ingest format is
TOSA, not linalg, precisely so the quantization survives exactly
(docs/DIALECT_L1.md §6).
Toolchain: LLVM/MLIR 20.1.6 only (docs/TOSA_NOTES.md §16 lists what breaks
on 21+), a C++17 compiler, and the venv described in docs/PLATFORM.md.
source scripts/env.sh
bash scripts/build_compiler.sh # kea-opt, kea-translate + 35 lit tests (~6 min)
bash scripts/build.sh # kea-as, kea-dis, kea-sim, kea-rt, keac + 17 tests
.venv/bin/python -m pytest frontend/tests # 92 tests
.venv/bin/python tools/keac/tests/numeric_check.py # 7 layouts, bit-exactThe end-to-end demo — emit TOSA from the quantized graph, compile, simulate, validate against golden vectors, and produce the roofline:
bash demo/run_all.sh # ~2 min; --quick skips the scheduler A/BCompiling one model by hand — no flags are needed; the defaults fit:
# .kgraph.json -> TOSA MLIR
cd frontend && ../.venv/bin/python -m kea_frontend.tosa_emit \
../models/mobilenetv2_int8.kgraph.json -o /tmp/m.tosa.mlir \
--function mnv2 --last-index 178
# TOSA -> .keaf, then run it
build/native/bin/keac /tmp/m.tosa.mlir --function mnv2 -o /tmp/m.keaf \
--schedule --keep-intermediates
build/native/bin/kea-sim /tmp/m.keaf --stats-json /tmp/m.stats.jsonkeac --imem-budget <n> exposes the tiler's whole-function instruction budget
— the knob that picks the tiling, and therefore the cycle count. The default is
the measured optimum for this network; docs/RESULTS.md §6.2 has the curve,
including the cliff below it.
MobileNetV2 int8, 224×224, batch 1, models/mobilenetv2_int8.kgraph.json
(torchvision IMAGENET1K_V1, percentile observer, BN folded). Everything at
the compiler's defaults — the demo passes no tuning flag anywhere.
| Nodes compiled and run on the NPU | 183 of 183 — nothing runs on the host |
| Convolutions | 52 of 52, all scheduled |
| Programs | 2 — the network is 37,295 instructions at its coarsest tiling and IMEM holds 32,768 |
| Total cycles | 3,230,350 = 3.23 ms at 1 GHz (3,854,857 unscheduled) |
| Instructions | 40,099 across the two programs |
| Useful arithmetic | 601.6 Mops (300.8 M MACs) |
| DRAM moved | 22.7 MB (16.0 MB read, 6.7 MB written) |
| Arithmetic intensity | 26.5 ops/byte — below the 32.0 ridge point, so memory bound |
| Achieved | 186.2 GOPS of 424.7 attainable = 43.9% |
| MXU MAC utilisation | 33.9% of the 256 int8 MAC/cycle peak |
| MXU padding efficiency | 93.3% (useful ÷ issued MACs) |
| Numerical result | bit-exact on the split that keeps the pool on the host; ±1 on 11.4% of logits, argmax 4/4, with the pool on the NPU |
--kea-schedule A/B |
1.181× on the 52-convolution feature extractor, 1.193× on the whole network |
Caveats, in order of how much they matter.
- MobileNetV2 does not fit in one program, and cannot. At the coarsest
tiling of every layer it is 37,295 KEA-1 instructions against a 32,768
entry IMEM — 13.8% over. That is a capacity result, not a bug: the machine
is branchless and IMEM is not paged, so program size is a hard limit. Every
op in the graph lowers; it is purely size.
docs/RESULTS.md§2.1 measures it three ways. - All 183 nodes on the NPU, or bit-exactness — not both. The cut can go
either side of the head pool. Keep the pool on the host and the result is
bit-exact against the numpy reference on all four golden vectors. Put it on
the NPU and 11.4% of the logits shift by exactly ±1, with the argmax
unchanged 4/4 — because the frontend can only spell a scale-changing pool as
avg_pool2d+rescale, which rounds twice where the reference rounds once. That is a Level 1 dialect limitation, not a backend one.docs/RESULTS.md§4.2. kea-simcycle counts are a lower bound. Scratchpad port arbitration, DRAM row buffers, refresh and turnaround, and misalignment costs are all unmodelled (docs/SIMULATOR.md§2). Real LPDDR would cost 10–30% more on scattered access — and the classifier, at 94.8% of attainable and 15.2 GB/s of a 16 GB/s port, is where that would show first.- The
imem-budgetwindow is narrow and has a cliff. Only ~19,100–21,000 is feasible at all. Below 20,000 the tiler makes too few, too large tiles for the scheduler to overlap, and--schedulecorrectly declines — emitting output byte-identical to not scheduling. The default sits at the measured optimum.docs/RESULTS.md§6.2. - Accuracy figures for the quantized graph itself (67.9% top-1 on a
1000-image Imagenette split, not ImageNet-1k) live in
docs/FRONTEND.md§8 with their own caveats. This repository's contribution is that the compiled artifact reproduces the reference exactly, not that the reference is accurate.
Compiling a real network found six backend defects; all six are fixed, and
bash demo/regress/run_regressions.sh asserts the fixed behaviour — 11
assertions — so they cannot silently come back.
| was | now | |
|---|---|---|
kea.pool name collision |
every pool untranslatable — a DRAM name and an SPM_A tile name collided | uniquification moved into makeBuffer(); a 7×7 pool runs in 460 cycles at 42.6% VPU |
activation-RHS kea.matmul |
error: null operand found, pointing at nothing |
a real diagnostic naming the op and the weight-stationary reason (the limitation itself stands) |
| Rule D violation | --kea-schedule failed on any prefix past node 97 |
the whole 52-convolution extractor schedules, and validates bit-exactly |
| IMEM overrun | 41,409 instructions at the default reserve factor; the demo was pinned to --spm-reserve 1 |
-kea-tile is IMEM-aware; the extractor fits at the defaults with no flags |
standalone kea.rescale |
no Level 2 lowering, so a scale-changing pool could not compile at all | lowered through a 16×16 identity matmul — this is what puts the pool on the NPU |
kea.trace marker hoisting |
10 of 52 TRACE regions opened at cycle ~0; per-layer numbers in a scheduled build were nonsense (Σ regions = 7.43× the program) | markers key on their own queue, bounded by a region lookahead; Σ regions = 1.072×, checked on every build by demo/common.py's own detector |
Half of them were diagnosis or instrumentation bugs rather than capability
bugs — the compiler could already do more than it appeared to, and small
reproducers were what showed that. Details in docs/RESULTS.md §3.
Cycle-approximate simulator results at the compiler defaults; these are not
silicon measurements. The plot is regenerated by bash demo/run_all.sh.
The two layer families separate cleanly, which is the whole point of plotting it:
| arithmetic intensity | achieved | share of ops | share of layer-cycles | share of DRAM | |
|---|---|---|---|---|---|
| conv (MXU), 35 layers | 16.5 – 92.0 ops/B | 118 – 316 GOPS | 90.7% | 74.5% | 62.3% |
| 3×3 depthwise (DWU), 17 layers | 3.6 – 10.0 ops/B | 38 – 101 GOPS | 8.9% | 23.0% | 32.3% |
classifier fully_connected |
1.9 ops/B | 29.2 GOPS | 0.4% | 2.5% | 5.3% |
All 17 depthwise layers, the classifier and 13 of the convolutions fall below the ridge point: 31 of 53 layers are memory bound. A depthwise 3×3 does 9 MACs per input element regardless of channel count, so its intensity is fixed by the kernel, not by the layer size; the classifier reads 1.28 MB of weights to do 1.28 M MACs and can never be anything but bandwidth limited. No amount of scheduling moves those points up; only fusing them into their neighbours (so the activation never round-trips through DRAM) would.
Two layers are worth naming. The last 320→1280 pointwise at 7×7 is the fastest in the network — 92.0 ops/byte, 316.4 GOPS. And the classifier, the least efficient layer by MAC utilisation (5.7%), is the most efficient by the only measure that applies to it: 94.8% of attainable, 15.2 GB/s of a 16 GB/s port. It is the one layer here that is genuinely finished.
Layer points and both network points are the scheduled build; the two stars are
the whole network before and after --kea-schedule.
This is demo/results/schedule_excerpt.kasm lines 46–65, the scheduled stream
for one 112×112 pointwise convolution. Long operand lists are wrapped and a few
stride fields elided as …; nothing is reordered. Four things are happening at
once: DMA1 is loading the next row band between the two MATMULs of the
current one, the VPU is requantizing the previous band's accumulator,
DMA0 is storing the band before that, and LOAD_W alternates
bank=0 / bank=1 so a weight load never waits for the array:
MXU WAIT event=3, threshold=1
MXU WAIT event=4, threshold=1
MXU LOAD_W w_addr=w:0, w_row_stride=16, k_rows=16, n_cols=16, bank=0, dtype=int8
MXU MATMUL a_addr=a:28688, a_inner_stride=32, a_outer_stride=3584, m_inner=112,
m_outer=8, acc_addr=acc:14336, …, bank=0, acc_mode=overwrite, dtype=int8
DMA1 WAIT event=2, threshold=1
DMA1 DMA_LD spm_space=SPM_A, dram_addr=@slice_6_7.input0+86016, <- next band in,
spm_addr=a:86064, len0=3584, n1=8, n2=1, dram_s1=3584, … mid-MATMUL
DMA1 SIGNAL event=4, inc=1
VPU WAIT event=5, threshold=1
VPU WAIT event=6, threshold=1
VPU VQUANT acc_addr=acc:0, out_addr=a:172128, qparam_addr=w:512, <- previous band's
num_pixels=896, channels=16, …, out_zp=-5, dtype=int8 accumulator
VPU SIGNAL event=3, inc=1
VPU SIGNAL event=1, inc=1
MXU LOAD_W w_addr=w:256, w_row_stride=16, k_rows=16, n_cols=16, bank=1, dtype=int8
MXU MATMUL a_addr=a:28704, a_inner_stride=32, a_outer_stride=3584, m_inner=112,
m_outer=8, acc_addr=acc:14336, …, bank=1, acc_mode=accumulate, dtype=int8
MXU SIGNAL event=5, inc=1
MXU SIGNAL event=7, inc=1
DMA0 WAIT event=7, threshold=1
DMA0 WAIT event=1, threshold=1
DMA0 DMA_ST spm_space=SPM_A, dram_addr=@slice_6_7.0.out, spm_addr=a:172128, <- band before that
len0=16, n1=112, n2=8, dram_s1=16, dram_s2=1792, … out
The unscheduled build of the same layer
(demo/results/schedule_excerpt.unscheduled.kasm) issues every DMA_LD on
DMA0, never touches DMA1, and puts a WAIT between every producer and
consumer. It takes 102,764 cycles; the version above takes 53,113 — 1.935×.
Over the whole feature extractor the same mechanism is worth 1.181×, and
kea-sim says the answer is still bit-identical.
You cannot review that property in a binary, and you cannot unit-test it without a parser. That is the argument for the boundary.
include/kea/ the frozen ISA: hw_config.h, isa.h, program.h, keaf.h
compiler/ out-of-tree MLIR: the `kea` dialect, conversions,
include/kea/ fuse/tile/schedule/alloc passes, the .kasm emitter
lib/ Dialect/ Conversion/ Transforms/ Target/Kasm
tools/ kea-opt, kea-translate
test/ lit tests (35)
sim/ the cycle-approximate simulator (functional + timing)
runtime/ KEAF reader/writer, the .kasm assembler, kea-rt
tools/ kea-as, kea-dis, kea-sim, keac (+ keac's e2e tests)
frontend/ PTQ: torch/ONNX -> .kgraph.json, the numpy golden model,
kea_frontend/ and tosa_emit.py -- .kgraph.json -> TOSA MLIR
models/ the shipped quantized graphs (MobileNetV2, a tiny ViT)
tests/mlir/ verified TOSA and linalg examples for 20.1.6
tests/invariants/ cross-component invariants (e.g. requant equivalence)
demo/ the end-to-end demo, its results, and regress/ --
regression tests for the defects found in bring-up
docs/ the specifications; adr/ for the three decisions
| If you want | Read |
|---|---|
| the end-to-end result, per layer, with every caveat | docs/RESULTS.md |
| the instruction set | docs/ISA.md, then docs/ISA_ERRATA.md |
| the timing model the simulator implements | docs/MICROARCH.md, docs/SIMULATOR.md |
| what TOSA actually looks like on 20.1.6 | docs/TOSA_NOTES.md — machine-verified, trust it over upstream docs |
the kea dialect |
docs/DIALECT_L1.md (tensors), docs/DIALECT_L2.md (machine) |
| how conv becomes MATMULs | docs/CODEGEN.md |
| tiling, buffers, DRAM layout | docs/MEMORY_PLANNING.md |
| the list scheduler and its cost model | docs/SCHEDULING.md |
| requantization, bit-exactly | docs/QUANTIZATION.md + docs/adr/0003-* |
| quantization, calibration, accuracy | docs/FRONTEND.md |
the .kasm and .keaf formats |
docs/ASSEMBLY.md, docs/ARTIFACT_FORMAT.md |
| why the pipeline is shaped this way | docs/adr/ |
