NanoSoC closes timing at 96 MHz without help, but the fabric is the first thing to miss when the clock rises or the die grows: the address decode, arbitration and the data multiplexer of the crossbar sit in one cycle, and long wires between the crossbar and a far memory add to it. The standard repair is a register slice, a pipeline stage on a valid and ready channel, and the standard mistake is a slice that quietly halves the throughput of the link. This lesson places slices, prices them in NanoSoC numbers and shows the half-rate trap in a model that fails when it should.
A register slice cuts one AXI channel with a register stage. AXI has five channels (AW, W, AR for requests, B and R for responses), each with its own valid and ready, so a slice is applied per channel and a full slice on a path costs one cycle on each direction that carries it. Three kinds exist. A forward slice registers valid and data and leaves ready combinational: it cuts the data path but not the ready path. A backward slice registers ready only. A full slice, two entries deep, registers both and keeps one beat per clock, at the price of one cycle of latency and a second storage entry. A single register with ready equal to not full is the trap: it accepts a beat only every second cycle, so throughput halves.
Where slices go follows the failing path and the traffic. Put them on the long wire, not on every port: a slice between the crossbar and a far slave helps both directions of that slave only. Put them on the request channels of a path first (AR, AW, W) because they carry the address decode, then on the response side if the multiplexer is the problem. Never put one in a path whose latency budget has no room: the CPU's blocking reads feel every cycle, the SBR's streams do not.
# Illustrative: three ways to put one register stage on a valid/ready channel.
# The source always has data and the sink always accepts: count beats in 100 cycles.
def run(kind, cycles=100):
delivered = 0
full = [False, False] # up to two stored beats
for _ in range(cycles):
if kind == "none":
delivered += 1
continue
if kind == "single": # one register, ready only when empty
if full[0]:
delivered += 1
full[0] = False # sink takes it, no refill this cycle
else:
full[0] = True # source loads it
if kind == "skid": # two entries: ready stays high until both are full
if full[0]:
delivered += 1
full[0], full[1] = full[1], False
if not full[0]:
full[0] = True
elif not full[1]:
full[1] = True
return delivered
none, single, skid = run("none"), run("single"), run("skid")
assert none == 100
assert skid >= 99 # full rate, one cycle of latency
assert single == 50 # the trap: half throughput
# NanoSoC cost of a full slice on both directions of the CPU path (illustrative)
T_SYS = 1e9 / 96e6
base = 93.75
with_slice = base + 2 * T_SYS # one cycle on the request and one on the response
assert abs(with_slice - 114.58) < 0.01 and abs(with_slice / base - 1 - 0.222) < 0.001
# stages needed for a path that takes 14 ns (illustrative) at two clock rates
import math
need = lambda delay, period: math.ceil(delay / period) - 1
assert need(14.0, 1e3 / 96) == 1 and need(14.0, 1e3 / 192) == 2
print("PASS: full slice keeps 99 of 100 beats, single register halves to 50, CPU word 93.75 -> 114.58 ns")Every slice names its channel, its kind (forward, backward or full) and the cycle it adds in each direction, and the latency budget table gains a row for it.
No slice is a single register with ready equal to not full on a path that must sustain one beat per clock; the throughput of the link with the slice is checked, not assumed.
A slice sits on the failing path and is removed again if the path later closes without it, so the latency is not paid for ever.
Drive a long burst through the sliced channel with the sink always ready and count beats per window: a full slice must deliver one beat per clock after one cycle of fill, and a half-rate result names the single-register trap. Then stall the sink at random and check that no beat is lost or duplicated, which is where a slice with a broken skid entry fails. Finally re-time the CPU single-word read: it must grow by exactly the cycles the slices add, 20.8 ns for one full slice on both directions at 96 MHz.
The SBR stream keeps 361.4 MB/s and gains one cycle of first-beat latency, because a full slice sustains one beat per clock. The CPU read latency does not change, because the CPU does not use the SBR's channel.