Simulation Engineering Toolkit — Presentation 14

An Accelerator Model in SimPy, End to End

A real graph from torch.export or ONNX run through an event-driven model of a tiled accelerator: off-chip memory, interconnect, DMA engines, an on-chip buffer with back-pressure, a compute array and a vector unit. Lowering to tiles, a cycle-approximate timing model, the roofline, Little's law and stall attribution, a timeline and hot-spot report, a cycle-stepped twin, how gem5 and SST are structured, a bit-identical C++ fast path with pybind11, ONNX Runtime execution providers, and an FHE-style NTT workload.

SimPy Back-pressure Stall attribution Cycle-based twin gem5 and SST pybind11 Execution providers NTT
Graph → Tiles → Simulate → Stalls → Hot-spot → Port
00

Topics We'll Cover

Concepts used here, and where they are explained. Each links to a glossary entry: this series glossary, or the glossaries of LLM Inference Simulators and FHE Accelerator Simulators for concepts those series already explain.

01

What the Model Must Answer

Deck 10 turned PyTorch and ONNX models into operator traces and costed them on a roofline: one number per operator, no queues, no contention. An architect's next questions need time to pass: while the array computes, is the next tile already loading? Does the buffer fill? Which engine is everyone waiting for? This deck builds the event-driven model that answers them, simfront.accel in Torch_Sim_Frontend, and runs real graphs through it end to end.

off-chipmemorychannels, GB/s NoC readper direction NoC writeper direction load DMAin order store DMAin order on-chipbufferback-pressure compute arrayR x C MAC/cycle vector unitlanes/cycle readydone

Its one-page block specification (docs/accel_spec.md) is written as for a hardware block: contents, interfaces, behaviour, timing, ordering, counters and assumptions. Every number on these slides comes from examples/accel_results.md, and the two presets (edge-npu, dc-npu) are illustrative.

02

SimPy Primitives, Mapped to Hardware

SimPy has few primitives (InfSim 02 lists them all), and a data-movement accelerator needs only these:

BlockSimPyWhat the primitive gives for free
Memory channelsResource(capacity=channels)A queue of transfers waiting for a channel
NoC read, NoC writeResource(capacity=1) eachSerialisation per direction (separate read and write networks, as on AXI)
On-chip bufferContainer(capacity=bytes)put(n) waits until n bytes are free: back-pressure
Ready and done FIFOsStore()Hand-off between engines, in order
DMA engines, computeprocesses (generators)Each engine's life as one readable function
"Operator stored"Event()Read-after-write dependencies between operators
sim.py: the load DMA, one process source
def load_dma():
    for i, t in enumerate(tiles):
        tm.issue[i] = env.now
        o = first.get(i)
        if o is not None and o.deps:
            yield env.all_of([op_done[d] for d in o.deps])
        tm.dep[i] = env.now
        if t.alloc:
            yield buf.put(t.alloc)
        tm.alloc[i] = env.now
        if t.load_dur > 0:
            yield from transfer(t.load_dur, noc_r)
        tm.load_end[i] = env.now
        yield ready.put(i)
03

Back-Pressure in the Smallest Model

Before the accelerator, the property that matters most in it: a producer and a consumer joined by simpy.Store(capacity=depth). A full FIFO makes put wait, so a producer that runs ahead is stalled instead of filling an infinite queue. The probe records the depth at every change; fifo.py is the whole model.

Two step plots of FIFO depth over time. Top: a consumer 20% slower than the producer fills a 16-deep FIFO to the top and keeps a 2-deep FIFO full; the producer is blocked about 16 to 17% of the time either way. Bottom: balanced rates with bursts of eight; the 16-deep FIFO absorbs each burst, the 2-deep one blocks the producer 39% of the time.
CaseDepthThroughput (items/unit)Producer blockedLλW
slow consumer10.827117.0%0.8090.809
slow consumer20.830816.6%1.7611.761
slow consumer40.831716.4%3.7093.709
slow consumer80.831716.2%7.6417.641
slow consumer160.831715.9%15.37315.373
bursty10.574142.9%0.5030.503
bursty20.618138.5%1.0031.003
bursty40.721928.1%2.0212.021
bursty80.90549.6%4.2084.208
bursty160.96413.4%7.8057.805

Source: examples/accel_results.md in Torch_Sim_Frontend

Depth buys throughput only when the rates vary. With a consumer 20% slower, depth changes nothing (the consumer sets the rate). With bursty arrivals, going from depth 1 to 16 raises throughput from 0.5741 to 0.9641. Little's law (L = λW) holds exactly on every run, which the tests assert: a free check on the probes.

04

From a Graph to Tiles

The model takes a simfront-trace/1 trace from any of deck 10's front ends (torch.export, ONNX, the dispatch trace) and lowers it to tiles: the unit the hardware loads, computes and stores.

Which unit

  • Matmul, convolution, attention → the compute array. A convolution is an im2col GEMM: M = output positions, K = Cin/groups × kernel, N = Cout/groups.
  • Elementwise, norms, softmax, pooling, gathers, NTTs → the vector unit.
  • Views are free; operators without a cost rule are counted, not simulated.

How it is tiled

  • A GEMM is blocked so each tile's operands and result fit in half the buffer (double buffering), halving the largest block dimension until it fits.
  • Each tile loads both operand blocks (no reuse: the simplest dataflow, and it shows the cost of tiling as extra traffic).
  • Every operator's first load waits until its producers are stored: a read-after-write dependency through memory.
lower.py: blocking a GEMM to fit the buffer source
def _split_gemm(b: int, m: int, k: int, n: int, e_in: int, e_out: int, budget: int):
    """Block sizes (bb, bm, bk, bn) whose operands and result fit in ``budget`` bytes."""
    def size(bb, bm, bk, bn):
        return bb * (e_in * (bm * bk + bk * bn) + e_out * bm * bn)

    bm, bk, bn = m, k, n
    while size(1, bm, bk, bn) > budget:
        big = max(bm, bk, bn)
        if big == 1:
            raise ValueError(f"a 1x1x1 GEMM block does not fit in a {budget}-byte tile")
        if bm == big:
            bm = math.ceil(bm / 2)
        elif bn == big:
            bn = math.ceil(bn / 2)
        else:
            bk = math.ceil(bk / 2)
    bb = max(1, min(b, budget // size(1, bm, bk, bn))) if (bm, bk, bn) == (m, k, n) else 1
    return bb, bm, bk, bn

The same CNN exported by torch.export and by ONNX does the same 2,802,304 multiply-accumulates on the array by both routes (a test asserts it).

05

The Timing Model: Cycle-Approximate Components

Every duration is computed once, at lowering, so all three engines (the SimPy model, the fast path and the cycle-stepped twin) run the identical program.

EventDurationLevel of detail
Transfer of b byteslatencyDRAM + latencyNoC + b / min(BWDRAM/channels, BWNoC)Transaction: no banks, rows or bursts
Array tile (batch, m, k, n)batch · ⌈m/R⌉ · ⌈n/C⌉ · k + R + C cyclesCycle-approximate: output-stationary systolic array, fill and drain once per tile
Vector tile of w elements⌈w / lanes⌉ cyclesThroughput only
Cycle-approximate against cycle-accurate

The array formula is right to within a fill and drain per tile, and it captures the effect that matters to an architect: PE efficiency falls when m or n is not a multiple of the array size. A cycle-accurate model of the same array (every PE register, every skewed input) would agree on large tiles and cost orders of magnitude more to run. It is worth building when the question is inside the tile (a dataflow choice, a hazard), or to calibrate the formula against RTL, as SimEng 05 does for an NTT core. InfSim 01's fidelity ladder puts the rule in one line: use the highest rung that can answer the question.

06

Methods: Roofline, Little's Law, Utilisation and Bottlenecks

Four first-order methods that every simulator result should be checked against, and how this model uses each:

MethodStatementIn this model
Roofline (InfSim 03)time ≥ max(FLOPs / peak, bytes / bandwidth)A lower bound: the makespan is at least the total load time and at least the total compute time (tested)
Little's law (InfSim 06)L = λW for any stable systemAsserted exactly on the FIFO model; a violation would be a probe bug
Utilisation lawU = X · S: busy fraction = throughput × service timeEvery component's busy fraction, from its intervals; memory and NoC also as bandwidth used
Bottleneck analysis (InfSim 06)The resource whose speed-up most shortens the runStall attribution (next slide): the largest wait names the hot-spot

Latency against throughput. One inference's latency is the makespan; throughput needs the pipeline full. The FIFO slide is the throughput side of the same trade: depth (and buffer) buys throughput under variable rates, and costs latency and area (SimEng 13).

Utilisation is not saturation. GPT-2's prefill on edge-npu keeps the array at 31.0% (PE efficiency 98.1%) efficiency when busy, but busy only 31.0% of the run: the arithmetic is efficient, the array is starved.

07

Finding the Stalls

The compute units issue in order, so the run splits exactly into computing and the gaps between tiles. Each gap is attributed by walking back along the next tile's load: was it transferring (or the DMA still busy with earlier tiles: load bandwidth), waiting for buffer space (buffer full), or waiting for an earlier operator's results to be stored (dependency)? The parts sum to the latency on every run, a tested invariant.

Where the compute units' time wentShare
compute6.4%
load bandwidth59.8%
buffer full0.0%
dependency33.7%
store tail0.1%

Source: examples/accel_results.md in Torch_Sim_Frontend

For the small CNN on edge-npu, compute is 6.4% of the time; the hot-spot is off-chip memory, and the operator boundaries cost a third.

A bigger buffer made it slower

Eight images at 102.4 GB/s on a 32 × 32 array: a 512 KiB buffer gives 158.84 µs, a 2 MiB buffer 204.45 µs. Tiles grow with the buffer, so each operator has fewer, larger tiles and less overlap of load, compute and store; the dependency share rises from 14.7% to 27.4%. Tile size is a design parameter in its own right.

08

Interactive: Where the Time Goes

The recorded sweep (batch of eight images through the CNN, on the C++ fast path). Choose a configuration; the bar splits the compute units' time into computing and the three kinds of stall.

09

Real Models: Timeline and Hot-Spot

Published configurations, traced on the meta device without weights (deck 10), then simulated:

ModelTokensPresetTilesLatency (ms)Array busyDRAM bandwidth usedHot-spot
gpt2128edge-npu1,05151.83731.0%56.4%off-chip memory
gpt2128dc-npu4634.21224.2%10.2%off-chip memory
llama3-8b2048dc-npu17,9911,613.05762.5%9.4%compute array

Source: examples/accel_results.md in Torch_Sim_Frontend

Timeline of GPT-2's 128-token prefill on edge-npu: the load DMA lane is almost always busy, the compute array lane shows short bursts, the vector unit is nearly idle, buffer occupancy oscillates between about 40% and 95%, and at the end the LM-head matmul streams for about 8 ms

The timeline shows the hot-spot at a glance: the load DMA lane is nearly solid, the array lane is not. The last 8 ms is the LM-head matmul (50,257 × 768 weights): 15.5% of the run in one operator. Llama-3-8B on dc-npu is the opposite case: compute-bound, with the hot-spot on the array. The same intervals are written as a Chrome trace for Perfetto.

10

Process-Based Against Cycle-Based

An RTL simulator advances a clock and evaluates every block on every edge (Introduction to Simulation measures Icarus against Verilator). cycle.py does that to this accelerator: on every cycle each engine is a state machine that retires its tile if its countdown has expired and starts the next if it can. The SimPy model instead jumps from one state change to the next.

ProgramTilesCyclesSimPy eventsCycle-model evaluationsSimPy (s)Cycle-stepped (s)Slower byIdentical
tiny-cnn14104,439314313,4490.0010.0441xyes
tiny-llama, 32 tokens139973,1352,9992,920,6470.0090.2731xyes
gpt2, 128 tokens105151,837,34820,775155,520,7530.05913.83233xyes

Source: examples/accel_results.md in Torch_Sim_Frontend

Identical answers

On a program quantised to whole cycles, both engines give the same eight event times for every tile. Within a cycle the twin retires first, then starts, and repeats until nothing changes, which is what one SimPy instant does.

Different costs

Event-driven work grows with state changes; cycle-based work with cycles, busy or idle. For GPT-2 that is 20,775 events against 155,520,753 evaluations. Cycle-based earns its cost when the question is inside the cycles: arbitration, pipeline hazards.

11

How gem5 and SST Are Structured

Two production simulators, built on the same three ideas as this model: components, connections with flow control, and an event queue. Names below are as in recent releases of each; their documentation (gem5, SST) is the reference.

Ideagem5SSTsimfront.accel
Unit of modellingSimObject: a C++ class with a Python declaration of its parametersComponent (C++, registered with element-library macros), with pluggable SubComponentsA SimPy process plus the resources it holds
ConfigurationA Python script builds the object tree, then instantiates and simulates itA Python script creates components, sets parameters and connects linksAccelConfig, a frozen dataclass
ConnectionsPorts: a request port bound to a response port in the Python config; packets carry requestsLinks between named ports, each with a latency; components exchange Events over themShared Store and Container objects
Flow controlTiming mode: a send that returns false is retried when the receiver signals it can accept (back-pressure)Up to the components (credits, as in its network models)Container.put waits for space
TimeAn event queue in ticks (1 ps by default); clocked objects convert cycles to ticksA core time base; components register clock handlers and per-link event handlersSimPy's heap of events, in seconds (or cycles, quantised)
Fidelity modesAtomic, timing and functional accesses; CPU models from simple to out-of-orderWhatever each element implements, from analytic to cycle-levelOne timing model; a cycle-stepped twin
ParallelismOne event queue per simulated system by defaultConservative parallel simulation over MPI ranks and threads; link latencies give the lookaheadNone (sweeps run in parallel instead)

The lesson for a model of this size: make the connection the unit of flow control, as both do. Here a Container is the port's ready signal; in gem5 it is a retry, in SST a credit. Parallel discrete-event simulation (InfSim 08) needs the lookahead that SST's link latencies provide.

12

The Hot Path in C++ with pybind11

The SimPy model spends its time on event bookkeeping: 20,775 events for GPT-2's 1051 tiles (slide 10), each a heap push and pop and a generator resumed (measure before porting: SimEng 11). When the two DMA engines cannot contend for a memory channel, the loop has nothing left to arbitrate, and every tile's times follow from earlier tiles': a recurrence with a min-heap of buffer frees. It is written in Python, then ported line for line to C++20 and exposed with pybind11. All three agree bit for bit on every field of every tile, on real traces and on Hypothesis-generated programs.

ProgramTilesSimPy (s)Python recurrence (s)C++ call (s)C++ kernel (s)Identical
tiny-cnn (edge-npu)140.0010.00000.00000.00000yes
gpt2 128 (edge-npu)1,0510.0610.00190.00090.00013yes
llama3-8b 2048 (dc-npu)17,9910.9960.03720.01940.00287yes
llama3-8b 512 (edge-npu)87,0753.7280.17270.09910.01592yes

Source: examples/accel_results.md in Torch_Sim_Frontend

Amdahl, measured

On the largest program the algorithm (no event loop) bought 22x; C++ bought 11x on the kernel but only 1.7x on the whole call, because converting Python lists dominates. Lowering, still Python, now takes 0.2562 s, longer than the simulation. The next port is the lowering, or a zero-copy array interface (SimEng 02 asks the same question for Rust).

13

Modern C++ in the Port

The port is small, and uses the features worth knowing (SimEng 03 covers value types and RAII):

_fastpath.cpp: a template constrained by a concept source
template <typename K>
concept Ordered = requires(const K& a, const K& b) {
    { a < b } -> std::convertible_to<bool>;
};

// A binary min-heap ordered by key(item). std::priority_queue would do; writing it out keeps
// the pop order identical to Python's heapq for equal keys (both break ties by a sequence number).
template <typename T, Ordered K, K (*key)(const T&)>
class MinHeap {
_fastpath.cpp: unique_ptr, moves, the GIL source
auto p = std::make_unique<Pipeline>(std::move(op), std::move(first), std::move(last), std::move(stores),
                                    std::move(alloc_in), std::move(alloc_out), std::move(load_dur),
                                    std::move(comp_dur), std::move(store_dur), std::move(deps), n_ops,
                                    capacity);
py::gil_scoped_release release;
return p->run();
14

ONNX Runtime Execution Providers

A hardware backend joins ONNX Runtime as an execution provider (EP). When a session is created, ONNX Runtime asks each EP in priority order which nodes it can run (GetCapability), fuses each EP's connected nodes into subgraphs, and gives everything unclaimed to the CPU EP (InfSim 09; ORT: adding an EP). A real EP is C++ against ONNX Runtime; ep.py reproduces the partition so the simulator can cost it:

Device supportsNodes claimedSubgraphsCrossingsDevice (µs)Host (µs)Link (µs)Total (µs)
Conv, Gemm4/1741336.353.0415.1954.59
+ Relu, MaxPool10/1751269.171.1615.6085.93
+ BatchNormalization, ReduceMean (everything)17/171099.960.000.0099.96

Source: examples/accel_results.md in Torch_Sim_Frontend

With these illustrative numbers the host is better at memory-bound operators than the edge device, so claiming fewer nodes is faster. The partition has to be costed, not counted: the point of operator coverage as a time metric.

15

An FHE Workload: Polynomial Products via the NTT

Lattice-based homomorphic encryption multiplies polynomials in Zq[X]/(XN+1), stored as one row of N residues per RNS prime ("limb"), through the NTT: c = INTT(NTT(a) · NTT(b)) after a twist by a 2N-th root of unity (FHESim 01). ntt.py has the golden model, checked against schoolbook multiplication by Hypothesis, and a workload builder in the same trace format, so it runs through the same accelerator.

ntt.py: the negacyclic product, the golden model source
def polymul(a: list[int], b: list[int], q: int) -> list[int]:
    """a * b in Z_q[X]/(X^n + 1) through the NTT (negacyclic twist by psi)."""
    n = len(a)
    p = psi(n, q)
    w = p * p % q
    tw = [pow(p, i, q) for i in range(n)]
    fa = ntt([x * t % q for x, t in zip(a, tw, strict=True)], q, w)
    fb = ntt([x * t % q for x, t in zip(b, tw, strict=True)], q, w)
    c = intt([x * y % q for x, y in zip(fa, fb, strict=True)], q, w)
    inv = pow(p, q - 2, q)
    return [x * pow(inv, i, q) % q for i, x in enumerate(c)]

One product at N = 216 with 24 limbs is 96 operators. An NTT is (N/2) log2N butterflies; at three operations each that is 3.0 operations per input byte. On dc-npu it takes 489.10 µs and is memory-bound; with a 16-lane vector unit, 2,789.18 µs and NTT-bound. The full FHE picture (key switching, bootstrapping, key traffic) is in FHESim 02 and FHE_Accelerator_Sim.

16

Computing in the Data Path (Speculative)

FHE is bound by data movement (FHESim 02), so one family of ideas moves the computation to the data: into memory (FHEmem, arXiv:2311.16293; APACHE, arXiv:2404.15819), into the network (in-network aggregation for training, SwitchML, arXiv:1903.06701), or onto light (FHESim 04, FOptInf 03). The model adds a stage in the memory read path that performs the NTT as the data streams in (a pipelined NTT, like the delay-feedback designs in Cryptography 10), with a budget in operations per byte.

PresetVector lanesNTT runsLatency (µs)Speed-upHot-spot
edge-npu64on chip6,856.501.00xoff-chip memory
edge-npu64in transit, 0.521,208.890.32xin-transit stage
edge-npu64in transit, 1.69,043.770.76xin-transit stage
edge-npu64in transit, 3.06,463.291.06xin-transit stage
edge-npu64in transit, 6.06,463.291.06xin-transit stage
edge-npu8on chip9,719.491.00xvector unit
edge-npu8in transit, 0.521,294.900.46xin-transit stage
edge-npu8in transit, 1.69,129.781.06xin-transit stage
edge-npu8in transit, 3.06,549.301.48xin-transit stage
edge-npu8in transit, 6.06,549.301.48xin-transit stage
dc-npu512on chip489.101.00xoff-chip memory
dc-npu512in transit, 0.51,407.210.35xin-transit stage
dc-npu512in transit, 1.6628.650.78xin-transit stage
dc-npu512in transit, 3.0463.501.06xin-transit stage
dc-npu512in transit, 6.0463.501.06xin-transit stage
dc-npu16on chip2,789.181.00xvector unit
dc-npu16in transit, 0.51,502.451.86xin-transit stage
dc-npu16in transit, 1.6723.883.85xin-transit stage
dc-npu16in transit, 3.0558.734.99xin-transit stage
dc-npu16in transit, 6.0558.734.99xin-transit stage

Source: examples/accel_results.md in Torch_Sim_Frontend

Speculation, labelled

The stage and its budgets are this deck's illustration, attributed to no one. What the model says: when the on-chip NTT is fast, the stage gains little and a stage that cannot keep up with the stream throttles memory; when the NTT engine is the bottleneck, it wins up to 4.99x; above the NTT's own 3.0 operations per byte, more budget buys nothing. Precision, conversion energy and area are not modelled here (FHESim 04 prices them for optics).

17

Specification, Tests and CI

The model is held to the same discipline as the front end (SimEng 09): requirements SF-18 to SF-28 in EARS patterns, each traced to the tests that verify it by a matrix generated from the test run.

RequirementOracle in the tests
SF-18 tiles fit; a GEMM's tiles do exactly its MACsThe cost rules' FLOPs; hand-worked im2col shapes
SF-19 back-pressure; occupancy ≤ capacityOccupancy rebuilt from the timings, every run
SF-20 loads wait for producers to be storedEvent order, every operator
SF-21 metrics, stalls sum to latency, plotsThe sum, to 1 part in 1012; a PNG and a Chrome trace written
SF-22, SF-23 fast path bit-identical; refuses contentionThe SimPy model, exactly, plus Hypothesis-generated programs
SF-24 cycle-stepped twin identicalThe SimPy model, exactly
SF-25, SF-26 NTT product; in-transit budgetSchoolbook multiplication; the budget as a bound
SF-27, SF-28 Little's law; EP partitionL = λW exactly; ONNX Runtime's own profile

GitHub Actions runs the suite on Python 3.10 and 3.12 with SIMFRONT_REQUIRE_CPP=1, which turns a C++ module that failed to build into a test failure instead of a silent fallback to Python, then smoke-tests the CLI on the CNN, the NTT workload and the cycle-stepped twin.

18

Reading List

  • J. L. Hennessy, D. A. Patterson, Computer Architecture: A Quantitative Approach, 6th ed. (2017): chapter 2 and appendix B on the memory hierarchy, chapter 7 on domain-specific architectures.
  • H. T. Kung, "Why Systolic Architectures?", IEEE Computer 15(1), 1982.
  • HEIR, the MLIR compiler for FHE (InfSim 09), and the FHE background in Cryptography 08 (CKKS, TFHE, bootstrapping).
  • More: InfSim 10, Further Learning.
19

What to Take Away