Allo Limitations Register¶
This page is the fork’s register of Allo limitations: concrete obstacles met while building accelerators on Allo, each of which required either a workaround in user code or a patch to the Allo library. It was consolidated from two passes:
the first pass (items 1-10), from adding a VPU, a hardware loop and an on-device transpose to an L2 TPU running FlashAttention (
levels/L2/tpu.py);the second pass (items 11-23), from building the instruction-programmable TinyTPU-isa (TinyTPU-isa: A Programmable GEMM Accelerator) on
mainand taking it through the Vitis dataflow path to RTL co-simulation;the third pass (item 24), from compiling for that design rather than building it – generated programs that every cheap check accepts and the RTL does not run.
Each entry keeps its dated corrections and retractions in place rather than rewriting them away.
Gaps in Allo’s abstractions – things that are missing a type, a primitive or a pass rather than a bug fix – are ranked with their legality rules in Extending Allo’s Abstractions, which is also the standard a new primitive has to meet before it lands.
Related feature-gap tracking lives as fork issues and is not restated here: combinational wires (fork issue #9), HLS dependence pragma (fork issue #10), shared mutable memory across kernels (fork issue #11; relates to items 1-2), streams as top-level inputs (fork issue #12), and the nested sub-region Stream compile-time-constant shape constraint (fork issue #4; item H). The living fork-vs-upstream feature map is the pinned fork issue https://github.com/sunwookim028/allo/issues/13.
Register¶
Every item was re-verified on main at 7a24c21e on 2026-09-19. Each
older item has a standalone repro under tests/limits/ that prints one
[item N] <STATUS> line; the repros were run again against main’s
working tree (after the upstream #612 merge dc6b8fa6) with the same
verdicts. Statuses:
REPRODUCES: the limitation is present.
FIXED by <commit or PR>: a commit removed it. Fixes marked fork-only exist only on this fork and are upstreaming candidates.
CANNOT-REPRODUCE: the claimed failure does not occur, and no fix explains it (it may never have been broken).
NOT-A-LIMITATION: the behaviour is not Allo’s.
Layers: frontend (allo/ir/infer.py, allo/ir/builder.py,
allo/customize.py), simulator (allo/backend/simulator.py), HLS
driver (allo/backend/hls.py, allo/backend/vitis.py), emitter
(mlir/lib/Translation/Emit*.cpp), SystemC fork (the
choonsik1/allo:SystemC-emitter emitter). File:line references are to
7a24c21e unless a fork commit is named. Fix sizes are the root-cause
agent’s estimates unless marked verified (a prototype was built and
passed).
To run a repro from a checkout of this repository:
source $(conda info --base)/etc/profile.d/conda.sh && conda activate allo
export LLVM_BUILD_DIR=/home/sk3463/llvm-allo-6b09f739/build OMP_NUM_THREADS=8
cd tests/limits && python item04_math_exp.py # -> [item 4] REPRODUCES: ...
tests/limits/_worktree.py makes every repro test the tree it is committed
in: from the primary checkout it does nothing, and from a separate worktree
(which needs its own built allo/_mlir) it strips the conda env’s editable
install. item15 needs vitis_hls on PATH and takes about 4 minutes;
every other repro is codegen or simulator only. The files are not named
test_*.py, so pytest does not collect them.
Open¶
Item |
Status |
Layer |
Root cause |
Impact on TinyTPU-isa |
Fix size |
Repro |
|---|---|---|---|---|---|---|
REPRODUCES |
frontend |
|
none (no transcendental ops) |
~10 lines |
||
REPRODUCES (extended by E) |
frontend |
Kernel contexts share the region’s scope list ( |
forces the |
~15 lines |
||
REPRODUCES – but loudly: a symbol-redefinition error, not a silent break |
frontend |
|
none (one region, no sub-region calls) |
8 lines (prototyped; simulator-verified, HLS untested) |
||
|
simulator, HLS driver, frontend |
(a) |
diagnosis time only |
3-line prototype yields |
||
REPRODUCES, plus a false positive |
emitter |
|
forces flat |
not sized |
||
REPRODUCES – via |
frontend |
Process calls are emitted in |
forces |
~30 lines (topological sort) |
||
REPRODUCES |
HLS driver |
|
|
~150 lines |
||
(a), (c) REPRODUCE; (b) CANNOT-REPRODUCE; (d), (e) are capabilities and work |
frontend |
(a) |
(c) forces declare-before-assign at
|
(c) 4-line prototype passes 12 dataflow tests (the AIE non-unroll path must keep the scope) |
||
REPRODUCES (fork-only code) |
emitter |
|
none (Vitis target) |
~10 lines to reject instead |
||
REPRODUCES |
emitter, HLS driver |
Only construct/get/put are dispatched ( |
none (Vitis target) |
not sized |
||
REPRODUCES |
SystemC fork |
|
none (Vitis target); blocks a MiniTPU-class delay line on the SystemC path |
the |
|
|
REPRODUCES (on probes) |
HLS driver, emitter |
|
none on the shipped design: the opt-in |
see item text |
probes (not committed) |
|
REPRODUCES (Allo’s rule, not Vitis’s) |
emitter; SystemC fork |
Allo refuses a region-scope Stateful shared by two kernels
( |
0 cycles, measured: every restructure the design needed was Allo-legal |
SystemC fork: ~50-100 lines (bind one writer and one reader to one instance’s two pin sets) |
||
REPRODUCES |
simulator |
The simulator pipeline has no math-to-LLVM pass
( |
none (no transcendental ops) |
1 line (verified) |
||
REPRODUCES |
frontend |
A scalar |
not assessed |
3 lines (verified) |
||
REPRODUCES |
frontend |
|
harness only: a sweep or test that expects to catch a build failure is terminated |
not sized |
||
REPRODUCES (fork-only code) |
emitter |
Catapult |
none (Vitis target) |
not sized |
reported by item18_catapult_try_ops_blocking.py |
|
REPRODUCES |
frontend |
Item 5 extended: same-name, same-type shadowing of a region parameter gives an MLIR region-isolation error. Same root cause as item 5. |
as item 5 |
with item 5 |
variant (b) of item05_region_param_shadowing.py |
|
REPRODUCES |
frontend (AIE) |
The AIE |
none (Vitis target) |
not sized |
informational line of item07_bitwise_and.py |
|
REPRODUCES |
frontend, HLS driver |
|
+409 / +361 cycles at 4x4x4 / 16x16x16 when restored on |
not sized (warn; or reset-time init; or elide – see item) |
|
|
REPRODUCES |
frontend |
A called sub-region is type-checked with the caller’s globals:
|
none on TinyTPU-isa (one region); blocks composing regions across modules. Workaround: import the sub-region’s globals into the caller |
~5 lines (merge the callee’s own module globals over the caller’s); not prototyped |
||
PARTLY FIXED (fork-only) – bf16 emits and is bit-exact on Vitis; three residual divergences below |
emitter |
|
unblocks the BF16 MiniTPU model, which could not be emitted at all. On Vitis a bf16 MAC still costs a full fp32 multiplier (3 DSP) and adder – the win is storage and bandwidth, not arithmetic |
landed (emitters + |
|
Fixed or closed¶
Closed items are tracked as closed GitHub issues, not as rows in this file: the issue body carries the status, root cause, fix commit/PR and repro that used to live here. This keeps live state in git/GitHub rather than in a checked-in snapshot.
Item |
What it was |
Issue |
|---|---|---|
Region-scope |
||
|
||
Simulator dropped nested-call stream lowering |
||
No bitwise |
||
Sim cache invalidation misses imported helpers |
||
Simulator deadlocked when processes outnumbered OMP threads |
||
Bit-slices lowered to signed |
||
Emitter could generate a local colliding with a parameter name |
||
No |
#10 (predates this
migration; not reused for anything else). Its section below is kept in
full rather than trimmed: the legality rule that landed after the fix
( |
Item 13 is not in this table: it was largely
retracted, but a real convenience gap (no allo.dma intrinsic) remains
under the same item number, so its status is not unambiguous enough to close
as a GitHub issue outright – see its own section below.
Two sub-items sit inside rows of the open table: 10(b) and 17(b) are CANNOT-REPRODUCE.
Corrections from the 2026-09-19 re-verification and impact analysis¶
These replace text that earlier revisions of this page (and of the notes it came from) stated. The per-item sections below have been corrected in place.
Item 6’s diagnosis was wrong. Plain locals declared inside
elifarms always worked (repro variants (a) and (b) pass; (c), reading a name declared in a different arm, is rejected by the frontend). The failure was item 1’s cause and was fixed with it by5c4d1b53; item 6 is merged into item 1.Item 8 does not break silently. Two callsites now fail loudly with a symbol-redefinition error at build time.
Item 7 cannot be reproduced; bitwise ops have worked since
12f898d7(2023). Only the AIEcpp-stylerules lack them (item F).Item 9 is not an Allo limitation: the cache belonged to another project’s Makefile.
Item 14 has a false positive: a local 2-D array passed to a nested function is rejected too.
Item 15 hangs silently via
df.build(target="vitis_hls", mode="csim")rather than printingan hls::stream is read while empty.The one-owner-per-array rule is Allo’s, not Vitis’s (Shared on-chip memory: the one-owner rule is Allo’s, not Vitis’s). Earlier text, here and on the TinyTPU-isa pages, attributed it to Vitis (
HLS 200-779/200-979). A Vitis probe shows#pragma HLS stream variable=buf type=unsyncshares an on-chip array between two processes, one per BRAM port (HLS 200-824/200-755/200-634);200-779applies only to synchronized arrays. Vitis does separately forbid onem_axibundle read by two processes (HLS 200-1013/200-984). The earlier claim that removingvru’s double handling needs a second producer on a shared memory was wrong: measured impact on the design is 0 cycles, because every restructure it needed was Allo-legal.Item 21 is priced (it was “the 2.3% itself, forgone”): 35 cycles on the shipped design at 16x16x16, 95 once the design fixes are in; the pragma form costs 1,744 FF in
accuagainst 17,438 for the reverted rotation; and it can be injected today by patchingkernel.cppbetweens.build(mode="csyn")and running Vitis. See TinyTPU-isa vs Gemmini: A Matched Comparison for the attribution.Item 22’s root cause is located:
emitWireGet/emitWirePutat forkEmitSystemC.cpp:1828-1848are baresc_signalaccesses with nothing tying the reader’s loop to the writer’s. The fix is theSC_METHODcomb mode scoped inc7402f9f.AlloMemPins does not give two clients one memory: memory instances are keyed per (call, operand) at fork
EmitSystemC.cpp:2539-2545, so each client gets a replica. The fix is ~50-100 lines binding one writer and one reader to one instance’s two pin sets. Catapult SystemC Flow and the Wire is corrected accordingly.
Surfaced while building the L2 TPU (FlashAttention)¶
Notes from the Track B step 2 effort to add VPU + hardware loop + on-device
transpose to levels/L2/tpu.py. Each item is a concrete obstacle that
required either a workaround in user code or a patch to the Allo library.
Priority annotations below were folded in from the former root STATE.md
(now removed; project state is judged from git/GitHub, and the living
fork-vs-upstream feature map is the pinned fork issue
https://github.com/sunwookim028/allo/issues/13). Related feature-gap tracking
lives as fork issues and is
not restated here: combinational wires (fork issue #9), HLS dependence pragma
(fork issue #10), shared mutable memory across kernels (fork issue #11; relates
to items 1-2 below), streams as top-level inputs (fork issue #12), and the
nested sub-region Stream compile-time-constant shape constraint (fork issue #4;
item H, not item 3 as an earlier revision said).
1. Region-scope @ Stateful lowering was incomplete (+ item 6) – closed¶
FIXED by 5c4d1b53 (fork-only; upstreaming candidate). Closed as
issue #36, which carries
the root cause, the fix and the repro.
2. @ Stateful could not be declared inside @df.kernel bodies – closed¶
FIXED by upstream PR #577. Closed as issue #37, which carries the root cause and the repro.
3. Simulator dropped nested-call stream lowering – closed¶
FIXED by 5bc104c8 (fork-only; upstreaming candidate). Closed as
issue #38, which carries
the root cause, the fix and the repro.
4. math.exp / math.log are not recognized by the AST builder¶
Status (re-verified 2026-09-19)
REPRODUCES. Note that the workaround below, allo.exp, does not run on the dataflow simulator: see A. The dataflow simulator cannot run allo.exp / allo.log.
Inside
@df.kernelbodies,math.exp(x)raisesKeyError: 'exp'.Must use
allo.exp(x)(and friends) instead.Not documented as a constraint; the failure mode (KeyError on a Python-builtin-ish name) does not point at the workaround.
Priority: Low —
allo.expworks.
5. Variable shadowing between region params and kernel-local names¶
Status (re-verified 2026-09-19)
REPRODUCES. Extended by E. Item 5 extended: same-type shadowing of a region parameter (same-type shadowing).
Declaring a local
d_addr: int32 = cmd[3]inside@df.kernel def compute_driverraisesAssertionError: Invalid assignment to d_addr, type mismatchbecause the enclosing@df.region def tpu(..., d_addr: int32[1], ...)parameter leaks into the kernel scope. The compiler treats the localint32write as an attempted rebinding of the region parameter (aint32[1]).Workaround: rename every kernel-local that happens to share a name with a region parameter (
d_addr → cmd_d, etc.).The error message names the variable but not the shadowing, so this takes a while to diagnose.
Priority: Medium — silent/misdirected error.
6. Local int32 decls inside elif branches don’t dominate uses – closed¶
Merged into 1. Region-scope @ Stateful lowering was incomplete (+ item 6) – closed – the diagnosis for this item was wrong:
plain locals in elif arms always worked, and the failure was item 1’s
cause. See issue #36.
7. No bitwise & operator support in Allo expression DSL – closed¶
CANNOT-REPRODUCE: bitwise ops work in the default typing rules since
12f898d7 (2023). Only the AIE cpp-style rules lack them:
F. The AIE cpp-style typing rules reject every bitwise op. Closed as
issue #39.
8. Single-MXU-call rule (Allo region instantiation)¶
Status (re-verified 2026-09-19)
REPRODUCES, but loudly: two callsites now fail at build time with a symbol-redefinition error (redefinition of symbol named 'feed_0__0_fixed'), not silently as stated below.
mxu(...)cannot appear in two differentif/elifbranches even if they are mutually exclusive at runtime. Allo instantiates sub-regions at build time regardless of conditions, so two branch callsites become two independent instances. (Corrected 2026-09-19: the build now fails loudly with a symbol-redefinition error; an earlier revision said the design silently breaks.)Each level keeps
mxu(...)in exactly one combined branch (OP_MM | OP_MMTfor L1;COMPUTE_PRELOADED | COMPUTE_ACCUMULATEDfor L2).This forces unnatural code structure — the natural reading is “if preloaded, do mxu with these args; if accumulated, do mxu and add” — but the compiler needs us to flatten them.
Priority: Medium — forces unnatural code structure.
9. Sim cache invalidation misses imported helpers – closed¶
NOT-A-LIMITATION: the .cache/llvm_sim/ cache belonged to another
project’s Makefile; Allo keeps no simulator cache. Closed as
issue #40.
10. Error messages point at lowered MLIR, not source¶
Status (re-verified 2026-09-19)
REPRODUCES; (b) CANNOT-REPRODUCE – three regions build and run in one process.
Most failure modes surface as MLIR / LLVM errors at line numbers in a generated module that the user never sees. Examples:
loc("-":1892:3): error: cannot be converted to LLVM IR ...Assertion 'value' failedFailure while creating the ExecutionEngine
Mapping these back to the offending Python construct requires dumping
s.moduleand counting lines — there is no source-position attribution back to the originaltpu.py.The MLIR Context cannot be re-instantiated in the same Python process without crashing (
LLVM ERROR: Option 'fast' already exists!), so debugging via “build twice and diff” doesn’t work.Priority: Low.
Surfaced while building an instruction-programmable TPU (2026-09)¶
A second pass, from building examples/tinytpu/ on main:
an int8 instruction-programmable tiled-GEMM accelerator taken through the
Vitis dataflow path to RTL co-simulation, and compared against a data-type-
and mesh-matched Gemmini (TinyTPU-isa vs Gemmini: A Matched Comparison).
The findings below were previously scattered across examples/accelerator/*/
markdown and none of them had reached notes/ (the fork’s former notes
directory, now this documentation), which is why they are consolidated here. They are ordered by what they would cost an Allo user, not
by when they were found.
11. The dataflow simulator deadlocked when processes outnumbered OMP threads – closed¶
FIXED 2026-09-17 by f193c057 (fork-only; upstreaming candidate, upstream
draft PR #611): the OpenMP team is now sized to the section count instead of
defaulting to the core count. A tier-0 diagnostic watchdog (7bc6d413) was
added at the same time; the tier-1 per-channel report remains unbuilt –
track as a fresh enhancement rather than here. Closed as
issue #41.
12. Bit-slices lowered to signed ap_int<N>, silently, and the simulator disagreed – closed¶
FIXED by upstream PR #612 together with fork 3de74846, merged into
main in dc6b8fa6. Closed as
issue #42.
13. “No program-controlled DMA” (struck) – largely RETRACTED; the gap is convenience, not capability¶
Status (re-verified 2026-09-19)
Retracted 2026-09-18 by the entry itself; not part of the 2026-09-19 pass.
wrap_io=True copies each argument into a local buffer before the region runs,
sized to the declared array rather than to what the program touches.
wrap_io=False drops the copy but every access then pays bus latency.
Measured, same design, one build each (cycles, cosim):
config |
marginal |
fixed |
4x4x4 |
16x16x16 |
|---|---|---|---|---|
|
18.1 cyc/instr |
1102 |
2.12x |
1.94x |
|
39.8 |
481 |
1.21x |
2.25x |
Gemmini |
10.8 |
483 |
1.00x |
1.00x |
The retraction (2026-09-18)¶
The conclusion drawn from that table – “Gemmini has a third option and Allo does not expose it” – was wrong, and the table itself is confounded.
wrap_io=False was measured with a strided access pattern and no local
buffer. Probing which patterns Vitis actually bursts, with wrap_io=False and
flat arguments:
lA[(off + r) * MAXDIM + e], e over meta_for(T) <- what our dma_ld does
[HLS 214-115] Multiple burst reads of length 4 and bit width 8
for i in range(n): buf[i] = lA[off + i] (n RUNTIME)
[HLS 214-115] Multiple burst reads of VARIABLE LENGTH and bit width 8
A contiguous copy with a runtime length from a runtime offset infers a real
variable-length AXI burst, at II=1. That is mvin, and Allo expresses it
today. The 39.8 cycles/instruction in the table above is the cost of 4-byte
bursts, not of m_axi; it condemns the access pattern, not the configuration.
What remains true, and what is actually left:
wrap_io=Truegenuinely is not a DMA.wrap_data_movement(allo/ir/transform.py:450) takes its extent fromshape = MemRefType(arg.type).shape– the static type – with no offset and no length anywhere in the generated function. It is a whole-argument hoist run once at region entry, and every declared word is a startup cycle whether the program touches it or not.So the two options are “hoist everything” or “issue your own bursts”, and the second one works. What Allo lacks is only the convenience of an
allo.dma(buf, ptr, offset, length)intrinsic that makes the burst idiom obvious rather than something you discover by reading HLS burst messages.Priority: Medium (an ergonomics and documentation item), down from High. The performance work it was blocking is ours, not Allo’s.
The general lesson¶
The measurement that produced the wrong conclusion was real and repeatable; it was the attribution that was wrong. Two configurations were compared while a third variable – the access pattern – differed between them, and the result was charged to the configuration. Before charging a cost to the toolchain, check that the thing being measured is the thing named.
14. wrap_io=False rejects multi-dimensional arguments to nested kernels¶
Status (re-verified 2026-09-19)
REPRODUCES, with a false positive: a 2-D local array passed to a nested function is rejected too.
Top-level multi-dimensional arrays are linearized to 1D pointers … which cannot be passed to nested functions expecting multi-dimensional arrays
The message is good and names the fix. Flat
int8[M*N]arguments with manualrow * stride + coladdressing work, and are arguably the honest shape for DRAM anyway.Priority: Low (documentation), but it interacts with #13: taking the low-fixed-cost option forces flat arguments.
15. Vitis csim executes dataflow processes in declaration order¶
Status (re-verified 2026-09-19)
REPRODUCES. Through df.build(target="vitis_hls", mode="csim") the consumer-first order hangs silently instead of printing the error below.
A consumer declared before its producer reads an empty stream:
ERROR [HLS SIM]: an hls::stream is read while empty
Kernel declaration order in a
@df.region()is therefore load-bearing forcsim(not for RTL, where processes are concurrent). Nothing documents this.Cost here:
dma_stwas declared third and consumed whataccu, declared last, produced. Reordering fixed it with no hardware change.Priority: Medium. A note in the dataflow docs would be enough.
16. cosim is not wired into df.build¶
Status (re-verified 2026-09-19)
REPRODUCES.
df.build(target="vitis_hls", mode=...) handles csim and csyn; every other
mode routes to the XDEVICE Makefile flow, and the emitted host.cpp is an
OpenCL/XRT host, which is not what cosim_design wants.
For an instruction-programmable design this matters more than it looks: the loop trip counts are data, so
csynthcan only report a worst-case bound derived from the ISA’s field widths. Measured, same design, two builds:build
csynth
cosim
ratio
16x16 array, 262 instances
2.259e+08
1,176
~192,000x
4x4 array, before narrowing a row-count field
91,407
4,133
22x
The five-order-of-magnitude case is the headline; the 22x is the one that was fixed, by giving the row count its own 7-bit field instead of a 12-bit one (no datapath change). Cosim is the only number comparable to a real accelerator’s cycle count.
The general rule, which cost two projects time independently: a model and a measurement that disagree are usually answering different questions, and the question the model is answering is often about the design space rather than the program. csynth was not broken either time – it was correctly bounding the machine the encoding permitted. The mirror-image case is a model that predicts a schedule being read as a prediction about the emitter you actually shipped.
examples/tinytpu/cosim.pyis a working driver: it generates a plain C++ testbench from the same program and reference the simulator uses, patchesm_axidepths (cosim requires them; Allo emits none), and drivesvitis_hls. It is ~180 lines and could be folded into the backend.Priority: Medium.
17. Frontend constraints worth documenting¶
Status (re-verified 2026-09-19)
and (c) REPRODUCE; (b) CANNOT-REPRODUCE; (d) and (e) are capabilities and work.
Each cost real time; none is a bug exactly, but none is discoverable:
Stream-array subscripts must be compile-time. A runtime index fails with “Fail to resolve the expression as symbolic expression” – correct, but it does not mention streams. Use
allo.meta_for.Nested
meta_forover a 2-D stream array fails where a singlemeta_forover a 1-D one is fine. Forces flat[T*T]stream arrays indexedi*T + j.Names bound inside
meta_ifare not visible after it (“Unsupported Namea”). Declare before, assign inside.Runtime loop bounds do work in a
df.kernel, including with stream ops in the body. This is the capability that makes a workload-independent design possible at all – one build, shape as data – and it is undocumented.df.buildiscustomize(func)+s.build(...), so the schedule primitives (s.partition,MockBuffer) are reachable on the Vitis path. Also undocumented, and load-bearing: partitioning the feeders and the accumulator took the top-level interval from 168 to 74 cycles, measured on the since-removed weight-stationary design (git show e2451b81:examples/accelerator/tinytpu_vitis/RESULTS_WS.md, section 3).Priority: Low individually, Medium as a “dataflow gotchas” page.
18. Catapult lowers try_get/try_put to blocking reads with success hard-coded true¶
Status (re-verified 2026-09-19)
REPRODUCES. full() has the same problem: D. Catapult full() always returns false.
mlir/lib/Translation/EmitCatapultHLS.cpp emits ch.read(v) for
StreamTryGetOp and ch.write(v) for StreamTryPutOp, then emits
bool success = true; unconditionally. The comment says why: nb_read /
nb_write inside a spin-while loop segfaults Catapult’s go compile (LOOP-19),
and a blocking op “always succeeds → spin-while exits in 1 iteration →
bounded”.
The workaround is defensible; what is not is that it is silent, and that the
comment understates it. “Scheduling semantics differ only at runtime” is true
for the while not S.try_put(x): pass idiom, which is what the bring-up
designs used. It is false for the reason non-blocking ops exist:
Any design that branches on failure – try this channel, else try the next; poll a control channel and do other work if empty; arbitrate between requesters – has its else-branch turned into dead code, because
successis a compile-timetrue. The RTL is silently a different design from the one written: a decoupled, backpressured graph becomes lock-step blocking.It is not diagnosed. No warning, no
#error, nothing in the emitted C++ marking the substitution. The first sign is a Catapult schedule that does not match the intended architecture, or a deadlock in a design the simulator runs fine.The three backends now disagree on the same frontend op, which is the deeper problem: Vivado emits honest
.read_nb(/.write_nb((EmitVivadoHLS.cpp); Catapult emits blocking +true; TAPA emits nothing and hard-fails the build (see #19 below, andtests/dataflow/test_stream_ops_hls.py::test_tapa_stream_nb). A frontend primitive whose meaning changes per target is a correctness trap, not a portability inconvenience.Related Catapult deviation, same file:
empty()is emitted as!ch.available(1)becauseac_channelhas no.empty()in the synthesizable subset (EDG CIN-59). That one is a faithful translation.Documented today only in Catapult SystemC Flow and the Wire (as a backend note, not as a correctness risk) and Non-Blocking Stream Operations.
Priority: Medium as it stands, High for anyone building arbitration on the Catapult path. The cheap fix is to refuse: raise on
StreamTryGetOp/StreamTryPutOpfortarget="catapult"unless an explicit opt-in attribute says the blocking substitution is acceptable. Failing to build beats silently building the wrong circuit.
19. try_get/try_put on the TAPA target fail to emit, with a generic message¶
Status (re-verified 2026-09-19)
REPRODUCES.
EmitTapaHLS.cpp’s visitor dispatches only StreamConstructOp /
StreamGetOp / StreamPutOp. The base hooks emitStreamTryGet,
emitStreamTryPut, emitStreamEmpty, emitStreamFull in
mlir/include/allo/Translation/EmitBaseHLS.h are empty bodies and are never
reached: the op falls through to visitUnhandledOp, and emitBlock reports
"can't be correctly emitted", which surfaces as
RuntimeError: Failed to emit HLS code. ... Common issues: nested functions with multi-dimensional arrays when wrap_io=False.
Failing is the right call – this is strictly better than #18. But the message
names a cause that has nothing to do with the actual one, so the user is sent
looking at wrap_io instead of at an unsupported op. TAPA has try_read /
try_write, so the gap is implementable, not fundamental.
tests/dataflow/test_stream_ops_hls.py::test_tapa_stream_nbasserted.try_read(/.try_write(and so had been failing outright. Markedxfail(strict=True, raises=RuntimeError)with the mechanism named, so the gap is recorded and the test turns red the moment the codegen lands.History worth noting:
3723e817deleted this test as dead, and mergecdac5e68resurrected it. A test can come back from the dead in a merge without anyone noticing it is red.Priority: Low for the codegen, Medium for the error message –
emitErrorshould name the op it could not emit.
A failure mode worth naming: an unused capability measures as a worthless one¶
Two independent instances, one from this project and one from the MiniTPU project, and the trap is sharper than “profile before optimising”:
MiniTPU split a controller FSM to remove a serialisation bound and measured 719 cycles before, 719 after – because their emitter issues eight pushes then eight pops, so the two halves never hold work at the same time.
We scaled the array from 4x4 to 16x16 and measured 1.47x for 16x the PEs – because the design is fixed-cost bound and the data path cannot feed it.
In both cases the measurement is correct and the obvious reading of it is wrong. “Splitting the controller does not help” and “a bigger array does not pay” are what the numbers literally say, and both conclusions are false: the capability was unused, not worthless. Nothing in the measurement distinguishes those two, which is what makes it dangerous – a null result normally retires a hypothesis, and here it silently retires the wrong one.
Practical rule: before changing a mechanism, confirm the workload can present the mechanism with work it could exploit. If it cannot, fix the schedule or the feed first, or the experiment will tell you the mechanism is useless.
Theories tested and disproved¶
Recorded so nobody re-runs them. Each looked plausible and each cost a cycle of investigation:
theory |
test |
result |
|---|---|---|
PE |
swap the two puts |
minimum depth unchanged |
a cycle in the process graph deadlocks |
two repros, one with real traffic on every edge |
both ran – cyclic regions are fine |
the |
chunk it |
still hung |
the sequencer’s control broadcast is the cause |
rewrite as a forwarding chain |
exact, depth unchanged |
The actual cause was #11, in the simulator, not in any of these.
20. The emitter could generate a local whose name collided with a parameter – closed¶
FIXED by aece11c9 (fork-only; upstreaming candidate). Closed as
issue #43, which carries
the root cause, the fix and the repro.
21. No #pragma HLS dependence primitive, so a false dependence cannot be asserted away¶
Status (2026-09-19)
FIXED by bbea2af0 (fork-only; upstreaming candidate): a schedule
primitive, s.dependence(axis, target, dep_type="inter"|"intra",
direction=None|"RAW"|"WAR"|"WAW", distance=None, dependent=False,
dep_class=None|"array"|"pointer"), next to s.partition in
allo/customize.py. It stores the claim as a dependence attribute on
the loop and emitLoopDirectives in EmitVivadoHLS.cpp emits
#pragma HLS dependence variable=<array> ... inside that loop (affine and
scf loops alike). The array is a local buffer or an argument of the
loop’s function. It is reachable on a dataflow region through
allo.dataflow.customize by the kernel instance’s name, as s.partition
is. Tests: tests/test_vhls.py::test_dependence_pragma,
::test_dependence_pragma_rejects_bad_claims,
::test_dependence_pragma_dataflow_region. 112 lines of Python (about
half of them the docstring and argument checks) and 46 of C++, against the
30-50 sized: larger than sized, not blocked. TinyTPU-isa has used it for its
accumulator since e24e433b (The accumulator’s dependence claim); the price
below is what that recovered.
Update (2026-09-22): the claim is now checked, one-sidedly
The entry below closed with “A dependence claim is a contract, and the primitive does not check it. … Nothing in Allo can see a false claim; only RTL can.” The first half of that no longer holds, and the second half was always too strong: a provably false claim is visible in the IR.
allo/dependence.py is the rule, with tests/test_dependence.py (20
tests). The legality sentence:
A
dependenceclaim is legal unless the IR proves a dependence on the claimed array at a distance the claim denies.
inter with dependent=False denies every distance of one iteration or
more; inter with dependent=True, distance=d denies 1 through
d-1; intra with dependent=False denies distance 0. A dependence
provable at a denied distance is refused, at the s.dependence call and
again after every later primitive, with an error naming both accesses, the
direction, the distance and the claim that would be legal instead. A
dependence that is not provable is accepted – that is the whole point
of the primitive, so the rule must never refuse for lack of proof. It
therefore catches only demonstrably false claims and confirms nothing.
The analysis is a same-element test over subscripts of the form
constant + sum(coefficient * induction variable). A loaded or carried
index, a non-uniform stride (A[2*i] = A[i]), a mod/floordiv
subscript, an access under a guard, or an inner loop without constant
bounds all yield no proof, which is the accepting answer.
What the evidence is worth. Every refusal in tests/test_dependence.py
is against a kernel constructed to be refused; the rule has never fired on a
real design here, because there is no false claim in the tree to fire on.
The real mistake it would catch is a plainly written recurrence
(C[i] = C[i-1] + A[i]) whose author reaches for inter false to
silence Unable to enforce a carried dependence constraint instead of
restructuring the loop. See s.dependence – the rule/obligation split, worked.
What is still an obligation, and now says so: s.dependence(...,
because=...) records the premise, which appears in
s.dependence_obligations and as // dependence obligation, checked by
no tool: ... above the emitted pragma; omitting it warns
(allo.dependence.UndeclaredPremise). TinyTPU-isa’s claim is the example
– it is true only because check_program enforces THE ACCUMULATOR
DISTANCE CONTRACT in assemble(), outside the compiler – and the rule
accepts it, as it must. mutate.py’s ar_claim_false is the standing
evidence that this half is undecidable: it passes bench_isa,
stress_isa and the rule, and only TPU_TB=stress cosim catches it.
Two corrections to the text below and to
Extending Allo’s Abstractions. (1) Both pages said the analysis “already exists in
C++ (analyzeDependency, checkDependence)”. allo::checkDependence
(mlir/lib/Support/Utils.cpp:520) is return true; with its body
commented out since cec32446 and has no callers – wiring it would have
refused every claim including TinyTPU-isa’s true one, which is the backwards
failure the rule is designed against. analyzeDependency is live but
answers a question about whole loop bands with no distance. The rule is a
new analysis in Python. (2) The rule found no false claim already in the
tree: both existing claims (TinyTPU-isa’s ar, and the two in
tests/test_vhls.py) are unprovable and accepted.
Found and not fixed. s.split rewrites a band onto two new loops and
carries the dependence attribute onto neither, so a claim made before a
split disappears silently along with its pragma
(tests/test_dependence.py::test_a_loop_transformation_drops_the_claim_rather_than_moving_it).
A defect in LoopTransformations.cpp, not in the rule, and the reason no
primitive in this tree can currently falsify a standing claim.
The text below is the item as it stood before the fix.
Vitis takes #pragma HLS dependence variable=x inter false for exactly the case
where the scheduler cannot prove two accesses are independent but the author
can. Allo emits no dependence pragmas and has no primitive for one – the
only pragmas it generates are the m_axi / s_axilite interface lines in
allo/backend/vitis.py:410, plus per-array bind_storage / array_partition.
Cost, measured: an accumulator doing
ar[f1+r] = ar[f1+r] + vwithrcarried schedules atFinal II = 3in BRAM (store/load distance 1) and II=2 fully partitioned into registers. One pragma line would have said the reads and writes never alias.Without it the recurrence has to be engineered away in hardware: a write-behind rotation (hold the last two rows in registers, write
artwo iterations late, answer reads in that window from a bypass mux) takes the memory off the carried path and reaches II=1 – at 13.7x the flip-flops in that unit (1,270 -> 17,450) for a 2.3% end-to-end gain.And the redesign did not survive its own price. The rotation was built, was bit-exact at all five shapes, and was reverted at the 2026-09-18 checkpoint:
arscales with the array dimension, so the flip-flop cost grows with T while the 2.3% does not. The shipped design is back to the nested form at II=2.Priced, 2026-09-19 (replacing “the 2.3% itself, forgone”): injecting
#pragma HLS dependence variable=ar inter falseinto the emittedkernel.cpp(v_accudep, now underexamples/tinytpu/impact/) measures 35 cycles at 16x16x16 on the then-shipped design (919 -> 884; 5 at 4x4x4), and 95 once the design fixes are in (v_design_dep). The pragma form costs 1,744 FF inaccuagainst 17,438 for the reverted rotation. Before the fix the only way to get it was patchingkernel.cppbetweens.build(mode="csyn")and running Vitis. Attribution: Where the deficit came from, and what was landed.A dependence claim is a contract, and the primitive does not check it. Landing the claim on TinyTPU-isa showed that
inter falseonaris true only for programs that never read an accumulator row within two iterations of writing it: the synthesized loop loads in state 5 and stores in state 7, and a distance-1 or -2 read returns the old row in RTL while every simulator (Allo’s, and Vitis csim) is exact. The design makes the claim true in its assembler. Nothing in Allo can see a false claim; only RTL can. (Superseded in part by the 2026-09-22 update above: a provably false claim is now refused. This one is not provable, and is accepted with its premise declared.)So the missing primitive is not cosmetic: it is the difference between a one-line assertion and a hardware redesign with a real area price.
Priority: Medium-High. It is the standard HLS escape hatch for II problems and Allo cannot reach it. A
s.dependence(...)primitive alongside the existings.partition(...)is the natural shape.
22. The SystemC fork’s Wire is semantically incomplete – and wrong in RTL, not just in simulation¶
Status (re-verified 2026-09-19)
REPRODUCES; root cause located (below).
Tested 2026-09-18: it is not the free-running rewrite; the conclusion
below stands. The hypothesis was that isSteadyStateLoop (which turns a
kernel’s outermost loop into while (1) under __SYNTHESIS__ when its
induction variable is unused) made acc run unthrottled, and that
72c70dcb’s guard missed it because acc has no memory port. The guard was
extended to any loop body containing a WireGetOp (guard_experiment/ under
tests/systemc/rtlsim/), tests/systemc/dot_product_four_links.py was re-emitted from
choonsik1/allo:SystemC-emitter with and without it, synthesised with Catapult
2024.2, and simulated under Xcelium 24.03:
The emitted code changed exactly as intended.
acc_0lost its free-running loop and its done-on-entry.mul_0(Stream reads) kept its free-running loop.pe_streamandpe_channelemitted byte-identical code.pe_wirestill fails 8/8 at all 18 pacings, with and without the guard, in identical cycle counts (20 cycles at unit pacing; values0 8 40 112 240 368 496 624:accsamples every other product and then holds the last one).The current emitter’s
pe_streamandpe_channelpass all 36 pacings, andBREAK_DATAturns both red.
Removing while (1) bounds acc’s trip count but gives it nothing to wait
on. Its loop still advances on its consumer’s Push, never on the
producer. The missing piece is synchronisation, as stated below. The
August-netlist matrix further down also reproduces case-for-case under
Xcelium (RUNNER=run_mulacc_xrun.sh ./REPRO.sh).
Recorded here because it closes a question this project spent real time on: whether
the SystemC path (choonsik1/allo:SystemC-emitter) offers the non-handshaked
fixed-latency edge that a no-interlock machine needs and Allo’s Stream cannot
express. It does not.
An earlier investigation reported pe_wire as “synthesises clean and fails
csim” and concluded the SystemC thread model could not represent a wire –
i.e. that the design was good and the simulator was lying. That is backwards.
Simulating Catapult’s own pe_wire netlist under xsim:
Stream[int32,2] boundary PASS (all 18 producer/consumer pacings)
Channel[vld_rdy] boundary PASS (all 18)
Wire[int32] boundary FAIL 8/8 wrong, at all 18
Measured 0 0 0 2 2 10 10 28 against golden 2 10 28 60 110 182 280 408. The
csim failure was a true positive.
Mechanism, from the netlist rather than the model: acc_0 has no input
handshake at all – its only input is bare data – and its loop counter advances
on its consumer’s ready. mul_0 latches the wire on its producers’ valid.
Nothing couples the two counters, mul takes ~3 cycles per product and acc
one per step, so acc runs the entire loop before mul produces anything.
Positive control: holding
acc_0in reset 3-4 cycles longer and stepping it once per product makes the identical wire RTL produce the exact golden result. The wiring and arithmetic are right; only the lockstep is missing, and the correct window is 2 cycles wide (delays 2 and 5 both fail).The construct names an edge without specifying when either end samples it. Its correctness depends on a global cycle discipline that nothing in the emitter establishes, checks, or documents. The one design previously cited as evidence that wires work was already flagged as luck; this makes luck the general case.
A wire design CAN be verified – xsim on the Catapult netlist against a numpy golden, with four injected faults all going red, including a one-cycle latency change. But it needs hand-built lockstep per design and is not checkable by the type system.
Root cause in the emitter (located 2026-09-19):
emitWireGet/emitWirePutat forkEmitSystemC.cpp:1828-1848are baresc_signalaccesses, and nothing ties the reader’s loop to the writer’s. The fix is theSC_METHODcomb mode described next (c7402f9f).Making wires sound needs the emitter to give cycle-locked kernels a shared advance – one enable driving every stage’s counter, which is what a VLIW delay line is. That is scoped, in five phases, as an
SC_METHOD“comb” emission mode, and its own top risk is whether such a model simulates as well as it synthesises. The scoping document is not onmain– it exists only in commitc7402f9f, onchoonsik1/allo:SystemC-emitter:git show c7402f9f:notes/archive/SYSTEMC_COMB_MODE.md. Deliberately left there rather than restored intonotes/archive/(now retired into this documentation): it plans work on a branch that is not in this checkout, and its repro anchors are/tmppaths that no longer exist, so the two sentences above are what survives of it.
Consequence for the roadmap: the SystemC path does not currently supply a usable non-handshaked edge, so the MiniTPU-class direction is blocked further back than “Allo won’t hand out two memory ports”. It needs a scheduling model first.
Two incidental findings¶
A valid/ready protocol violation in the emitted memory port.
feed_0assertsv0_req_vldfrom its reset state but books the acknowledgement two FSM states later, whileAlloMemconsumes the request one cycle after reset – so the fullpe_wiretop deadlocks from reset at any reset length. It affectspe_streamandpe_channelequally, including the design the branch reports as Xcelium-cosim bit-exact, which suggests these netlists were never simulated standalone.No SystemC library or MatchLib on this host (
ace-01): nolibsystemc*,systemc.h,connections.hormc_connections.hanywhere. csim cannot run here for any design. SystemC 2.3.x + NVlabs MatchLib would be a few hours and no licence.
23. m_axi port widening is not reachable from user code – align_value is necessary and not sufficient (a NEGATIVE result)¶
Status (re-verified 2026-09-19)
REPRODUCES on the probes; the real design widens with the opt-in align_value (b4be2b10).
The goal was one line of Tcl: config_interface -m_axi_max_widen_bitwidth 512,
so that a long int8 burst moves 64 bytes a beat instead of one and the operand
traffic becomes nearly free. Vitis refuses:
[HLS 214-307] Could not widen since type i8 size is greater than or equal to
alignment 1(bytes)
Scope, and it is the point of this item: every result below was measured on
PROBES – small standalone kernels written to provoke that message – not on
the real design (examples/tinytpu/microarch_isa.py).
Whether the real design reproduces HLS 214-307 at all is an open question,
under separate investigation as of 2026-09-18. Three files already assert the
widening block as a whole-design fact –
examples/tinytpu/microarch_isa.py:247,
RESULTS_ISA.md:467, COMPARISON.md:280 – and the probes do not establish
that. Nothing here upgrades them; a probe result is not a design result.
What the probes did settle:
__attribute__((aligned(N)))on the element type is the wrong lever. It is the obvious first reach and it does not produce the alignment Vitis is testing: the test is on the pointer parameter, not on the type behind it.align_valueon the parameter is necessary. It is what putsalign 64on the argument in the IR; nothing else tried did.And it is not sufficient. No widening was observed in six probe combinations. “Add
align_valueand the port widens” is false, and retiring that is what this item is for – it is worth six synthesis runs to whoever reads “port widening is blocked in Allo” and sets out to unblock it.There is nowhere in the emitter to hang the attribute. The
m_axipragma that actually ships is a regex rewrite of already-emitted C++:postprocess_hls_code(allo/backend/vitis.py:381) re-splits each parameter line of the top function’s text, rewrites arrays toT *name, and emits the interface line atallo/backend/vitis.py:410. At that point there is no MemRef, no memory space and no port in scope – only a string – so there is nothing to derive an alignment from. The emitter’s own interface path, which would have had all three, is dead-commented:VhlsModuleEmitter::emitFunctionDirectives(mlir/lib/Translation/EmitVivadoHLS.cpp:2777) has its entirem_axi/s_axilitebody commented out at 2779-2837; the live code from 2838 emits onlydataflow,inline, and the per-array directives.
Pragma inventory (grep over mlir/lib, mlir/include, allo/, 2026-09-18)¶
Live on the Vivado/Vitis path: stream (EmitVivadoHLS.cpp:1775,1792,2621),
pipeline (2581), unroll (2595,2597), bind_storage (2696),
array_partition (2719), dataflow (2838), inline (2843), and the m_axi
line rewritten in vitis.py:410. That is the whole list.
Not emitted by any emitter – zero hits, not “hard to reach”:
ap_none/ap_stable/ap_fifointerface modes.#pragma HLS latency(the onlylatencyhits inallo/are report parsing,allo/backend/report.py,catapult.py:286).#pragma HLS protocol.#pragma HLS dependence– see item 21, where the cost of its absence is measured; not restated here.#pragma HLS resourcesurvives only as dead comment (EmitVivadoHLS.cpp:2756).
Correction to :ref:`item 21 <limitation-21>`, in the open. It states that the only pragmas Allo
generates are “the m_axi / s_axilite interface lines in
allo/backend/vitis.py:410”. The m_axi half is right. s_axilite is not
emitted on the Vitis path at all: the only live s_axilite in the tree is
allo/backend/pynq.py:173,176, a different backend, and the emitter’s copies
are inside the dead comment above. Item 21’s argument is unaffected – it is
strengthened, since the interface pragma set is one line narrower than claimed.
Priority: Low as an action, Medium as a warning. Nothing here asks for work: the next honest step is the open real-design question, not more probes. If the real design does reproduce
HLS 214-307, the fix is in the emitter – restoreemitFunctionDirectivesso the interface pragma is derived from the MemRef instead of reconstructed from text – and it becomes the same shape of gap as item 21: a one-line HLS assertion that Allo has no way to reach.
Note
Update, recorded after this item was written (commit b4be2b10,
2026-09-18): the open real-design question has been answered on the real
design. HLS 214-307 does not reproduce there – the widen setting is
accepted, csynth completes, and the bursts simply stay at bit width 8 with
no diagnostic. With Allo now emitting align_value on m_axi pointers
(opt-in, configs={"align_value": 64}), the same setting takes gmem0 to
bit width 512. See TinyTPU-isa vs Gemmini: A Matched Comparison and
AMD Vitis HLS (FPGA). The status of this item is left as last recorded
pending re-verification.
24. Five checks pass a TinyTPU-isa program whose Vitis cosim never completes¶
Status (2026-09-24): FIXED in the shipped design
DIAGNOSED as a channel-depth threshold on 2026-09-22 – the same ten
programs, same tree, same toolchain, all complete at TPU_QD=16
(The diagnosis: it is a channel-depth threshold, and TPU_QD=16 clears it) – and landed: QD=16 is the default in
microarch_isa.py as of 63ee6ec7 (2026-09-24), which moved the
published row to 175 / 265 / 421 / 482 / 674
(What QD=16 costs the shipped design). The item still reproduces at TPU_QD=8,
which is now an override rather than the shipped configuration, and the
predicate – which programs need which depth – remains open, so this is
a cure and not a characterisation. Because the predicate is open, the CHIA
loop does not let a candidate choose the depth: QD is proposable so that
the refusal can name this item, and 16 is the only value
evaluate.config_refusals accepts (The configuration is a candidate’s to propose, inside a stated envelope). Cheap half:
python tests/limits/item24_cosim_hang.py (seconds, no Vitis). RTL half:
ALLO_LIMITS_COSIM=1 on the same file (one csynth, ten cosims, tens of
minutes). Family and bisection:
examples/tinytpu/act/rtl_hang.py; log:
dev/records/tinytpu/logs/cosim_act_rtl_hang_bisect.log.
A legal, bit-exact TinyTPU-isa program of sixteen instructions passes every
check the fork has short of RTL and then does not finish in cosim_design:
check |
result on the minimal program |
|---|---|
|
accepts |
|
no deadlock, minimum channel depth 1 |
|
correct |
|
completes, bit-exact against |
Vitis csim |
|
Vitis cosim (xsim, RTL) |
does not complete |
This is the second independent occurrence. The first is on ACT and the TinyTPU-isa Mapper, where two row-tiled ACT mappings behaved the same way and the flow withdrew its “5 encodable” claim in favour of “3 confirmed on RTL”; that instance was found by a different tree, from a different generator, on the same design.
What “does not complete” is, exactly¶
A completing run of this design prints two progress lines and a $finish:
// RTL Simulation : 0 / 1 [n/a] @ "109000"
// RTL Simulation : 1 / 1 [n/a] @ "777000"
$finish called at time : 796590 ps
for a 198-cycle program. A non-completing run prints the first line and
never the second. 109000 is picoseconds and is simply where Vitis makes
its first periodic report, so it is the same number in every log, passing or
hanging – it is not where the design stops, and nothing measured here
locates the stall. Vitis’ own deadlock detector reports nothing, so this item
does not call it a deadlock. What is measured is: no second progress line, no
report, and no completion against a neighbour that finishes in under a second
of simulated time.
The minimal program¶
act.rtl_hang.tiled(n_out=2, n_block=2, n_reduce=2, rows=4): two output
column blocks x two row blocks, each a two-deep accumulation of four rows
followed by an mvout. Fully unrolled – no LOOP/ENDLOOP at all –
four dma_ld and twelve compute instructions, plain int8 GEMM tiling with no
vrelu and no epilogue.
The bisection, and what it rules out¶
Ten programs, one csynth, one cosim each, ACT_COSIM_TIMEOUT=200:
program |
cosim |
knob changed |
|---|---|---|
|
no completion |
the minimal case |
|
223 |
one output block |
|
223 |
one row block |
|
198 |
both |
|
230 |
no accumulate step |
|
356 |
eight rows instead of four |
|
no completion |
the other program the judge found |
|
291 |
one output block |
|
459 |
the |
|
no completion |
four rows instead of sixteen |
Four hypotheses die here, and they are the useful part of the item:
Not the hardware loop. The looped and fully unrolled forms of the
pointwiseprogram behave identically, and the wholetiledfamily is unrolled.Not ``vrelu``. The entire
tiledfamily has novreluand the minimal case is in it. Converselygemm.reluat 16x16x16 has avreluin a loop and completes in 750 cycles.Not “a transfer inside the emitted nest rather than hoisted into a prologue” – the hypothesis left open on ACT and the TinyTPU-isa Mapper. Measured the other way round, twice each: both non-completing programs have every transfer in a prologue, and both
weights_reloadedmappings, which issue adma_ldbetweenmvouts inside the output nest, complete (256 cycles at 8x8x8, 638 at 16x16x16, 0 of 256 wrong). In-nest staging is neither necessary nor sufficient, so a staging column does not separate the two classes. The same hypothesis was killed independently from the small end:tests/limits/item24_cosim_small_programs_complete.pycosims four deliberately constructed programs atrows=4– a prologue-only control, adma_ldafter anmm, adma_ldinside a loop, and adma_ldsharing a loop body with anmmand anmvout– and all four complete, at 169 / 189 / 171 / 186 cycles. Two trees, two directions, one dead hypothesis.Not monotone in size. Halving any one of the three tile counts makes it complete, and so does doubling the row count. It is neither “too big” nor “too small”, which rules out simple capacity and fill explanations.
No positive characterisation is offered: the four surviving programs that fail
share three or more accu instructions per output tile and four or more
output tiles, but tiled n2 b2 k2 r8 has both and completes, so that is a
correlation and not a condition. The diagnosis was open, until the
channel-depth measurement below.
The diagnosis: it is a channel-depth threshold, and TPU_QD=16 clears it¶
The whole family run twice in one tree on one day, ACT_COSIM_TIMEOUT=300,
the only difference being the stream depth every channel is declared at:
program |
|
|
|
|---|---|---|---|
|
no completion |
359 |
the minimal case |
|
320 |
324 |
|
|
320 |
324 |
|
|
295 |
299 |
|
|
327 |
331 |
|
|
549 |
553 |
|
|
no completion |
681 |
the other case the judge found |
|
484 |
488 |
|
|
652 |
656 |
|
|
no completion |
280 |
All ten complete at QD=16, bit-exact, and the three that do not complete at QD=8 are exactly the three this item was filed for. The control is in the same run: every program that completed at QD=8 still completes, four cycles slower, which is the deeper FIFOs’ pipeline skew and is the sanity check that the two columns are the same design.
This also explains “not monotone in size”, which was the observation nobody could place: a threshold is not monotone. Halving a tile count or doubling the row count moves the program off the threshold from either side.
A second, independent instance, found from the other end. Two reordered
GEMM programs (isa_dsl.gemm_program_interleaved and _b_per_tile, which
issue an operand dma_ld between two mms instead of hoisting every
load into a prologue) hang in cosim exactly when Kt >= QD – at T=4
32x32x32 and above, and at T=8 64x64x64 and 32x64x32, but not at T=4 16x16x16
(Kt=4), T=8 48x48x48 (Kt=6) or T=8 64x32x64 (Kt=4). 30 million
simulated cycles, no second progress line, Vitis’ deadlock detector silent –
the same signature. TPU_QD=16 completes the identical program bit-exact
(32x32x32: 3,355 cycles). Two generators, two shapes of program, one knob.
What is fixed and what is still open. Fixed: the fault is a bounded-channel
hazard between the sequencer’s in-order dispatch across five queues and the
units’ in-order consumption, and the depth those queues are declared at is the
threshold. Open: the predicate. Kt >= QD holds for the reordered GEMMs
and says nothing about this item’s family, and the obvious counting rule –
“more than QD instructions sent to one unit” – is refuted by the shipped
GEMM itself, which sends accu 20 instructions at 16x16x16 and 272 at
64x64x64 at QD=8 and completes. So depth is the cure without yet being the
characterisation, and a program cannot be screened by counting.
What QD=16 costs the shipped design¶
Raising the depth is a change to the shipped design, which the reproduce
gate pins at five numbers, so it is measured in the same paired form: one
tree, TPU_MAXDIM=16, everything else unset, QD=8 and then QD=16.
The right-hand column is the published row since 63ee6ec7; the
left-hand one is what was published before it.
shape |
|
|
delta |
|---|---|---|---|
4x4x4 |
171 |
175 |
+4 |
8x8x8 |
261 |
265 |
+4 |
12x12x12 |
417 |
421 |
+4 |
16x16x8 |
483 |
482 |
-1 |
16x16x16 |
685 |
674 |
-11 |
The row moves and it is not uniform, which is the interesting part: the
three smallest shapes pay the pipeline skew of deeper FIFOs (+4 each, the same
+4 the item-24 family paid), and the two largest get faster, because a
deeper queue lets the sequencer run further ahead of the units it dispatches
to. The QD=8 column reproduces the then-published
171 / 261 / 417 / 483 / 685 exactly, which is the control; the QD=16
column is the row reproduce.sh checks today. The same deltas reproduce
independently at TPU_MAXDIM=64: 218 / 357 / 563 / 677 / 879 becomes
222 / 361 / 567 / 676 / 868, +4 / +4 / +4 / -1 / -11 again, so the
lumpiness is a property of the depth and not of one stride.
Correctness at QD=16 is unchanged: stress_isa.py prints STRESS OK:
492/492, and the RTL gate (TPU_TB=stress) is 0 wrong over six calls at
both 4x4x4 and 16x16x16 – including ar_distance(4), so the accumulator’s
dependence contract still holds at the deeper queues.
The price, on the FPGA¶
csynth on xcu280 at the 3.33 ns target, shipped config (T=4, MAXDIM=16):
|
BRAM |
DSP |
FF |
LUT |
estimated period |
|---|---|---|---|---|---|
8 |
40 |
14 |
17,075 |
26,558 |
2.431 ns |
16 |
40 |
14 |
18,699 (+9.5 %) |
27,210 (+2.5 %) |
2.431 ns |
32 |
40 |
14 |
19,779 (+15.8 %) |
28,420 (+7.0 %) |
2.431 ns |
At T=4/MAXDIM=64 the same step costs +1,624 FF (+9.3 %) and +652 LUT, also with BRAM and the period unchanged.
Where the queues live, because “BRAM unchanged” is not a property of the
knob. Every one of the 106 FIFOs maps to shift registers at QD=8, 16 and
32 alike – measured, not assumed (RTMG 210-285 in the csynth log) – and
BRAM stays at 40 throughout. The widths are what decide when that stops: the
control queues are 64 bits, the operand and array chains 32, the activation
forwards 8, and the accumulator return path cw is 128, the widest. At
depth 32 that widest queue is still only 4,096 bits, which is why nothing has
migrated yet. This says depth is cheap up to 32 at these widths; it does not
say depth is cheap. A wider design, or depth 64, has to re-measure –
MiniTPU’s equivalent is block RAM they are already at 47 % of, where a 4 KiB
register file costs 96 of 312 tiles because the width forces four tiles
whatever the depth.
The price, on standard cells – which is a different object¶
The FPGA figure must not stand in for the ASIC one by silence, because the mechanism that makes depth cheap on the FPGA does not exist in 45 nm. Vitis emits every one of these FIFOs as
reg [DATA_WIDTH-1:0] SRL_SIG [0:DEPTH-1]; // MEM_STYLE = "shiftReg"
an unconditional shift of a DEPTH x DATA_WIDTH register array. Vivado
infers SRL16 primitives from that, and an SRL16 holds sixteen bits per
LUT whether the FIFO is eight deep or sixteen – which is exactly why the
FPGA bill for 8 -> 16 is only +652 LUT and why the +1,624 FF is pointer and
output-register overhead rather than storage. Standard cells have no such
primitive: the array is flip-flops, and the cost is linear in depth.
Counted from the instance list of the very RTL the ASIC flow reads (the two
csynth runs above, wq excluded because it is declared at a fixed depth 4
and does not scale with QD):
queue width x instances |
bits at |
bits at |
|---|---|---|
128 x 4 ( |
4,096 |
8,192 |
64 x 5 (control) |
2,560 |
5,120 |
32 x 36 (operands, chains) |
9,216 |
18,432 |
8 x 12 ( |
768 |
1,536 |
total |
16,640 |
33,280 |
So QD=16 adds 16,640 flip-flops on standard cells. Against the shipped
MAXDIM=16 baseline’s DC run – 200,561 sequential cells, 1,136,598 total cell
area, 906,098 of it non-combinational – that is +8.3 % sequential cells
and, at that run’s 4.52 area units per sequential cell, about +6.6 % of
total cell area.
A full DC run was not available here: the flow is mflowgen on zhang-21
with /scratch, the module system and a DC licence, none of which exist on
this host, and a run is 37-72 minutes there. The figures above are therefore a
count from the RTL plus the measured area-per-sequential-cell of the same
design in the same flow, not a synthesis result, and they should be replaced
by one when that host is free. What is not an estimate is the mechanism: the
FIFO is a register array in the Verilog, and there is no SRL in 45 nm.
That the two substrates land at a similar percentage (+9.5 % FPGA FF, +8.3 % ASIC sequential cells) is a coincidence of this depth, not agreement: the FPGA number is control overhead with the storage free in SRLs, the ASIC number is the storage itself. At QD=32 the ASIC cost doubles again (+33,280 flops, linear) where the FPGA adds only a further 1,080 FF.
The decision, and what landing it left open¶
Landed: ``QD=16`` is the default (63ee6ec7, 2026-09-24), and the
published row is now 175 / 265 / 421 / 482 / 674. A design that does not
complete on legal programs is worse than one four cycles slower, and every
reason to hesitate was checked rather than assumed:
the five-shape row moves by +4 / +4 / +4 / -1 / -11, and no margin against Gemmini flips: at these shapes the design is behind Gemmini at QD=8 and still behind by the same few per cent at QD=16, and at the matched MAXDIM=64 points the two largest shapes move the right way;
correctness is unchanged (492/492, and the RTL stress gate including the dependence-contract case);
the clock does not move: 2.431 ns estimated at QD 8, 16 and 32 alike;
the cost is +9.5 % FF and +2.5 % LUT on FPGA with no BRAM, and about +8.3 % sequential cells on standard cells – which must be quoted beside the cycles, because the ASIC area comparison against Gemmini is being assembled and a sequential-cell change of that size lands directly on it.
The one honest caveat is that this buys termination with area, and it buys it for a hazard whose predicate is still open. Nobody should read “depth is the cure” as “depth is free at any depth”: the FPGA ladder is sub-linear only because of SRL16, and the ASIC cost is linear.
What landing it did not settle, and what must not be quietly absorbed:
The ASIC price is still unmeasured. The +16,640 flip-flops above are a count from the RTL priced at this design’s own measured area per sequential cell, not a DC run, and the T=8 ASIC export was taken from the
QD=8RTL. Both need redoing against the shipped netlist.The Gemmini parity sweep was measured at ``QD=8``. Every deficit and ratio on TinyTPU-isa vs Gemmini: A Matched Comparison and The Benchmark Set therefore describes the previous default. The pre-landing check above says no margin flips, which is why landing was safe; it is not a re-measurement, and no comparison should be restated until the sweep is re-run at ``QD=16``.
2026-09-22: a channel depth flips it, and the program is a shipped mapping
A fifth instance, from the PyTorch workload suite
(The Workload Suite: PyTorch MLPs on TinyTPU), narrows the search. The program is
isa_dsl.gemm_program(4, 16, 16, relu=True) – the shipped GEMM
mapping, the same one reproduce.sh cosims at other shapes, and the one
the ACT search independently picks for that shape. At the shipped
TPU_MAXDIM=64:
TPU_QD=8(the default): no completion. One progress line, none after it, andxsimkheld a core at 99 % for over twelve minutes against a program the same build runs in 584 cycles.TPU_QD=16, the only knob changed: 584 cycles, 0 of 4096 bytes wrong. Its second layer, and all four layers of a four-layer MLP, the same.
Two things follow. First, the fault is sensitive to channel depth,
which the earlier bisection never varied – so whatever blocks is a
handshake and not a value, and QD is the first knob found that moves
the boundary rather than the program. Second, a hand-written shipped
mapping is in the failing class, so this is not a property of generated
or unusual programs; the published counts avoid it only because they are
measured at TPU_MAXDIM=16, where the burst is four times shorter.
kpn_model is not fooled for lack of trying: it reports minimum channel
depth 1 for this program, so the protocol model says QD=8 is ample.
That is the same weakness the priority note below describes – the cheap
checks descend from assemble()’s own header – now with a knob attached
to it. Try ``TPU_QD=16`` before treating a hang as a verdict on the
program.
Priority: High as a warning, Medium as an action. The warning is that on this design the cheap checks do not substitute for cosim, and two of them (
kpn_modeland the header the simulator consumes) are derived fromassemble()’s own header, so their agreement is weaker evidence than it looks. ``kpn_model`` is the specific gap: it runs the channel protocol at depthQDand reports these programs deadlock-free, so it is missing the sequencer’s ability to block part-way through the several queues one instruction is dispatched to. Making the model block mid-instruction is now a well-posed change with ten programs to check it against, and it is the action that would let a program be screened before Vitis.The remaining action on the design side is the predicate, not the cure:
cosim_design -trace_level allon the sixteen-instruction case atQD=8would name the blocked process and the full channel, which is what turns “raiseQD” into a rule the assembler could enforce.Run the RTL half in its own process group. Killing
vitis_hlsdoes not killxsim: one orphanedxsimkfrom a non-completing case here was still holding a full core 32 minutes after its parent died, and several agents share this host.item24_cosim_small_programs_complete.pystarts each child withstart_new_session=Trueand kills the group on timeout; anything that cosims a program which may not finish should do the same.
25. bfloat16 runs in the simulator and aborts the process in every HLS emitter¶
Status (2026-09-24): CLOSED – see item I, which supersedes this
Fixed the same day it was written. The account below is kept because it is
the state a reader of examples/minitpu would have found, and because two
of its three guesses about the C++ spelling were wrong in ways worth
recording: ap_bfloat16 and hls::bfloat16 do not exist – Vitis
HLS 2023.2 ships no bf16 type at all, and its one arbitrary-precision float,
ap_float, segfaults csynth at every width – so Vitis needed an
emitted shim rather than a vendor type. ac_std_float<16, 8> was right,
and is what Catapult and SystemC now use. SystemC turned out to be the
cheapest of the three, not a design decision: ac_marshaller.h already
carries a wrapper for it.
The original entry (2026-09-24): OPEN, simulator-only
bfloat16 is a complete arithmetic type in the dataflow simulator – as a
Stream element, as an operand, widened to float32 and rounded back –
and it has no emission path at all. EmitVivadoHLS.cpp:115,
EmitCatapultHLS.cpp:102 and EmitSystemC.cpp each fall through their
getTypeName chain to assert(1 == 0 && "Got unsupported type."), which
is a SIGABRT, not a catchable exception: the process dumps core with an
LLVM crash banner and no diagnostic naming the type. Repro:
python -m pytest tests/dataflow/test_bf16_dataflow.py (15 s, no Vitis) –
float16 emits on all three targets, bfloat16 dumps core on all three.
This is the trap the guidance on extensions is meant to catch: a design can be
built, run and validated end to end in the simulator and then be impossible to
emit, with the failure arriving as a core dump rather than as an error.
examples/minitpu is exactly that design – MiniTPU is a BF16 machine, so its
model is simulator-only today.
What emission needs:
A
BFloat16Typecase in each of the threegetTypeNamefunctions, ahead of theFloat16Typecase (they are distinct MLIR types;bf16does not matchFloat16Type, which is why it falls through).The C++ spelling is genuinely unknown.
ap_bfloat16andhls::bfloat16are the plausible Vitis names and neither has been tried here; Catapult’sac_std_float.hoffersac_std_float<16, 8>, which has BF16’s field layout but has not been tried either. SystemC has no BF16 type at all, so the emitter would need a 16-bit container plus conversion, which is a design decision and not a type-table entry.Whatever spelling is chosen,
bf16arithmetic must round the same way the simulator does, or a model that validates in simulation will not validate in cosim. That is a second piece of work, not a consequence of the first.
Adjacent, and separate: the int -> float bitcast rule at
allo/ir/infer.py:1195-1207 derives its result type from the bit count alone,
so 16 bits always gives float16 and a 16-bit lane can never be bitcast to
bfloat16. A 24-bit float accumulator can still be emulated – through the
32-bit bitcast pair, as examples/minitpu/microarch.py does – but a bf16
bitcast cannot be spelled.
Surfaced by the 2026-09-19 re-verification and impact analysis¶
Found while re-verifying the items above (A-F, with repros under
tests/limits/) and while pricing the TinyTPU-isa deficit to Gemmini (G and
the shared-memory item; the variants are on main under
examples/tinytpu/impact/, folded in from branch
impact-limits – commits f98c0dac and 55405e00 – since deleted).
A. The dataflow simulator cannot run allo.exp / allo.log¶
The simulator’s own pipeline in LLVMOMPModule.__init__
(simulator.py:1674-1686) has no math-to-LLVM pass, so a kernel using
allo.exp fails with Failure while creating the ExecutionEngine
(cannot be converted to LLVM IR: ... for op: math.exp). The plain LLVM
backend lowers the same op (populateMathToLLVMConversionPatterns in
lower_allo_to_llvm). This also breaks item 4’s documented workaround on the
simulator. Fix: 1 line, verified. Repro: tests/limits/new_sim_math_lowering.py.
B. Stream.get() is never cast to the destination type¶
x: int8 = s.get() or b[i] = s.get() with an int32 stream and an
int8 destination fails IR verification ('affine.store' op value to store
must have the same type as memref element type), while the same narrowing
from an int32 array element works. Root cause builder.py:1054.
Workaround: land the value in a temporary of the stream’s own type first.
Fix: 3 lines, verified. Repro: tests/limits/new_stream_get_no_cast.py.
C. customize() exits the interpreter on a frontend error¶
Any frontend error inside allo.customize (type inference or IR building)
prints a traceback and calls sys.exit(1) (customize.py:1382,1408). A
SystemExit is not caught by except Exception, so a sweep, a notebook or
a test harness that expects to catch a build failure and move on is terminated
instead. Repro: tests/limits/new_customize_sys_exit.py.
D. Catapult full() always returns false¶
EmitCatapultHLS.cpp:402-414 emits full() as a hard-coded false
(ac_channel has no .full()). Same class as item 18: a design that
branches on it has the branch compiled away, silently. Reported by
tests/limits/item18_catapult_try_ops_blocking.py.
E. Item 5 extended: same-type shadowing of a region parameter¶
A kernel local with the same name and the same type as an enclosing region
parameter (e.g. buf: int32[4] = 0 against a buf: int32[4] parameter)
does not hit item 5’s type-mismatch assertion; it gives an MLIR
region-isolation error instead. Same root cause as item 5 (shared scope list).
Repro: variant (b) of tests/limits/item05_region_param_shadowing.py.
F. The AIE cpp-style typing rules reject every bitwise op¶
What survives of item 7: the cpp-style typing rule set used by the AIE
target (typing_rule.py:855-860) has no bitwise ops, so &, |, ^
and shifts are rejected there while the default rule set accepts them. Shown by
the informational line of tests/limits/item07_bitwise_and.py.
G. = 0 on an array is a runtime zero-fill loop¶
An array declared = 0 lowers to a runtime zero-fill loop: builder.py:1063-1075
emits linalg.fill, and hls.py:288 (convert-linalg-to-affine-loops)
turns it into a loop that runs every invocation. Restoring spad = 0 on
TinyTPU-isa (v_memset) measured +409 / +361 cycles at 4x4x4 /
16x16x16 (661 / 1280 against 252 / 919, the design shipped until
e24e433b; the same with all six arrays = 0, v_memset6). This is the
514-cycle zero-fill the shipped design removed.
The right semantics are one of: warn; initialise at reset through the existing
Stateful memref.global path (correct only for the first invocation); or
elide the fill when every element is provably written before it is read.
H. A sub-region from another module is type-checked against the caller’s globals¶
Found 2026-09-19 while re-checking fork issue #4; it had no row until then, and the register previously cross-referenced #4 to item 3, which is a different, already-fixed simulator bug.
When a region calls a sub-region, builder.py builds the callee with
ASTContext(global_vars=ctx.global_vars.copy(), ...) (builder.py:2838) –
the calling region’s globals, not those of the module that defines the
sub-region. So a sub-region declaring fifo: Stream[int32, 4][N_SUB], with
N_SUB and Stream imported only in its own module, fails when called from
another module: infer.py:90 reports “stream array shape should be a compile
time constant”, or “Unsupported type Stream” if the caller does not import
Stream either. The same sub-region builds fine on its own. Upstream has the
same code.
Workaround: import the sub-region’s shape constants and Stream into the
calling module; composition then runs and gives the right result.
The repro, new_subregion_foreign_globals.py,
runs both variants and prints REPRODUCES while the caller-lacks-globals
variant fails. That variant surfaces as SystemExit: 1 rather than an
exception because customize() exits on frontend errors (item
C); the real message is on stderr. The sub-region and the
working variant are written as generated modules, because a nested definition
trips the separate re-parse IndentationError of upstream issue #588.
Fix: merge the callee’s own module globals over the caller’s when building a sub-region – about five lines, not yet prototyped.
I. bf16 ran in the simulator and aborted every HLS emitter¶
Found and fixed 2026-09-24. bfloat16 worked end to end in the dataflow
simulator – as a Stream element type and as an arithmetic type – and
killed the process in every C++ emitter: EmitVivadoHLS.cpp:115,
EmitCatapultHLS.cpp:102 and EmitTapaHLS.cpp:94 all reached
assert(1 == 0 && "Got unsupported type."), which is abort(). A Python
caller did not get an exception; the interpreter died, so “this type is not
supported” was indistinguishable from “the emitter crashed”. That failure mode
was the worst part of the gap and is now gone everywhere, including in the
backends that still do not support bf16.
What C++ type a bf16 becomes, and why. This was the open question; the answer differs per toolchain and neither guess from the documentation survived contact with the installation.
Vitis HLS 2023.2: nothing native exists.
grep -ri bfloatover/opt/xilinx/Vitis_HLS/2023.2/includereturns no hits at all – there is nohls::bfloat16and noap_bfloat16.__bf16is rejected by its clang (ERROR: [HLS 207-3801] unknown type name '__bf16'). The one arbitrary-precision float it does ship,ap_float<W, E>fromap_float.h, acceptsap_float<16, 8>and is csim-exact – butcsynth_designsegfaults on it, in CDFG construction, afterWARNING: [SYN 201-506] Unknown intrinsic op [_ssdm_op_FloatingPoint_Mul.i16.i16.i16]. The crash is not bf16-specific:ap_float<16, 5>and evenap_float<32, 8>segfault csynth the same way, soap_floatis unsynthesizable in this release at any width. (std::bfloat16_t, which the Python type maps used to name, needs-std=c++23and<stdfloat>; these flows compile at c++11/c++17.)So the Vivado/Vitis emitter defines its own:
struct allo_bfloat16, emitted into the generated header, and only when the module actually uses bf16, so non-bf16 designs keep byte-identical output. It stores a rawuint16_t(notap_uint<16>: the emitter implementsarith.bitcastwith aunion, and a union member with a non-trivial default constructor deletes the union’s own), widens tofloatfor arithmetic – exact for a bf16 product or sum – and rounds back with round-to-nearest-even, so each operation rounds exactly once, asarith.mulf/arith.addfon bf16 do.Catapult: ``ac::bfloat16`` from
ac_std_float.h, which Catapult ships in$MGC_HOME/shared/includeand the generated header already included. It compiles at-std=c++11and works insideac_channel.SystemC: ``ac::bfloat16`` as well, and it was cheaper to support there than f32 was. The SystemC emitter shares
getCatapultTypeName, so the type came for free; what each float type additionally needs there is a set of shims – raw-bit access forarith.bitcastand memory ports (data_ac_int()/set_data(), whichac::bfloat16has), text input for the testbench, andsc_trace– plus a ConnectionsWrapped<>payload specialization. bf16 needs noWrapped<>:ac_marshaller.halready hasAC_SPECIAL_FLOAT_WRAPPER(ac::bfloat16, 16), whereac_ieee_floathas none and this fork had to write one by hand for f32 (EmitSystemC.cpp,ALLO_IEEE_FLOAT_MARSHALL_DEF). So a bf16Streamlowers toConnections::In/Out/Combinational/Fifo< ac::bfloat16 >with nothing hand-written behind it. Untested against a real Catapult/MatchLib install – there is no licence on this host.
Residual divergences – the part that is still a trap.
On Vitis, bf16 arithmetic is fp32 arithmetic. Synthesis of an Allo-emitted 8x8 bf16 GEMM (xcu280, 3.33 ns) instantiates
fmul_32ns_32ns_32_4_max_dsp(3 DSP each) andfadd_32ns_32ns_32_7_full_dsp(2 DSP each) – the same operators afloatdesign gets. A 16-element bf16 MAC costs 3 DSP / 243 FF / 338 LUT for the adder and 3 DSP / 143 FF / 78 LUT for the multiplier, identical to thefloatversion of the same kernel, at the same II. What bf16 does buy on this toolchain is storage: 34 vs 62 BRAM_18K and 6629 vs 10822 FF over the whole MAC kernel. Any area model that assumes a bf16 multiplier is cheaper than an fp32 one is wrong here. A genuinely narrow bf16 multiplier would have to be written out field-by-field in the shim.Catapult’s float-to-bf16 conversion truncates.
ac::bfloat16’s operators default toAC_TRN_ZERO; the generated header now setsAC_STD_FLOAT_BFLOAT16_ROUND_OVERRIDEtoAC_RND_CONVbefore includingac_std_float.h, which makes add/sub/mul/div round to nearest-even and agree with MLIR. Itsfloatconstructor, however, hard-codesAC_TRN_ZERO(ac_std_float.h,convert<width,e_width,AC_TRN_ZERO>()) and is not overridable, so anarith.truncffrom f32 to bf16 in Catapult output truncates where the simulator rounds. Constants are unaffected (a bf16-valued literal converts exactly under any mode). Fixing it means emitting an explicitconvert<16,8,AC_RND_CONV>()for that cast rather than a plain assignment.intel, tapa and xls still do not support bf16 – deliberately not implemented. They now name the type and the backend in an MLIR error and return failure instead of aborting. (
EmitIntelHLS.cppadditionally used to callval.getDefiningOp()->emitError(...), which null-derefs for a block argument.)
Separately fixed: an int-to-float bitcast could never produce a bf16.
infer.py derived the result type of x.bitcast() from the source
bitwidth alone, so 16 bits meant float16 – a bf16 could be packed into a
UInt(16) but never unpacked. bitcast now takes an optional target type,
u.bitcast(bfloat16), checked against the source width;
builder.py:2903 already passed node.dtype through and needed no change.
The no-argument form is unchanged.
Note
A numbered item recording the same blocker from the MiniTPU side (the BF16
design that could not be taken to hardware) was in flight while this was
written. It was not on origin/main at e3f289aa; if it lands, merge it
into this entry rather than keeping two.
Evidence. An Allo-emitted 8x8 bf16 GEMM is bit-exact against the
simulator under both g++ and Vitis csim_design (64/64 elements), and
csynth_design completes: 396 cycles, 10 DSP, 2044 FF, 4325 LUT, estimated
Fmax 393.55 MHz. Catapult output is checked at the text level only; there is
no Catapult licence on this host.