TinyTPU-isa: The ISA¶
The instruction encoding TinyTPU-isa executes: bit layout, opcodes, the
derived properties each unit relies on, the instruction-memory header, the
memory map, the parameter set and the numerics. Every fact here is generated
from isa_spec.json and checked against the design, so this is the page to
skim for a field width or an opcode number. The machine that executes it is
TinyTPU-isa: A Programmable GEMM Accelerator; the measurements are on TinyTPU-isa: Results and History.
Every fact on this page is generated from
examples/tinytpu/isa_spec.json, which is the source of
truth for the instruction encoding, the opcodes, the instruction-memory header,
the memory map, the two program contracts, the build parameters and the
numerics. gen_isa.py --write regenerates the tables below and
isa_encoding.py; gen_isa.py --check fails if either is stale or if the
design or the reference model has drifted from the spec. The prose outside the
generated region is hand-written rationale.
Bit layout¶
Most significant on the left. enc() builds word 0 and enc_agu() word 1; both are generated from the field table below, so neither picture can go stale.
word 0:
+------------+------------+------------+------------+------------+------------+------------+
| | nr | f3 | f2 | f1 | f0 | op |
| 63:62 | 61:54 | 53:42 | 41:30 | 29:18 | 17:6 | 5:0 |
+------------+------------+------------+------------+------------+------------+------------+
word 1: up to AGU_TERMS = 3 address terms of 19 bits:
+------------+------------+------------+------------+
| | term 2 | term 1 | term 0 |
| 63:57 | 56:38 | 37:19 | 18:0 |
+------------+------------+------------+------------+
one term: stride [18:7] level [6:4] target [3:0]
resolves to: field[target] += iv[level] * stride, for each term with target != 0, in term order
Bits 62, 63 of word 0 and bits 63:57 of word 1 are unused.
file |
held |
note |
|---|---|---|
|
generated |
The spec as Python. Regenerated and diffed byte for byte by gen_isa.py –check. |
|
generated |
The ISA tables, between the GENERATED markers. The prose around them is hand-written. |
|
checked |
The shipped instantiation: the build parameters, read from the environment, and the names the harness imports. Held to this file by parameter range and by parameter agreement under several configurations. |
|
checked |
Opcodes, DMA flags, AGU targets and budgets, and the instruction layout named once (OP_LO .. NR_HI, AGU_*_BITS), which |
|
checked |
|
|
checked |
The hardware. Its bit slices of a 64-bit word are literal because a symbolic bound widens the extract to i32 (docs/source/designs/tinytpu_library.rst), so every slice is checked against the field table here, as is the mvout saturation. |
|
checked |
The program generator, through the encoder it shares with the design. |
|
built on the generated module |
The reference model. Names operands by the |
|
checked |
|
Instruction word¶
An instruction is 2 64-bit words (IWORDS). Word 0 carries the opcode and five fields; word 1 carries up to 3 address-generation terms.
field |
bits |
width |
usable range |
role |
|---|---|---|---|---|
|
|
6 |
0 .. 31 |
opcode |
|
|
12 |
0 .. 2047 |
operand field, AGU target 1 |
|
|
12 |
0 .. 2047 |
operand field, AGU target 2 |
|
|
12 |
0 .. 2047 |
operand field, AGU target 3 |
|
|
12 |
0 .. 2047 |
operand field, AGU target 4 |
|
|
8 |
0 .. 127 |
row count, or loop trip count |
Every field carries one more bit than its value range needs: an N-bit field safely holds 0 .. 2^(N-1) - 1, the spare-bit rule below.
subfield |
bits |
width |
usable range |
meaning |
|---|---|---|---|---|
|
|
4 |
0 .. 7 |
4 bits for 0..4 under the spare-bit rule. |
|
|
3 |
0 .. 3 |
3 bits for 0..LOOP_DEPTH-1. |
|
|
12 |
0 .. 2047 |
12 bits for 0..2047. |
A term resolves to field[target] += iv[level] * stride, for each term with target != 0, in term order. Targets: 0 = unused, 1 = f0, 2 = f1, 3 = f2, 4 = f3.
Opcodes¶
constant |
value |
name |
operand fields |
|
units |
|---|---|---|---|---|---|
|
0 |
nop |
none |
– |
|
|
1 |
dma_ld |
f0 = mode: source matrix | destination memoryf1 = dram_row0: first DRAM row of the source matrixf2 = col_block: packed-word column block within the row, 0 .. WPR-1f3 = dst_row0: first destination row, in spad or vr by the mode bit |
nr DRAM rows |
|
|
2 |
dma_st |
Retired: results leave via mvout. |
none |
– |
|
3 |
vld |
f0 = vr0: first destination vreg rowf1 = spad0: first source scratchpad row |
nr packed words |
|
|
4 |
mm |
f0 = vr_a: first activation row in vrf1 = ar0: first accumulator rowf2 = acc: 0 = overwrite ar, 1 = accumulate into arf3 = spad_w: first of T weight rows in spad |
nr activation rows (wavefront rows through the array) |
|
|
5 |
vadd |
f0 = ar_d: first destination accumulator rowf1 = ar_s1: first row of the left sourcef2 = ar_s2: first row of the right source |
nr accumulator rows |
|
|
6 |
vrelu |
f0 = ar_d: first destination accumulator rowf1 = ar_s: first source accumulator row |
nr accumulator rows |
|
|
7 |
mvout |
f0 = ar0: first accumulator row to retiref1 = dram_row0: first DRAM row of Cf2 = col_block: packed-word column block of C, 0 .. WPR-1 |
nr accumulator rows |
|
|
8 |
loop |
nr is the trip count, minimum 1 |
|
|
|
9 |
endloop |
none |
|
|
|
10 |
vaddrelu |
f0 = ar_d: first destination accumulator rowf1 = ar_s1: first row of the left sourcef2 = ar_s2: first row of the right source |
nr accumulator rows |
|
The units column is derived from the actions below, not written beside each opcode: an opcode reaches whichever units its actions name. It used to be typed, and it was wrong twice – mm did not name the array, and dma_ld named its destination in prose.
Instructions as compositions of Actions¶
Every instruction is an ordered list of per-unit effects. Each effect names a unit, one of that unit’s ports, and the element it touches. allo.actions holds the model and its legality rule; this table is what the spec declares, and the per-unit work counts in the header, the dispatch rewrites and the reads and writes below are queries over it rather than further declarations.
opcode |
unit |
port |
kind |
state |
base |
items |
only if |
|---|---|---|---|---|---|---|---|
dma_ld |
dma_ld |
dram.read |
read |
|
dram_row0 |
nr |
(mode & 1) == 0 |
dma_ld |
dma_ld |
dram.read |
read |
|
dram_row0 |
nr |
(mode & 1) == 1 |
dma_ld |
dma_ld |
mux |
compute |
|
– |
nr |
– |
dma_ld |
dma_ld |
dma2sp |
emit |
– |
– |
nr |
(mode & 2) == 0 |
dma_ld |
dma_ld |
dma2vr |
emit |
– |
– |
nr |
(mode & 2) == 2 |
dma_ld |
spm |
dma2sp |
receive |
– |
– |
nr |
(mode & 2) == 0 |
dma_ld |
spm |
spad.write |
write |
|
dst_row0 |
nr |
(mode & 2) == 0 |
dma_ld |
vru |
dma2vr |
receive |
– |
– |
nr |
(mode & 2) == 2 |
dma_ld |
vru |
vr.write |
write |
|
dst_row0 |
nr |
(mode & 2) == 2 |
vld |
spm |
spad.read |
read |
|
spad0 |
nr |
– |
vld |
spm |
sp2vr |
emit |
– |
– |
nr |
– |
vld |
vru |
sp2vr |
receive |
– |
– |
nr |
– |
vld |
vru |
vr.write |
write |
|
vr0 |
nr |
– |
mm |
spm |
spad.read |
read |
|
spad_w |
T |
– |
mm |
spm |
wcol |
emit |
– |
– |
T + 1 |
– |
mm |
array |
wcol |
receive |
– |
– |
T + 1 |
– |
mm |
array |
instructions |
emit |
– |
– |
1 |
– |
mm |
vru |
vr.read |
read |
|
vr_a |
nr |
– |
mm |
vru |
acol |
emit |
– |
– |
nr |
– |
mm |
array |
acol |
receive |
– |
– |
nr |
– |
mm |
array |
mac |
compute |
|
– |
nr |
– |
mm |
array |
cw |
emit |
– |
– |
nr |
– |
mm |
accu |
cw |
receive |
– |
– |
nr |
– |
mm |
accu |
ar.read |
read |
|
ar0 |
nr |
acc == 1 |
mm |
accu |
alu |
compute |
|
– |
nr |
– |
mm |
accu |
ar.write |
write |
|
ar0 |
nr |
– |
vadd |
accu |
ar.read |
read |
|
ar_s1 |
nr |
– |
vadd |
accu |
ar.read |
read |
|
ar_s2 |
nr |
– |
vadd |
accu |
alu |
compute |
|
– |
nr |
– |
vadd |
accu |
ar.write |
write |
|
ar_d |
nr |
– |
vrelu |
accu |
ar.read |
read |
|
ar_s |
nr |
– |
vrelu |
accu |
alu |
compute |
|
– |
nr |
– |
vrelu |
accu |
ar.write |
write |
|
ar_d |
nr |
– |
mvout |
accu |
ar.read |
read |
|
ar0 |
nr |
– |
mvout |
accu |
alu |
compute |
|
– |
nr |
– |
mvout |
accu |
ac2sp |
emit |
– |
– |
nr |
– |
mvout |
dma_st |
ac2sp |
receive |
– |
– |
nr |
– |
mvout |
dma_st |
dram.write |
write |
|
dram_row0 |
nr |
– |
loop |
sequencer |
fetch |
compute |
|
– |
1 |
– |
endloop |
sequencer |
fetch |
compute |
|
– |
1 |
– |
vaddrelu |
accu |
ar.read |
read |
|
ar_s1 |
nr |
– |
vaddrelu |
accu |
ar.read |
read |
|
ar_s2 |
nr |
– |
vaddrelu |
accu |
alu |
compute |
|
– |
nr |
– |
vaddrelu |
accu |
alu |
compute |
|
– |
nr |
– |
vaddrelu |
accu |
ar.write |
write |
|
ar_d |
nr |
– |
unit |
ports (items per cycle) |
II |
elastic |
|---|---|---|---|
sequencer |
|
1 |
yes |
dma_ld |
|
1 |
yes |
spm |
|
1 |
yes |
vru |
|
1 |
yes |
array |
|
1 |
yes |
accu |
|
1 |
yes |
dma_st |
|
1 |
yes |
A unit’s cost for an instruction is its busiest port’s item count, so spm charging an mm T + 1 iterations and accu charging a vadd two steps a row are consequences of these ports and not entries in a table. elastic says whether contention inside a unit costs a step or is illegal.
Derived properties¶
Facts that follow from the tables above rather than being written in them. gen_isa.py --check recomputes each one from the spec and then confirms the design behaves that way, so they are checked rather than asserted – every one of them was documented wrongly here until 2026-09-21, which is the argument for computing them.
every_operand_field_is_an_agu_targetEvery operand field is a legal AGU target, including one with a restricted value set such as mm’s
acc. Nothing in the encoding or in the sequencer distinguishes them: the sequencer adds iv[level] * stride to whichever field the term names and writes the sum back, and it does not know which fields have restricted values.Derived from:
agu.targets,agu.semantics.Corrects: Three documents in this project described
accas a static field that cannot be predicated on an induction variable. That is false. The real obstacle is the next property.agu_terms_are_additively_monotoneAn AGU term contributes base + iv * stride with no predication, no saturation and no wrap, so the values a field takes over a loop of trip t are the arithmetic progression base, base + stride, …, base + (t-1) * stride, and nothing bends it back inside a bound. A field with a restricted value set is therefore drivable from a loop for exactly as long as that progression stays inside the set –
isa_encoding.agu_legal_tripcomputes how long fromlegal_valuesalone.Derived from:
agu.semantics,opcodes[mm].operands[acc].legal_values.At base 0 stride 1 the frontier is 2: an accumulate field driven by the reduce loop assembles for exactly two k-tiles and is rejected at the third.
a_term_on_acc_costs_one_of_the_budgetA term on
acccosts one of the AGU_TERMS terms an instruction carries. The accumulating mm of the shipped tiled GEMM already spends all of them – activations walk the reduce loop, weights walk both the column loop and the reduce loop – so drivingaccfrom the reduce loop needs one more than the instruction word has, and the budget refuses the instruction before the monotonicity frontier above is ever reached. Widening the address generator and relievingaccare therefore COMPLEMENTS, not alternatives: at AGU_TERMS = 3 the second constraint is unreachable for any program with a column loop.Derived from:
agu.terms,opcodes[mm].operands.terms_used_by_shipped_acc_mmis read out of the shipped program’s own AGU word, not restated here.the_maxdim_ceilings_are_the_encoding'sWhat bounds MAXDIM is the ENCODING, not the datapath, and both bounds follow from numbers already in this file. They answer different questions – one asks what the operand layout can ADDRESS, the other what a CUBIC GEMM’s header count can promise – so neither is a correction of the other, and the binding one depends on the program.
isa_encoding.maxdim_ceilingcomputes each over multiples of T, and the design refuses a cubic GEMM at exactly the value the cubic-header ceiling gives.Derived from:
maxdim_ceilings,encoding_rule.usable_max,imem.count_slice_width,imem.entries[accu_iterations].The numbers are computed, not typed: at T=4 the addressing ceiling is 88 and the cubic-header ceiling is 76, so the cubic one binds; at T=8 they are 128 and 120, so the cubic one binds there too. An earlier revision of this file said 90 for the first, copied from the design’s comment, which solved the inequality over the reals instead of over multiples of T.
program_limits_predict_what_assemblesThe limits in
program_limitsdecide exactly which GEMM shapes this build can run, andisa_encoding.gemm_limitscomputes which limit refuses a shape and by how much. For the shipped layout, a shape assembles if and only if every limit but thesplit_operand_loadalternative fits.Derived from:
program_limits.limits,derived_constants[MAXROWS],imem.count_usable_max.The
split_operand_loadrow is the one that makes the answer actionable rather than final: at T=8 MAXDIM=128 the shipped layout is over by 1 onmax(M, K)for 32x128x128 and 64x128x128, but splitting the B load removes K from the row count and both assemble and compute A@B exactly – measured on the design, not argued. 128x128x128 is over by 1 even then, and its accu header count is over by 2049, so it needs both a wider nr and a wider header slice.
Per-unit rewrites¶
The sequencer hands two units a rewritten copy of the word, so each unit’s flat row loop reads its own work count out of nr:
opcode |
unit |
rewritten to |
why |
|---|---|---|---|
|
|
|
One header word plus T weight rows down wcol; f1 carries the array’s row count into the header word. |
|
|
|
accu takes two iterations per vadd row: first source on the even one, second source and the write on the odd one. 2 * MAXROWS fits the 8-bit field. |
|
|
|
Which units need a rewrite, and to what, is derived: it is every unit the sequencer dispatches to whose own work count differs from the instruction’s row count. Nothing states it, so adding an instruction adds no entry here.
Instruction memory header¶
imem[0:NHDR] (NHDR = 8) is a header of per-unit work counts; instructions follow, two words each. Each count is read back through a 16-bit slice, so it stops at 32767.
word |
bits |
name |
count |
consumer |
|---|---|---|---|---|
|
|
|
static instruction count |
|
|
|
|
the steps |
|
|
|
|
the steps |
|
|
|
|
the steps |
|
|
|
|
items on |
the array, via spm’s header word |
|
|
|
items on |
the array, via spm’s header word |
|
|
|
the steps |
|
|
|
|
the steps |
|
|
|
|
the row span of |
|
|
|
|
the row span of |
|
Every count but the static one is the work a unit does, summed over the issues the program makes, and it is computed from the actions above rather than written here. The per-opcode costs that follow are therefore derived too:
opcode |
unit |
steps |
|---|---|---|
dma_ld |
dma_ld |
|
dma_ld |
spm |
|
vld |
spm |
|
vld |
vru |
|
mm |
spm |
|
mm |
array |
13 at nr=8 |
mm |
vru |
|
mm |
accu |
|
vadd |
accu |
|
vrelu |
accu |
|
mvout |
accu |
|
mvout |
dma_st |
|
loop |
sequencer |
1 at nr=8 |
endloop |
sequencer |
1 at nr=8 |
vaddrelu |
accu |
|
Memory map¶
memory |
owner |
depth |
row width |
written by |
read by |
cleared at start |
|---|---|---|---|---|---|---|
|
|
|
|
|
|
no |
|
|
|
|
|
|
no |
|
|
|
|
|
|
no |
|
|
|
|
the host |
|
no |
|
|
|
|
the host |
|
no |
|
|
|
|
the host |
|
no |
|
|
|
|
|
the host |
no |
The written by and read by columns are derived from the actions. They used to be two lists beside each memory that nothing computed from and nothing checked.
No on-chip memory is cleared by the hardware, so every read of one is the program’s obligation; see the contracts below.
Contracts¶
Write before read.
Every ar row read by an accumulating mm, by either source of a vadd, by a vrelu source or by an mvout must have been written earlier in the SAME program, by an overwriting mm, a vadd or a vrelu.
Every vr row an mm reads as activations, and every spad row it reads as weights, must hold data a dma_ld put there – directly, or into spad and then through a vld.
vld is a pure copy and may copy an unwritten spad row; the copy is then unwritten too, and consuming it in an mm is an error.
nr >= 1 on every data op: a unit fetches an instruction whenever its row counter runs out, so a zero-row instruction is fetched as if it had one row. It desynchronises the unit; it is not a no-op.
Every resolved field must be within 0 .. 2047, the range the encoding rule admits.
Enforced by microarch_isa.check_program, which microarch_isa.assemble calls, so a violating program cannot be assembled.
The accumulator read-after-write distance. A read of an ar row must come at least AR_RAW_DIST accu iterations after the write it depends on. AR_RAW_DIST = 4 accu iterations; cost per opcode: mm 1 per row, vrelu 1 per row, mvout 1 per row, vadd 2 per row: first source on the even iteration, second source and the write on the odd one. Enforced by microarch_isa.check_program. Exercised at its edge by isa_dsl.ar_distance_program(AR_RAW_DIST), run by TPU_TB=stress cosim on every build.
Parameters¶
constant |
environment variable |
default |
legal range |
role |
|---|---|---|---|---|
|
|
4 |
4 .. unbounded |
SIMD width, and the array dimension: the array is T*T processing elements |
|
|
64 |
4 .. unbounded |
largest M, K, N supported by one build |
|
|
|
1 .. 2048 |
scratchpad depth in packed words |
|
|
|
1 .. 2048 |
operand vector registers |
|
|
|
1 .. 2048 |
accumulator vector registers |
|
|
|
8 .. unbounded |
instruction memory depth in 64-bit words |
|
|
16 |
2 .. unbounded |
stream depth on every point-to-point channel |
|
|
|
1 .. unbounded |
packed words the operand burst moves per loop iteration |
constant |
value |
role |
|---|---|---|
|
|
packed operand word: T operand lanes |
|
|
packed accumulator word: T accumulator lanes |
|
|
packed words per DRAM row; the legal range of col_block |
|
|
the highest operand row the shipped GEMM’s layout names; what the operand memories are sized from |
|
|
the fixed row window the stress harness’s fuzz programs address, independently of MAXDIM; a floor under the operand memories |
|
|
the m_axi beat the build aligns to (align_value 64) |
|
|
the largest nr the 8-bit field admits under the encoding rule |
Cross-parameter constraints, asserted by isa_encoding.check_parameters():
T >= 4– a packed operand word must hold the two 16-bit counts spm sends down wcolMAXDIM % T == 0– a DRAM row must be a whole number of packed wordsOPERAND_ROWS <= 2048– an address field carries 11 usable bits, and the layout numbers its rows 0 .. OPERAND_ROWS-1, so the highest address must be <= 2047AR_RAW_DIST <= T– a T-row GEMM must satisfy the accumulator distance contractIMEM_SIZE % 8 == 0– the program prefetch moves 8 words per iterationIMEM_SIZE >= NHDR + IWORDS– imem must hold the header and at least one instructionSPAD_ROWS <= 2048 and NVR <= 2048 and NAR <= 2048– a 12-bit address field carries 0..2047 under the encoding rule, and a memory of depth D is addressed 0..D-1, so D <= 2048 – the same count-versus-address distinction as the addressing ceilingSPAD_ROWS >= OPERAND_ROWS and NVR >= OPERAND_ROWS– an operand memory smaller than the layout addresses assembles and gives wrong answersNAR >= 2 * MAXDIM + 2– AR_C is MAXDIM rows and AR_P another MAXDIM from MAXDIM+1DMA_WORDS >= 1– the operand burst moves at least one packed word per iteration
What bounds MAXDIM¶
Two ceilings, both in the encoding rather than the datapath. They answer different questions, so neither is a correction of the other, and which one binds depends on the program and on T. The values below are computed by isa_encoding.maxdim_ceiling, over multiples of T: solving either inequality over the reals gives a number no build can use.
ceiling |
at T=4 |
at T=8 |
predicate |
the question it answers |
how the design refuses past it |
|---|---|---|---|---|---|
|
88 |
128 |
|
How large a MAXDIM can the shipped GEMM’s operand layout ADDRESS? A property of the layout and the address field, independent of the shape being run. |
check_program: “AGU-resolved f3=… is outside the 0..2047 range”, or the import-time assert on OPERAND_ROWS |
|
76 |
120 |
|
How large a MAXDIM can a CUBIC GEMM’s header count promise accu? A property of the WORKLOAD as well as the encoding – a non-cubic shape gives a different count, so this ceiling moves with the program. |
assemble: “header count … does not fit 15 bits” |
The binding ceiling for a build is the smaller of the two, and which one binds depends on the program. The design ships at MAXDIM=64, inside both.
Numerics¶
Active configuration: int8. A configuration states what happens to values, not only how wide they are, so that a format whose arithmetic is inexact can be added without restructuring anything above.
stage |
operation |
range, or exactness and what replaces it |
where / note |
|---|---|---|---|
operand |
integer, signed, 8 bits |
-128 .. 127 |
A, B, C at the region boundary; spad and vr lanes |
accumulator |
integer, signed, 32 bits |
-2147483648 .. 2147483647 |
ar lanes, and the partial sum travelling south through the array |
multiply |
operand x operand -> 16 bits |
exact |
int8 x int8 is bounded by 128*128 = 16384, so a 16-bit product is exact and the multiplier stays narrow. |
accumulate |
integer addition |
rounding: none; overflow: wraparound, two’s complement, at 32 bits |
order: strictly sequential in ascending contraction index, south down the PE column, then the mm’s acc term |
output |
accumulator -> operand, saturate |
to -128 .. 127, rounding none |
mvout, in accu, before the word leaves for dma_st |
|
integer addition |
overflow: wraparound, two’s complement, at 32 bits |
in the accumulator format |
|
max(x, 0) |
exact |
in the accumulator format |
Rationale¶
Control flow. LOOP/ENDLOOP drive a LOOP_DEPTH = 4 hardware loop
stack, as MiniTPU’s (MiniTPU: Reference Architecture). The sequencer’s loop is do-while, so a
trip count of 0 would run once; the generator refuses it (below).
Address generation. Each AGU term is (target, level, stride) and resolves
to field[target] += iv[level] * stride, so an address can be relative to any
enclosing loop’s induction variable. Terms name their target rather than being
fixed one-per-field, because a single field often needs two: the mm inside
the k loop names its weights at an offset by both the n tile and the k tile,
B_SP + nb*MAXDIM + kb*T, and a one-term-per-field encoding cannot say it.
Without this a loop body would reissue identical addresses every iteration and
simply redo the same work.
The spare-bit rule¶
Every field carries one more bit than its value range needs, because
an N-bit ISA field safely carries 0 .. 2^(N-1) - 1. So nr is 8 bits
for MAXROWS = 127, the address fields are 12 bits for a 2047 maximum, AGU
targets are 4 bits for 0..4, levels 3 bits for 0..3, strides 12 bits for
0..2047, and enc() asserts 0 <= v < 2^(w-1) for every field.
The rule was earned from an emitter bug that extracted a bit-slice into a
signed ap_int<N>, so a field whose top bit was set read back negative
and the loop it bounded ran zero times. The general lesson it left is
keep cosim in the loop rather than running it once at the end: the Allo
dataflow simulator did not model the narrowing, so the fault was a genuine
simulator/RTL divergence and was invisible to every functional check that had
been passing. The bug itself is in
TinyTPU-isa: Results and History.
Note
The emitter has since been fixed: the fork’s 3de74846 (“hls: emit bit
slices as unsigned”, 2026-09-18) and upstream’s 094ab413 (PR #612,
“Preserve unsigned bit-slice types during HLS codegen”) fix the same bug;
main took upstream’s implementation in merge dc6b8fa6 (2026-09-19).
The design’s spare-bit encoding predates the fix and is unchanged. See
12. Bit-slices lowered to signed ap_int<N>, silently, and the simulator disagreed – closed.
The nr width is also load-bearing for synthesis, not only for correctness;
see A programmable design’s csynth number is not a cycle count.
Header and program memory¶
The header words are tabulated above. imem[0] bounds the sequencer’s fetch;
every other count is dynamic, from expand(), which runs the program’s
control flow at assembly time and resolves the AGU exactly as the sequencer
does. With a hardware loop the static and dynamic counts differ, and a unit
promised more work than it receives does not produce a wrong answer, it
hangs – the one place where the assembler and the microarchitecture are
coupled.
expand() yields one (opcode, row count, f0, f1, f2, f3) tuple per
dynamic issue, and the resolved address fields travel alongside the row count
for the same reason: dma_ld now bursts, so it needs the DRAM row span
before its first instruction arrives (imem[7], above), and f1 is an
AGU target in the general case, not a constant readable off the static
encoding. Mirroring the sequencer’s control flow here is also what makes
bench_isa.py’s loop-vs-flat equivalence check strict: the two program
forms must agree on every resolved address, not only on the opcode and
row-count stream. assemble() runs every program through check_program()
first; check=False exists only so a test can put a known-bad program on
the machine and watch it fail – nothing that ships passes it.
IMEM_SIZE is NHDR + IWORDS * 24 (the longest program shipped, plus
headroom). The program is pulled on-chip by one burst of IMEM_SIZE words
before anything runs, so imem size is startup time whether the program uses it
or not. Measured when the prefetch moved one word per cycle: moving to the
2-word instruction format cost exactly +68 cycles at all five shapes, exactly
the 68 extra words it added – the loop logic itself cost nothing. Since
e24e433b the prefetch moves 8 words per cycle (gmem0 is 512 bits wide;
ib is cyclically partitioned by 8), so the 56 words cost 7 cycles, not 56
– worth 52 cycles at every shape (v_imem8). With control flow the program
is O(nesting), not O(tiles): the looped GEMM is at most 14 instructions (with
relu) at every shape, where the unrolled one reaches 32.
Programs, and the loop-nest generator¶
Three program forms are kept, and bench_isa.py checks them against each
other before building anything:
isa_dsl.gemm_program– the shipped program, from the loop-nest generator: Adma_ld’d straight into the vregs, B into the scratchpad, and everymmnaming its T weight rows there.microarch_isa.gemm_program_handwritten– the hand-emitted reference. The first k-tile is peeled out of the k loop deliberately: it carriesf2=0(overwrite the accumulator) where the looped tiles carryf2=1(accumulate), and peeling is how you say that without a predicate on the induction variable.microarch_isa.gemm_program_flat– the fully unrolled differential reference, absolute addresses and no control flow.vadd_programcomputesrelu(2 * (A @ B))on the first output tile, sovadd/vrelustay exercised now that the GEMM inner loop no longer needs them.isa_dsl.vector_programandisa_dsl.ar_distance_programare test programs: the first varies every field GEMM holds constant (bothdma_lddestinations,vld, distinct accumulator regions), the second sits exactly on the accumulator distance contract (The accumulator’s dependence claim).
isa_dsl.py is a code generator, not a compiler. It chooses nothing:
the programmer writes |
the tiling, the loop order, the layout, which k-tile is peeled, and which loop an address walks |
the generator derives |
every |
the generator refuses |
a nest deeper than the hardware loop stack, an induction variable used outside its own loop, and more address terms than one instruction can carry |
nobody decides |
what the best tiling is – there is no search here |
It ports MiniTPU’s mechanism (board_package/dsl.py:126-141, its loop()):
nesting depth IS the AGU level, and the only handle on a level is the
induction variable the with yields:
with k.loop(Nt, "n") as nb: # level 0, derived
k.mm(A_VR, AR_C, Ref(B_SP).at(nb, MAXDIM), rows=M, acc=False) # peeled
with k.loop(Kt - 1, "k") as kb: # level 1, derived
k.mm(Ref(A_VR + MAXDIM).at(kb, MAXDIM), AR_C,
Ref(B_SP + T).at(nb, MAXDIM).at(kb, T), rows=M, acc=True)
Swap two with statements and every address term follows, because the
induction variables carry their levels with them. It is generalised to this
AGU rather than copied: MiniTPU’s AGU is one term on one field as a
power-of-two shift, this one is three (target, level, stride) terms with
arbitrary 11-bit strides, any number of which may land on the same field –
B_SP + nb*MAXDIM + kb*T is two terms on f3 and their encoding cannot say
it. So Ref is a base plus an ordered list of (iv, stride) terms and any
field may be one.
It emits the identical instruction stream, and that is the whole test.
isa_dsl.assert_matches_handwritten compares both 64-bit words of every
instruction against gemm_program_handwritten at all five shapes and both
relu settings; bench_isa.py runs it before it builds anything. Bit-identical
means cosim cannot move, so it was not re-run.
What it bought. Three errors that were previously expressible are not: a
level that disagrees with the nest, an induction variable used after its loop
closed (the sequencer would resolve it against a stale iv_now[level]), and a
loop whose endloop was forgotten. Two more are now caught at the point
of writing rather than at enc_agu: a nest deeper than LOOP_DEPTH, named
(“the nest is n > k > m > j > i”), and a trip count of 0, which the do-while
sequencer would run once.
What it cost. It is ~120 lines of machinery to remove four typed integers
from a 40-line program, and at this size the hand-written form was not actually
hard to keep right – the trade only pays once there is more than one program.
It also trades one hand-maintained correspondence for another: the instruction
methods (vld(vr, spad, rows)) restate the field layout that the opcode table
comments give. The new correspondence is stated once per opcode instead of once
per instruction and the bit-identity assertion checks it, which is why it is the
better of the two, but it is not free. And the genuinely subtle part of the
program – the peeled first k-tile, and the B_SP + T base that encodes “kb
starts at 1” – is exactly as subtle as it was.
Programs are written against this encoder (by hand, or through isa_dsl.py)
because the compiler backend that lowers a TOSA matmul into these instructions
lives only on the chia-codesign branch; see ACT and the TinyTPU-isa Mapper.