Skip to content

feat: migrate to bufferized voyager IR - #4

Merged
jeffreyyu0602 merged 29 commits into
mainfrom
voyager-compiler-migration
Sep 15, 2026
Merged

jeffreyyu0602 merged 29 commits into
mainfrom
voyager-compiler-migration

Conversation

@jeffreyyu0602

Copy link
Copy Markdown
Collaborator
  • retire the param.proto/interstellar tiling flow; codegen emits voyager_ir and the toolchain maps fused prim-op chains directly
  • add the runtime that walks it under test/common: Model, Interpreter, Backend, Checker, GraphUtils, Tensor
  • overlap consecutive tiles in the SystemC harness: the walk pushes an operation's params and moves on, so a unit's controllers fetch the next tile while the current one computes, and two threads pace the start and done handshakes in program order
  • resolve a windowed TensorBoxRef -- a voyager.subview folded into an operand -- as a base address plus a contiguous run, carrying the row pitch when the innermost dimension is partial
  • fetch a pitched window by walking the underlying rows and slicing each to the window's run; reject one as a side operand, which has no slice
  • take the MAX_TILES bound from the selection rather than re-deriving it from TESTS: the selection stops the walk at the loop it bounds, and a layer's display name need not be the name of any op
  • key semaphores by a slot -- a bank for a scalar one, a flattened [bank, *dims] for an array -- so a per-chunk semaphore array resolves
  • derive an SpMM's output width from the contracted operand's trailing dimension, which is the output width for a matmul's batched other as well as a linear's two-dimensional weight
  • fold SpMM partial sums the way the unit does: its accumulate reads the sum FEEDBACK_DELAY iterations back, so a row's nonzeros land in that many partials, which an adder tree folds in the vector type
  • map a microscaled quantize's scale pass by the layout of the buffer it reads, so the block axis keeps its role through a relayout
  • apply a side-operand dequantize through the stage mac instead of the pipeline-input dequantize flag, which scaled the wrong operand
  • match the hardware in the gold model: the vector-unit reducer in the gemv path, the accumulator interleave in average pooling, and the reciprocal immediate a scalar quantize is lowered to
  • leave a fusion's intermediate in the vector type until the single narrowing at the end rather than converting it by the dtype the IR declares
  • grade comparisons with torch.isclose semantics -- exact for gold-vs-accelerator, RTOL/ATOL against the reference -- and skip a CSR's values and indices past the row pointers, which are allocated for the worst-case nonzero count
  • pass --bank_width and --scratchpad_size to codegen so the memory planner aligns allocations to the store beat and sizes the whole physical L2, retiring CACHE_SIZE
  • migrate the Catapult SCVerify testbench file list to the new runtime
  • kill a timed-out test's process group in run_regression.py so no orphan outlives the runner

jeffreyyu0602 and others added 29 commits August 23, 2026 19:08
- retire the param.proto/interstellar tiling flow; codegen emits voyager_ir
  and the toolchain maps fused prim-op chains directly
- add the runtime that walks it under test/common: Model, Interpreter,
  Backend, Checker, GraphUtils, Tensor
- overlap consecutive tiles in the SystemC harness: the walk pushes an
  operation's params and moves on, so a unit's controllers fetch the next
  tile while the current one computes, and two threads pace the start and
  done handshakes in program order
- resolve a windowed TensorBoxRef -- a voyager.subview folded into an
  operand -- as a base address plus a contiguous run, carrying the row
  pitch when the innermost dimension is partial
- fetch a pitched window by walking the underlying rows and slicing each to
  the window's run; reject one as a side operand, which has no slice
- take the MAX_TILES bound from the selection rather than re-deriving it
  from TESTS: the selection stops the walk at the loop it bounds, and a
  layer's display name need not be the name of any op
- key semaphores by a slot -- a bank for a scalar one, a flattened
  [bank, *dims] for an array -- so a per-chunk semaphore array resolves
- derive an SpMM's output width from the contracted operand's trailing
  dimension, which is the output width for a matmul's batched `other` as
  well as a linear's two-dimensional weight
- fold SpMM partial sums the way the unit does: its accumulate reads the
  sum FEEDBACK_DELAY iterations back, so a row's nonzeros land in that many
  partials, which an adder tree folds in the vector type
- map a microscaled quantize's scale pass by the layout of the buffer it
  reads, so the block axis keeps its role through a relayout
- apply a side-operand dequantize through the stage mac instead of the
  pipeline-input dequantize flag, which scaled the wrong operand
- match the hardware in the gold model: the vector-unit reducer in the gemv
  path, the accumulator interleave in average pooling, and the reciprocal
  immediate a scalar quantize is lowered to
- leave a fusion's intermediate in the vector type until the single
  narrowing at the end rather than converting it by the dtype the IR
  declares
- grade comparisons with torch.isclose semantics -- exact for
  gold-vs-accelerator, RTOL/ATOL against the reference -- and skip a CSR's
  values and indices past the row pointers, which are allocated for the
  worst-case nonzero count
- pass --bank_width and --scratchpad_size to codegen so the memory planner
  aligns allocations to the store beat and sizes the whole physical L2,
  retiring CACHE_SIZE
- migrate the Catapult SCVerify testbench file list to the new runtime
- kill a timed-out test's process group in run_regression.py so no orphan
  outlives the runner
The compiler now stamps box.shape in the window's coordinate system and
names the operand's own shape in output_shape, so resolution reads the
window dims straight off the box and matches rank exactly.  Gate the bank
dims on banks_of(box) so an unbanked multi-dimensional slice takes
resolve_window rather than select_bank, read is_fc_layer off output_shape,
and drop declared_boxes with the set_declared_boxes call that built it.
The definition compiles for CFloat -- where the harness itself cannot -- and
aborts if it is actually called, so the declaration has to be visible there
too.
bits_rep() is non-const on every datatype but StdFloat, so a const immediate
does not compile.
Merge voyager-soc-wrapper (origin/main, the tapeout state) into this repo as
soc/ and migrate the SoC flow to the bufferized-IR compiler:

- test/soc/: GenerateSoCBinaries walks model.txt into per-layer RISC-V C
  firmware (IR loop structure, probe-located runtime patching of param
  fields, DMA stripped); ScheduleRecorder captures the program's data
  movement and semaphore protocol for the testbench to replay.
- soc/ csrc/vsrc: SoCSimulation replays the recorded schedule against the
  DUT, paced by per-unit done events (async commits' retire posts ride the
  in-flight dispatch as tokens; synchronous dispatches are drain barriers,
  matching Harness.cc); SoCMemory reaches the scratchpad SRAM macros through
  the VPI backdoor and mirrors writes for stomp checking.
- test/common: optional Backend data-op interception so a recording backend
  sees the copies/zeros the Interpreter otherwise executes in place.
- regression_common.py: helpers shared by run_regression.py and
  test/soc/run_voyager.py (result table, layers.txt parsing, skip rules,
  log-scrape patterns); the SoC report now compares ideal vs actual in
  cycles (resnet18 E4M3x16x16: 21/21 layers, matrix utilization 0.987).

- Reserve the base of the scratchpad for a program sharing it: codegen.mk
  plumbs SCRATCHPAD_OFFSET into the --scratchpad_offset compiler flag, and
  voyager-compiler moves to 9db9440, which plans above the reservation.
  SoCMemory reads the scratchpad geometry as SCRATCHPAD_SIZE / NUM_BANKS,
  retiring SOC_CACHE_SIZE / SOC_NUM_BANKS so the compiler and the testbench
  name one memory instead of two views of it.
- Let a scratchpad-resident config set what it needs: VOYAGER_INT_ID is
  overridable (PLIC ids shift down when a config has no UART) and
  LINKER_SCRIPT comes from the environment (../htif.ld links at the
  scratchpad base rather than the toolchain DRAM base).
voyager-compiler renamed its LLM test_codegen targets (llm_prefill,
llm_decode, llm_kivi -> llama_prefill, llama_decode, llama_decode_kivi)
and replaced --enable_mixed_precision / --outlier_pct with named
--qconfig tables.  codegen.mk follows: mxnf4_attn_head_int6 for the _mp
programs, mxnf4_outlier for _spmm.

The Llama stages are now exported whole from Hugging Face (embeddings,
one decoder layer, final norm and lm_head in one graph), so the decode
programs carry the embedding and rotary setup ahead of the decoder.
ci_skip_rules.json is reduced to one rule that skips exactly those ops
(everything before input_layernorm in layers.txt) for llama_decode,
llama_decode_mp and llama_decode_kivi; the old per-op rules matched
layers the new programs no longer emit.

Bump voyager-compiler to 759ae99.
Picks up 4c6579f, which gives a K-split GEMM with a lone-dequantize tail
the in-place reduction kernel. Without it, codegen for a per-tensor INT8
GEMM feeding an unquantized consumer -- ViT's patch embed -- crashed
before emitting model.txt.
read_tensor handed back a raw owning T* inside a std::any, and a std::any
frees nothing it holds, so every buffer needed an explicit delete[].
Ownership was decided per kernel and had drifted: MatrixOps, SpMM,
QuantizeOps and others freed their operands while LayerNorm, Microscaling,
Reduction and SlicingOp did not, nothing at all freed the results
run_host_operation writes back, Checker leaked both sides of every
comparison, and MatrixOps' accumulations buffer was never freed.

AccuracyTester runs a whole network per sample in one process, so this
accumulated: ~609 MB per sample, 77.9 GB by sample 128, and a full
1000-sample ImageNet run needed ~609 GB against 377 GB of RAM. The layer
tests never showed it because they run one op per process and exit.

Buffers now travel as std::shared_ptr<T[]>. Refcounting handles the
aliasing that made per-site ownership impossible: run_operation stores
each prim's result back into kwargs and then reads the last one out again
as the operation's output, and whether those are the same pointer depends
on whether cast_output had a conversion to do. It also retires the
dangling kwargs entries cast_input used to leave behind, since the map
keeps its own reference when a local is re-typed. Kernel-local scratch
that genuinely has one owner uses unique_ptr.

Peak RSS is flat afterwards: resnet18/MXINT8 holds 0.55 GB across a full
1000-sample accuracy run, scoring 70.30% against a 70.3 gold, and
vit/CFLOAT holds 5.61 GB.
The testbench-driven SoC modes (the plain run, --jtag-mode program and
vpi) no longer replay a recorded schedule. The emitted firmware executes
the layer's bufferized program in full -- loops, scalars, dispatches, the
CSR bookkeeping, and the program's semaphores as counters it owns -- and
asks the testbench for the DRAM<->scratchpad transfers it cannot perform
through a mailbox in its own memory (firmware/common/host_request.h):

- copies, zero fills and host tensor ops are requested by ordinal in a
  table both sides build from the same selection (test/soc/HostRequests.cc),
  with the run-time scalar values in the request, so run_async_copy,
  zero_buffer and run_host_operation stay the single implementation;
- a commit's retire post is a request the testbench completes once its
  settled done counts reach the counts the firmware had issued;
- completions increment counter cells the firmware spins on; the doorbell
  is hardware semaphore 7, watched by the Verilog collateral.

Every mode now runs one unit configuration: each unit waits on the
semaphore of its own index for a credit the firmware grants per pass. A
compute pass's credit is granted just before its last params word, after
the flag the grant raised has been cleared, so the flag's next rise is the
unit taking the credit, which is the proof the pass has started; the
vector unit's credit is posted once its group's compute passes have all
started. Counting completions through those flags, as full JTAG mode did,
was lossy (two dones of one unit between two polls count as one) and hung
matmul_mx_default_1_fused at four tiles. DISABLE_SEMAPHORE_WAIT becomes
NO_TESTBENCH, set for full JTAG mode only, under which requests and waits
compile to nothing. SoCSimulation is rewritten around the mailbox; the
staging handshake, the start grants and the INT_STATUS pokes are gone.
ScheduleRecorder remains for JtagPreload and moves to test/soc/jtag/.
run_voyager.py exports HOST_MAILBOX, the mailbox address read from each
layer's ELF.

Requests are assembled in place: the core has no data cache, so a request
staged on the stack cost 19.9 us; written straight into the slot it costs
1.95 us, and q_proj keeps its two-tile pipelining (35188 cycles, as under
the replay). quantize_mx_outlier_default_1, whose CSR running base the
replay raced, passes.

Also included, uncommitted since c79a65d: full JTAG mode (JtagPreload,
JtagSimChecker: the two-tile image and the readback list), the
interpreter's deferred scalar reads, the vector-loop cap applied to the
pack count in VectorOps.h, and the voyager-compiler pointer at 4439baa.
- The SoC gates vector_unit_start_rdy on its vector inflight count being
  zero (Voyager.scala), so consecutive vector passes never overlap there.
  The harness released the next pass as soon as the unit offered its start
  handshake, which the vector unit raises before it hands the params to
  its fetcher, so the second op of a commit body (a k-split reduction's
  epilogue, a multi-pass op's next pass) fetched the scratch tile the
  previous pass was still writing: the first 27 port words of every such
  tile came back stale. release_starts now applies the SoC's gate and
  retire_dones releases it; INT8 16x16 conv1_fused and
  output_bottleneck_dense_fused and MXINT8 16x16 attention_self_{q,k,v}
  pass again.
- Bump voyager-compiler to 9dc7e5b: a split-K tail that opens with a
  dequantize chains onto the accumulate instead of reading the tile back.
Picks up the SpMM scale-buffer veto: llama_prefill_spmm's q/k/v/o_proj
under MXNF4 no longer overflow the unit's 32-row weight-scale buffer.
c79a65d moved every gold kernel's tensors to std::shared_ptr<T[]> and
converted the body of DwC with them (its outputs are allocated as one)
but left the signature returning a raw Buffer*. DwC is instantiated
only under SUPPORT_DWC, which no build since then had set, so the
mismatch first surfaced once MXINT8 mobilenet_v2 codegen succeeded:
GoldModel.o failed to compile (cannot convert
std::shared_ptr<StdFloat<7, 8, ...> []> to StdFloat<...>* in return),
no runner was linked, and the fast-systemc run reported all 42 layers
failed.

Return std::shared_ptr<Buffer[]> as gemm and gemv_quantized do.
Picks up e139a80, which seeds the tiler's prefetch cache with the
four-field tuple for the layers interstellar skips. Since 57bfae2 the
serial path unpacks (mapping, access_list, bank_groups, scratch_slots),
while prefetch_tilings still stored (None,) * 3 for a depthwise conv,
so mobilenet_v2 codegen crashed before emitting model.txt and the
MXINT8 CI job died on the missing layers.txt. With the DwC fix beneath
it, all 42 uniquified mobilenet_v2 MXINT8 16x16 layers pass
fast-systemc with SUPPORT_DWC=true.
run_accuracy graded a run with abs(final - gold) < 1, so a datatype
that scored more than a point above the accuracy recorded for it in
ACCURACY_RESULTS failed the job exactly as a regression would. Only a
drop is a regression: pass any result above gold - 1 and keep the
recorded value as the floor.
Picks up 6d4501d, which sizes a split reduction's L2 output tile in
the anchor's dtype instead of the fixed 16-bit PSUM_BITS. Under CFLOAT
the tile search had admitted ViT's mlp_fc2 a K=64 x N=384 tile at 15
banks that the memory planner needed 17 for, so codegen raised
plan_memory (2228224 > 2097152 bytes), no model.txt was written, and
the CFLOAT ImageNet CI job crashed on an empty accuracy log. ViT
CFLOAT now plans into exactly 2 MB with fc2 back on its K=128 x N=256
tile and scores 84.5% on 1000 ImageNet samples (gold 84.7). Also
carries 67a0e90, the llama_verify speculative-decoding step.
The firmware runs the whole program now, so ScheduleRecorder no longer
records a replay schedule for the testbench: it gives JtagPreload the load
sequence to bake into the scratchpad image and where the dispatches fall
between them. With no testbench to pace against, the recorded order is the
order, so the semaphore bookkeeping goes away with it.

The grade is scratchpad-only, and readback_regions already bounds the dump
list to the bytes gold wrote, so the same gold walk can also write what a
passing readback must contain: <layer>_scratchpad_expected.bin.<i>, one per
region. A plain byte compare against those is the verdict jtag_sim_checker
gives -- gold and the accelerator are compared with no tolerance -- which
lets the chip flow grade itself with no gold model, tensors or compiler.

POWER_LOOP wraps the emitted program in while (1) for a steady-state power
measurement. Every address is baked into the parameter blobs, so a pass
repeats the same dispatches over the same scratchpad slots; the firmware
never returns, so those ELFs are driven by hand rather than by the graded
flow.

Simulation::grade_only_partition drops every output outside one partition
from the grade, which is what a scratchpad-only readback needs.
rtl_simulation was gated on pull_request, so pushes to main never uploaded
regression-results-* artifacts. check_pull_request.py looks up the latest
successful run on main, found one with no artifacts, and crashed on an empty
frame with KeyError: 'datatype'.

Run rtl_simulation on push as well, so each merge refreshes the baseline, and
skip the comparison instead of crashing when main has no results yet.
test_codegen.py no longer accepts --compile_single_layer, so
network-proto failed for every llama model and run_voyager.py could not
compile them.  Pass --num_hidden_layers 1, the flag CI already uses.
ci_skip_rules.json: name the five campaign networks in the MXNF4 decode rule
so run_voyager --skip_layers and the calibration planner resolve the same
list for them (they carried the same bool-operand layers under other names).

codegen.mk: --compile_single_layer became --num_hidden_layers 1.

Voyager.scala: comment the taped-out same-cycle start/done counter bug on
the three in-flight counters; no logic change (Sphinx silicon).
get_type_width asserted, so a layer with an operand outside the configured
types (le: a bool) aborted GenerateSoCBinaries and took every later layer of
the batch with it -- the per-layer catch in the emitter never saw it, since
abort() is not an exception.  Throw std::out_of_range instead, under
#ifndef __SYNTHESIS__ as the other host-side checks in src/ are, so HLS
still sees straight-line code.  The emitter now logs "Skipping le: ..." and
carries on (verified 2026-09-12, regression_results/2026_09_12_21_28_27).

The Catapult cache was re-stamped after the edit; make -q rtl is up to date.
Five commits: RTL calibration keyed by the bufferizer build cache key
(e613cbe: build_key on every node, DMA-only kernels grouped structurally,
one Calibration row per key, --num_hidden_layers), GEMV operand sizing and
accumulator-scratch charging in the tile search, fused-tail stream break
classified at the tile shape, outlier threshold over the whole tensor.
Two commits: a fused tail's fate named by K regime (fd6bd0d), and the
bufferizer folding a tail's renaming transpose into a view (f9d4c49),
which fixes llama_prefill matmul_mx_default_1_fused in the MXNF4 64x64
fast-systemc CI.
@jeffreyyu0602
jeffreyyu0602 force-pushed the voyager-compiler-migration branch from f7a7ba4 to 0733cd8 Compare September 14, 2026 12:13
@jeffreyyu0602
jeffreyyu0602 merged commit 8be634b into main Sep 15, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant