Skip to content

[LiveFIFO] Globally optimal FIFO-sizing search strategies (bottom-up exact, branch-and-bound) - #298

Draft
fpjentzsch wants to merge 20 commits into
devfrom
feature/livefifo_global_search
Draft

fpjentzsch wants to merge 20 commits into
devfrom
feature/livefifo_global_search

Conversation

@fpjentzsch

@fpjentzsch fpjentzsch commented Oct 14, 2026 •

Copy link
Copy Markdown

Adds two search strategies to live FIFO sizing that can find globally cost-optimal FIFO depth configurations, next to the existing per-FIFO binary searches, so that the binary-search results can be compared against a proven optimum.

What is in the PR

Virtual FIFO hardware (finn-rtllib/fifo_virtual)

  • fifo_gauge.sv: READ_FILL now returns the peak occupancy of a run in every mode (before: the capacity register in bounded mode), and READ_STALL returns run-sticky stall flags in bits [7:4] next to the per-frame flags, so a deadlocked run still reports which FIFOs blocked their producer. Both are cleared by every RUN_* command.
  • fifo_controller.sv: READ_STALL is issued as a 7-byte packet like READ_FILL and returns the status in the low byte (the previous 4-byte packet returned the wrong slot; the driver never used it).
  • BARRIER_CLEAN fix: the barrier waited for the detached input consumption of every gauge, but the first gauge consumes from the instrumentation source, which is not running in that phase, so it reported dirty forever and the controller's re-issue loop blocked the AXI read. The barrier now only awaits the detached output production (the periods are final once that is done). The driver uses the barrier instead of a fixed 5 s sleep.
  • New sim/fifo_controller_tb.sv + fifo_controller.tcl: controller in the loop, driven over AXI-lite exactly like the driver (detached run with idle source, barrier, paced run, bounded run at the paced peaks reproducing them without a producer stall, unbounded capacity value, deadlock with sticky flags). Both testbenches pass in xsim 2024.2.

Driver (driver/finn_plus_driver/overlays)

  • fifo_search.py (new, pure Python): oracle protocol, feasibility predicate (same thresholds as before), cost model, depth lattice, evaluation cache/log, the binary searches re-expressed on the oracle, the lower-bound phase, bottom_up_exact, top_down_bnb, and the strategy registry.
  • live_fifo.py: LiveFIFOOracle (one hardware evaluation = the old inner loop; gauge reads only when a strategy asks for them), search_strategy kwarg of experiment_fifosizing (fifo_search_order stays as a deprecated alias for binary_<order>), report_name for report sub-directories, a capability probe for the gauge extensions, and a search block in fifo_sizing_report.json with evaluations, duration, optimal_proven, lower bound, gap and the full evaluation log.
  • Tests: driver/tests/test_fifo_search.py (both exact searches against brute force on 200 random monotone instances, budgets, pareto mode, unreliable flags) and test_live_fifo_experiment.py (the whole experiment on an emulated ring); conftest.py stubs pynq so they run off the board too.

CI

  • ci/experiments/fifosizing_default.json: search_strategy kwargs, latency-only binary runs dropped, bottom_up_exact and two top_down_bnb seed variants added (600 s budget each) for interval and both. collect.py still picks the cheapest result for the follow-up build.
  • scripts/compare_fifo_search_strategies.py: builds the comparison tables below from a Measurement job's reports.

The two search methods, informally

Both rely on monotonicity: making any FIFO deeper never hurts, so if a configuration works, everything above it works, and if one fails, everything below it fails.

bottom_up_exact (Algorithm 1 of Stuijk, Geilen, Basten, Throughput-Buffering Trade-Off Exploration for Cyclo-Static and Synchronous Dataflow Graphs, IEEE Trans. Computers 57(10), 2008, https://doi.org/10.1109/TC.2008.58, with the hardware as oracle). Keep a to-do list of depth vectors sorted by cost, starting at the per-FIFO lower bounds (each FIFO at its minimum with all others unbounded). Pop the cheapest, measure it. If it works, it is optimal, because everything cheaper was already popped and found not to work. If it does not work, ask the gauges which FIFOs blocked their producer: any FIFO that never blocked could have been infinitely large without changing a single event, so only the blocked ones can help. Push one copy per blocked FIFO with that FIFO one step deeper, repeat. Exact, but it has to measure every configuration cheaper than the optimum that the expansions reach; with a paced source the backpressure flags every FIFO upstream of the bottleneck, so on larger DUTs that is far too many points (see results).

top_down_bnb (generic branch-and-bound over boxes of depth vectors; not from the paper). Start from a feasible incumbent (a binary-search result or the paced occupancies) and the box between the lower bounds and an upper bound. For each box: if its cheapest corner cannot beat the incumbent, discard it; measure its most expensive corner, and if that fails, discard the whole box; otherwise that corner is a candidate, and the gauges' peak occupancies from the same run give an even cheaper configuration with the identical execution for free. Then split the box along the FIFO with the largest cost range and continue with the "shrink" half first. Anytime: stopping early returns the incumbent plus a bound on what could still be gained; running to completion proves optimality within the box. With only the shrink halves in a fixed FIFO order it degenerates into the per-FIFO binary search.

Intermediate results (pipeline 154299, live_fifosizing.yml, stop_condition = both)

Cells: total FIFO size in kB (hardware evaluations, search duration). Cheapest per DUT in bold. ✓ optimality proven, ⏱ 600 s budget exhausted. UB = size of the paced-mode peak occupancies (start configuration), LB = cost of the per-FIFO lower bounds.

DUT FIFOs target (cycles) UB kB LB kB binary (best order) bottom-up exact B&B (seed bin) B&B (seed occ.)
kws 11 490 0.49 0.44 0.44 (19, 0.1s) [tie] 0.44 (20, 0.1s) ✓ 0.44 (37, 0.1s) ✓ 0.44 (41, 0.1s) ✓
cybsec 7 70 0.08 0.08 0.08 (8, 0.0s) [tie] 0.08 (9, 0.0s) ✓ 0.08 (15, 0.0s) ✓ 0.08 (8, 0.0s) ✓
bnn-pynq/tfc-w1a1 11 70 0.20 0.19 0.19 (17, 0.1s) [tie] 0.19 (18, 0.1s) ✓ 0.19 (33, 0.1s) ✓ 0.19 (34, 0.1s) ✓
bnn-pynq/tfc-w2a2 11 70 0.27 0.25 0.25 (17, 0.1s) [tie] 0.25 (18, 0.1s) ✓ 0.25 (33, 0.1s) ✓ 0.25 (34, 0.1s) ✓
bnn-pynq/cnv-w1a1 33 32774 25.04 0.21 2.67 (89, 0.8s) [tie] 25.04 (59070, 10m) ⏱ 2.50 (59188, 10m) ⏱ 2.50 (59386, 10m) ⏱
bnn-pynq/cnv-w2a2 34 115206 87.10 0.13 10.07 (108, 2.1s) [tie] 87.10 (28518, 10m) ⏱ 8.97 (28277, 584s) ✓ 8.97 (28170, 581s) ✓
gtsrb 33 32774 25.05 0.22 2.67 (92, 0.8s) [tie] 25.05 (57722, 10m) ⏱ 2.51 (58265, 10m) ⏱ 2.51 (58228, 10m) ⏱
vgg10 65 1027 2.25 2.22 2.22 (67, 0.4s) [tie] 2.22 (68, 0.4s) ✓ 2.22 (133, 0.8s) ✓ 2.22 (113, 0.8s) ✓
mobilenetv1 142 394272 303.44 7.90 22.06 (433, 16s) [reverse] 303.44 (7253, 10m) ⏱ 20.75 (14208, 10m) ⏱ 20.75 (14164, 10m) ⏱
resnet18 148 196608 224.38 19.30 27.64 (526, 16s) [reverse] 224.38 (14293, 10m) ⏱ 26.94 (16791, 10m) ⏱ 26.94 (16714, 10m) ⏱
transformer/radioml 78 1174 11.09 4.00 10.69 (116, 0.8s) [tie] 11.09 (66366, 10m) ⏱ 10.65 (62772, 10m) ⏱ 10.65 (62809, 10m) ⏱
transformer/vision 129 65536 32.68 1.82 28.78 (270, 4.5s) [forward] 32.68 (29081, 10m) ⏱ 28.71 (28612, 10m) ⏱ 28.70 (28477, 10m) ⏱
transformer/language 123 98304 220.00 7.03 105.20 (257, 5.2s) [reverse] 220.00 (25122, 10m) ⏱ 105.20 (24328, 10m) ⏱ 105.20 (24187, 10m) ⏱

Reading: the small DUTs are solved exactly by every method (the lower-bound corner is itself feasible). The B&B improves on the best binary order on every larger DUT, by 1 % to 6 % within the budget and by 11 % on cnv-w2a2, where it ran to completion and proved the optimum. bottom_up_exact explodes on everything larger than the small DUTs (tens of thousands of evaluations without reaching a feasible point) and then returns its start configuration, as its theory predicts for a paced pipeline with per-element lattice steps. The two B&B seeds give the same result because the seed only sets the initial incumbent, not the traversal.

One open observation from the follow-up builds: with the B&B configuration of the radioml transformer (12 of 78 FIFOs differ from the binary result by one or two elements) the real build reaches 1203 instead of 1174 cycles, although the virtual FIFOs measured the target exactly. All other follow-up builds hit their targets. This looks like the virtual-to-real FIFO gap at the boundary rather than a search bug and needs a closer look before the exact results on transformers are trusted.

Notes for review

  • The B&B does not shrink a box to the peak corner, as the plan suggested; that is unsound (other points of the box can need more than the observed peak). Peaks only improve the incumbent, which is confirmed by one evaluation at the end.
  • An old bitfile without the gauge extensions is detected by a probe after the paced run; the binary strategies are unaffected, the B&B runs without peak tightening, and bottom_up_exact refuses (returns its seed) unless allow_unreliable_flags is set.
  • The binary strategies do not read the gauges per evaluation, so their per-evaluation cost is unchanged. The exact strategies add one ring read per FIFO and evaluation (about 10 to 20 %). The wall-clock sleep of the runtime heuristic is 60 to 80 % of an evaluation on the CNNs and ResNet.
  • Driver tests run locally with cd driver && python -m pytest tests.

Later additions on this branch

Steady-state detection (#296). With the new build option instrumentation_steady_state (enabled automatically for live FIFO sizing builds, off otherwise so that the resource numbers of plain instrumented builds do not change; about 1500 LUTs / 700 FFs when on) the instrumentation wrapper exposes the window minimum/maximum of interval and latency, the cycles since the last completed frame and the age of the oldest frame in flight. The driver's LiveFIFOOracle then polls instead of sleeping: an evaluation ends immediately on a definitive negative (idle time or pending age above the threshold, window maximum above the threshold, instrumentation error) and otherwise once N frames have completed with the window maxima within the thresholds, where N = max(4, ceil(max(4 * paced_latency, 1e5) / paced_interval)) frames mirrors the old observation time (205 frames for kws, 8 for mobilenetv1). The old iteration_runtime times cap_factor (4) remains as a wall-clock cap that falls back to the old single-read verdict. Bitfiles without the registers keep the old behaviour. The report's search block lists which rule decided each evaluation (decisions). Verified with a cycle-level C simulation harness, csynth (II=1 kept, register map of the off variant identical to before) and an RTL simulation of the synthesized IP; not yet on a board.

FPGA primitive cost model (#278). src/finn/util/fifo_cost.py mirrors the selection ladder and geometry of fifo.sv (through resource_models._resolve/_fifo_cost, so it agrees with StreamingFIFO.get_fifo_cost() by construction) and returns per-FIFO vectors (LUT, FF, RAMB18, URAM), the depths at which the cost changes (cost_breakpoints, verified against a brute-force scan), the undominated steps of the non-monotone ladder, a realize_depth replacing "round up to a full BRAM block", and a scalarization with device-capacity-relative weights (device_weights(fpgapart, board)). MakePYNQDriver exports per-FIFO cost tables into the driver's settings.json, and experiment_fifosizing(cost_model="primitives") searches over the breakpoint lattice with that cost (default stays "bits"; both modes report the primitive vector of the final configuration when tables exist). The module docstring contains the mapping onto fifo_depth_search.py of the TEG branch so that the simulation-based minimization can switch to it. Deviations from the old simulation heuristics: narrow (<5 bit) FIFOs stay LUTRAM at any depth, URAM above 2028 is priced, the LUTRAM/BRAM boundary is 257 with power-of-two steps, shift registers only up to 33 (64 for <12 bit), BRAM lo/hi sub-steps.

Cost-function comparison benchmark. ci/cfg/live_fifosizing_costcmp.yml builds every DUT of live_fifosizing.yml once per experiment config (fifosizing_cost_bits.json, fifosizing_cost_primitives.json): the same branch-and-bound search minimising bits or primitives, each with its own follow-up build. scripts/compare_fifo_cost_models.py tabulates predicted vs. built FIFO cost in both metrics plus the follow-up interval. collect.py ignores experiments_config when matching reference experiments.

Optional improvements

  1. A cost function based on FPGA primitive counts instead of kB Done on the branch (see "Later additions"): finn.util.fifo_cost and the primitives cost model of the driver (Align the distributed FIFO-sizing cost heuristics with the consolidated fifo.sv (after #259) #278). Still open: switching the CI matrix to cost_model: primitives and consolidating the per-primitive vector into one scalar (currently device-capacity-relative LUT equivalents).
  2. Rework of the steady-state tracking Done on the branch (see "Later additions"): early negative verdicts and a frame-based horizon in the instrumentation wrapper and the driver (FIFO sizing: steady-state detection relies on observation-horizon heuristics (live_fifo and distributed_sim) #296). Still open: a board run to see the decision mix, and the long-horizon confirmation of the final configuration.
  3. Speed-up of the oracle evaluation: batching the per-FIFO AXI-lite configuration writes, or a controller command that loads all capacities at once. (The O(N²) Python overhead per pop of bottom_up_exact is already fixed on the branch: child costs are computed incrementally, 3 to 4x faster per pop on 142 FIFOs.)
  4. Better seeding of the branch-and-bound, e.g. best-bound node order with a strong incumbent, or seeding bottom_up_exact with a binary-search result so that a budgeted run returns something useful.

Add two search methods next to the per-FIFO binary searches of the live FIFO
sizing driver, selected by the new search_strategy kwarg of
experiment_fifosizing (fifo_search_order is a deprecated alias for
binary_<order>):

- bottom_up_exact: best-first search from per-FIFO lower bounds upwards that
  only enlarges FIFOs observed blocking their producer (Stuijk et al. 2008);
  the first feasible configuration is cost-optimal.
- top_down_bnb: branch-and-bound over boxes of depth vectors seeded with a
  binary search result; anytime, exact when run to completion.

Both report a proven lower bound and the optimality gap and never return an
infeasible configuration (the seed on budget exhaustion). The searches live in
the oracle-agnostic driver module fifo_search.py; the hardware oracle, the
capability probe and the reports are in live_fifo.py.

Virtual FIFO hardware: the gauge records the peak occupancy of every run
(READ_FILL now returns it in bounded mode as well) and run-sticky stall flags
in bits [7:4] of READ_STALL, which stay valid in deadlocks; the controller
issues READ_STALL as a 7-byte packet and returns the status in the low byte
like READ_FILL. A new testbench with the controller in the loop checks both.

Driver tests cover the strategies against brute force on random monotone
instances and the full experiment on an emulated ring; a conftest stand-in
for pynq lets them run off the board. The default CI matrix adds a budgeted
top_down_bnb run, fifosizing_exact.json runs both exact searches.
…d seed variants of the exact searches

The report_name kwarg of experiment_fifosizing selects the report sub-directory so that
the two top_down_bnb seed variants do not overwrite each other. fifosizing_exact.json is
removed, the default matrix covers all strategies.
The barrier waited for every gauge's detached input consumption as well as its
output production. The first gauge consumes from the instrumentation source,
which is not running in the detached phase, so it reported dirty forever and the
controller's re-issue loop blocked the AXI read. The periods are recorded on the
output side and are final once the production is done, so the barrier now only
awaits the output side (plus partially transferred frames). The driver uses the
barrier instead of a fixed 5 s sleep; the controller testbench replays the
driver's sequence including its accelerator reset before every run.
…O sizing run

scripts/compare_fifo_search_strategies.py reads the per-strategy fifo_sizing_report.json
files of a Measurement job and prints one Markdown table per stop condition with the
resulting FIFO size, number of hardware evaluations and search duration of every strategy.
Every pop recomputed the cost of each child over all FIFOs, which made the
Python side of a pop O(num_fifos * |dependency set|): about 3 ms per pop on a
142-FIFO model, comparable to the hardware evaluation itself. A child differs
from its parent in one FIFO, so its cost is the parent's cost plus that FIFO's
cost difference, rounded well below one bit so that equal-cost configurations
keep the order of a direct computation.
…f sleeping

LiveFIFOOracle.evaluate observes a candidate configuration by polling the new
steady-state registers of the instrumentation wrapper (window maxima of interval
and latency, idle cycles, age of the oldest pending frame) instead of sleeping for
the fixed iteration runtime. Definitive negatives end an evaluation immediately
(idle_timeout, pending_timeout, interval_violation, latency_violation); a positive
verdict needs steady_state_frames completed frames with the window maxima within
the thresholds (horizon_reached). The horizon is the old observation time in
frames, derived from the paced latency and interval, at least 4 frames; the old
runtime times cap_factor is a logged safety cap that falls back to the single-read
decision. The measured interval and latency are the window maxima.

EvalResult records the deciding rule, the frames and the evaluation time; they
appear in all_iterations and the search summary counts the decisions. Bitfiles
without the registers (detected from the HWH) keep the fixed runtime. The driver
tests emulate the registers (frames completing over polls, deadlocking and slowly
degrading configurations, the cap) and the fallback for old hardware.
Six read-only AXI-lite registers for steady-state detection in live FIFO sizing,
appended after run_frames so that every existing register keeps its address and
semantics: lat_win_min/max and int_win_min/max (extremes over the sliding window
of avg_n frames, recomputed by a sequential one-entry-per-cycle scan after every
completed frame, never in the cycle of a window update so that the window
buffers see at most two accesses per cycle and II=1 is kept), idle_cycles
(cycles since the last completed frame) and oldest_pending_age (cycles since the
start of the oldest frame in flight, 0 when none; the head of the timestamp
queue is held in a register because hls::stream cannot be peeked). The interval
of the first frame after a cfg[0] rising edge is measured against the previous
run or the reset and is excluded from the interval window extremes.

The timestamp queue is now a static stream; a local stream loses its content
between calls in C simulation. Vitis HLS 2024.2 keeps II=1 (depth 3) for the
instrument process; the core costs about 1300 LUTs and 800 FFs more.

The simulation testbench template is updated to the current register map (it
still addressed a years-old layout), reads the new registers, checks their
invariants and stops after a configurable number of frames.
Add finn.util.fifo_cost: the LUTs, flip-flops, RAMB18 tiles and URAM288s the
consolidated RTL FIFO takes for a depth, width and ram_style (the fitted
resource_models coefficients for LUT/BRAM/URAM at the style fifo.sv's
RAM_STYLE_EFF ladder resolves, the RTL's register inventory for FFs), the
fifo.sv geometry (implemented capacity, lo/hi memory spaces, pipeline and
output queue), the depths at which the cost vector changes (the candidate
ladder of a depth search, verified against a full scan), a documented
scalarization in LUT equivalents of the target device's capacity, the
monotone cost envelope a search that may only deepen FIFOs should minimise,
and a JSON export of the ladder.

MakePYNQDriver writes the per-FIFO tables and the device weights into the
live FIFO sizing driver's settings.json (fifo_cost_tables); the driver's
PrimitiveCost looks them up (envelope, realization, breakpoints), the Lattice
accepts per-FIFO candidate lists, and experiment_fifosizing selects the cost
model with cost_model="bits" (default) or "primitives", exporting the realized
depths and reporting the primitive vector of the final configuration in both
modes. The module docstring maps the TEG branch's fifo_depth_search functions
onto the new API.
The steady-state registers cost about 1500 LUTs and 700 FFs on the
instrumentation wrapper, which the CI benchmark comparison counts in the (top)
row of post_synth_resources.json with a 1 % tolerance. They are now gated in the
template by INSTR_STEADY_STATE: with the new DataflowBuildConfig option
instrumentation_steady_state (default False) off, the generated C++ is the
original wrapper (same register map, same code, Vitis HLS estimate identical:
2178 LUT / 2221 FF for the core); only the static timestamp stream (C simulation
fix) is kept in both variants. ZynqBuild enables the registers automatically for
live FIFO sizing builds, which need them for the steady-state detection of the
driver. The enabled variant keeps the window update at function level where it
is exclusive with the min/max scan, which keeps II=1 (nesting it like the
original code lost the II to carried dependences on the scanner state).
…al_search

# Conflicts:
#	driver/finn_plus_driver/overlays/live_fifo.py
#	driver/tests/test_live_fifo_experiment.py
@bwintermann bwintermann added this to the FINN+ 1.7.0 milestone Oct 27, 2026
ci/cfg/live_fifosizing_costcmp.yml builds every DUT of live_fifosizing.yml once per
experiment config: fifosizing_cost_bits.json and fifosizing_cost_primitives.json run the
same branch-and-bound search (seeded by the largest-first binary search, stop condition
both) minimising the storage in bits or the FPGA primitive cost, and each result gets
its own follow-up build. collect.py ignores experiments_config when matching reference
experiments, since it only selects what is measured. scripts/compare_fifo_cost_models.py
joins the search reports with the follow-up builds' FIFO depths, synthesized FIFO
resources and measured interval into one table per DUT and cost model.
…low-up build

The follow-up configuration copied every parameter of the measured run, including the
new experiments_config of the cost-function comparison, so the follow-up measurement
tried to run experiment_fifosizing on a build with real FIFOs and a normal driver
(pipeline 158970). The follow-up uses the default experiment config again.
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.

2 participants