Inside LPULite
An explainer you click through built from the LPULite source code · github.com/frankenstein-v1/LPULite
Inside LPULite
Three students designed a tiny AI chip from scratch, ran it on an FPGA — the kind of idea behind Groq's “LPU” — with a small language model on it. This takes you from a single on/off switch all the way to that chip typing “satvik”. No electronics needed.
8 lanes of 8-bit numbers moving in parallel
96-bit instruction words — 4,920 of them for one model
41.3/s full model passes per second on a ~$377 student FPGA board
100% of the FPGA's logic used
Electricity is electrons that are free to move
material copper (metal) glass push off
−1.6e-19 charge of one electron, in coulombs (C)
6.24e18 electrons per second = 1 ampere
Everything is made of atoms. Each atom has a heavy positive centre (the nucleus) and light negative electrons around it. Charge is the property that makes + and − attract. In a metal like copper, each atom's outermost electron is loosely held and wanders freely through the whole metal: a "sea" of free electrons. In glass every electron stays tied to its own atom. Give both a push: copper's sea drifts (a current), glass does nothing (an insulator). Counting charge: 1 coulomb is 6.24×10¹⁸ electrons, and 1 ampere means one coulomb passing a point each second.
Standard values: elementary charge e = 1.602×10⁻¹⁹ C, so 1 C = 1/e ≈ 6.24×10¹⁸ electrons. Copper: 29 electrons, 1 outer (4s) electron per atom.
An atom : a positive nucleus (+) with negative electrons (−) around it. Opposite charges attract.
In copper the outermost electron is barely held. It leaves its atom and roams: a sea of free electrons , jiggling randomly.
In glass every electron is bound to its own atom. It can wobble, it cannot travel. That is an insulator .
Back to copper. Press push : the whole sea drifts one way. Moving charge is a current .
The gate on the right counts electrons crossing. 6.24×10¹⁸ of them per second is 1 ampere (1 coulomb per second).
Voltage is a push , measured from a reference
0 V ground: the level we measure from
VDD the supply rail, e.g. 3.3 V or 1.1 V
Water in a raised tank presses down on the pipe below it; the higher the tank, the harder water is pushed through. Voltage is the same idea for charge: a difference in "electrical height" between two points. A battery or power supply lifts charge up to a high level. Height needs a floor to measure from, so every circuit picks one wire and calls it ground, 0 V. The high supply wire is called a rail; in chips it is named VDD. A voltage only means something between two points.
1 volt = 1 joule of energy per coulomb of charge. Chip core rails are typically ~1 V; LPULite's FPGA core pins are 1.1 V (see the last page of this part).
A raised tank pushes water down a pipe. Higher tank → harder push (pressure).
The circuit on the right is the same picture: a supply lifts charge up, it flows down through a load (a lamp).
The bottom wire is ground = 0 V : the floor every other voltage is measured from.
The top wire is the supply rail, VDD . Every point in between sits at some height between 0 and VDD.
Drag the slider: raise VDD and both the water and the charge are pushed harder . At 0 V: no push, nothing flows.
Current flows one way on paper and the other way in the wire
arrows conventional electrons both close switch
0.07 mm/s electron drift, 1 A in a 1 mm² copper wire
~2e8 m/s speed the “go” signal travels (illustrative, typical cable)
Current is how much charge passes a point per second. Early scientists guessed charge flows from + to −, and every drawing still uses that "conventional" direction. Electrons are negative, so they actually move the opposite way, from − to +. It does not matter for calculations. A surprise: each electron crawls, well under a millimetre per second. The lamp still lights almost instantly because the wire is already full of electrons; closing the switch sends a push along the whole wire at close to light speed, like pushing a pipe already full of marbles.
Drift speed v = I / (n·A·e) with copper n ≈ 8.5×10²⁸ electrons/m³, A = 1 mm², I = 1 A → ≈ 7.3×10⁻⁵ m/s. Signal speed depends on the cable; ~0.6–0.9 c is typical.
Current = charge passing the dashed cut each second. Measured in amperes (A) .
The conventional arrow (blue) goes from + to − outside the battery. All schematics use this.
But electrons are negative, so they really move − to + (yellow dots). Same current, opposite labels.
Close the switch: the push (yellow front) races around the loop and the lamp lights almost at once .
Watch the red tagged electron : it has barely moved. Drift is slow, the signal is fast.
More push, more flow; more resistance , less flow
V = I·R Ohm's law
R = ρL/A longer → more, thicker → less
Resistance is how hard it is for charge to get through something, measured in ohms (Ω). Electrons bump into vibrating atoms and lose energy, so a longer path means more bumps and a thicker path gives more room. Each material has its own resistivity ρ. Ohm's law ties the three together: current I = V / R. Double the voltage and the current doubles; double the resistance and it halves. On a graph of current against voltage, a resistor is a straight line whose slope is 1/R.
Resistivity at 20 °C (textbook): copper 1.68×10⁻⁸ Ω·m, aluminium 2.65×10⁻⁸ Ω·m, nichrome ≈ 1.10×10⁻⁶ Ω·m.
A resistor (zig-zag) makes electrons bump into atoms . Its size is R , in ohms (Ω) .
The ammeter reads current I = V / R . Drag V up: dots speed up, the needle climbs.
Drag R up: the same push gets less current through.
The graph : current against voltage is a straight line , slope = 1/R. The dot is your setting.
What sets R? A wire's length L , its cross-section A and its material ρ : R = ρ·L / A . Switch materials.
Every resistor turns electrical energy into heat
P = V·I watts = volts × amps
700 A a ~700 W chip at ~1 V (illustrative)
Power is energy per second, in watts. When current flows through resistance, the energy lost in the bumps becomes heat: P = V·I, which with Ohm's law is also I²·R. An indicator LED uses about 0.06 W. A large AI accelerator can draw roughly 700 W (illustrative), and because its core runs near 1 V, that is about 700 amperes. Nearly all of it ends up as heat, most of it in a piece of silicon a couple of centimetres across, which is why chips sit under heatsinks and fans or liquid cooling, and why power is delivered through hundreds of pins.
LED ≈ 20 mA × 3 V = 0.06 W (typical indicator). GPU/accelerator ~700 W, ~1 V core are illustrative round numbers, not a specific part.
Power = energy per second, in watts (W) . For anything electrical: P = V × I .
In a resistor that energy becomes heat . Push I up: the block glows, the thermometer climbs. Since V = IR, P = I²R .
The ladder compares real things on a log scale (each step ×10): an LED 0.06 W … a big AI chip ~700 W.
Chips run at low voltage (~1 V), so 700 W means ~700 A . Even a tiny wire resistance then wastes I²R as heat.
That heat has nowhere to go but out through the top: heatsink, fan, liquid . Power is a hard wall for chips.
Two resistors in a row split the voltage
R1+R2 series: resistances add
V·R2/(R1+R2) the middle node's voltage
In series, the same current goes through both resistors one after the other, so their resistances add and the supply voltage is shared between them in proportion to their size. The wire between them, called a node, sits at V × R2 / (R1 + R2). That is a voltage divider. A logic gate is a divider made of two switches: when the top switch has almost no resistance and the bottom one is huge, the node sits near VDD (a 1); swap them and it sits near 0 V (a 0). In parallel, the current has two paths, so the total resistance goes down.
Series R = R1 + R2. Parallel 1/R = 1/R1 + 1/R2. Supply fixed at 5 V (illustrative). "Reads as 1 / 0" bands use TTL input thresholds, 2.0 V and 0.8 V (same numbers as 3.3 V LVTTL). See
CMOS gates .
Series : R1 on top, R2 below, one path . The same current flows through both.
The supply's 5 V is shared : the bar shows how much each resistor "uses". Bigger R, bigger share.
The node between them sits at V·R2/(R1+R2) . Drag R1 small and R2 big: node goes high .
That is how a gate makes a 1 or 0 : a switch that is ON (tiny R) against one that is OFF (huge R) pins the node near VDD or near 0 .
Parallel : two paths side by side. Currents add , total resistance drops below the smaller one.
A capacitor fills up, and it takes time
Q = C·V charge stored = capacitance × voltage
63% reached after one time constant τ = R·C
A capacitor is two metal plates separated by an insulator. Charge cannot cross the gap, so electrons pile up on one plate and leave the other, and a voltage builds between them. How much charge it holds per volt is its capacitance C, in farads. Charging it through a resistor is slow at the end: the fuller it gets, the less push is left to drive current in. The voltage follows V(t) = V·(1 − e^(−t/RC)). After one time constant, τ = R·C, it is 63% full; after 5τ it is over 99%.
1 − e⁻¹ = 0.632; 1 − e⁻⁵ = 0.993. Units: kΩ × µF = milliseconds. Supply 5 V (illustrative).
Two plates with an insulator between them. Charge cannot cross the gap.
Close the switch: electrons flow onto one plate and off the other . A voltage grows between them.
The graph draws the plate voltage live . Fast at first, then slower : less push is left as it fills.
The dashed line marks τ = R·C : at that moment it is 63% full . Stored charge Q = C·V .
Change R or C and close again: bigger either one → longer τ → slower filling.
Inside a chip every wire is a tiny capacitor, so switching takes time and power
0.69·RC time to reach half-way (the delay)
α·C·V²·f dynamic power
A logic wire and each transistor gate it feeds form tiny capacitors to ground. To change a 0 into a 1, the driving transistor, which behaves like a resistor when on, must charge that capacitance up. That takes about 0.69·R·C to reach half-way, which is the gate's delay. Drive more gates and C grows, so the edge gets slower. If the clock is too fast, the wire never finishes charging and the 1 is never reached. Each full charge-and-discharge cycle turns energy C·V² into heat (half while charging, half while discharging), so power grows with clock frequency f.
Numbers are illustrative: driver R = 1 kΩ, wire 4 fF, each gate input 2 fF, V = 1 V. ln 2 = 0.693. Energy drawn from the supply per 0→1→0 cycle = C·V²; α = fraction of nodes switching each cycle.
A transistor that is ON acts like a resistor (R_on) connecting the wire to VDD.
The wire and every gate input it drives are small capacitors to ground. They all add up to C.
So a 0→1 is an RC charging curve . Delay ≈ 0.69·R·C (the red mark). Raise fan-out : slower.
Raise the clock f : past a point the wire never reaches a clean 1 (red). This sets a chip's top speed.
Every cycle dumps C·V² of energy as heat: P ≈ α·C·V²·f . 100 million nodes at α = 0.1 turns tens of µW each into hundreds of watts .
Why computers use only 0 and 1: noise gets ignored
2.0 V VIH: at or above reads as 1
0.8 V VIL: at or below reads as 0
An analog signal uses every voltage level as information, so any noise becomes an error in the value. A digital signal only asks "high or low?" A receiver treats anything above VIH as 1 and anything below VIL as 0; between them is a forbidden zone. The driver promises to send at least VOH for a 1 and at most VOL for a 0, and the gap between what is promised and what is needed is the noise margin. Noise smaller than that margin changes nothing at all. That is why billions of transistors can agree.
3.3 V LVTTL input/output levels (JEDEC JESD8C): VIH 2.0 V, VIL 0.8 V, VOH ≥ 2.4 V, VOL ≤ 0.4 V → guaranteed noise margin 0.4 V each side. The waveform here is sent at 3.3 V / 0 V.
Eight bits are sent as 3.3 V (1) or 0 V (0) . The wire adds random noise .
The receiver has two lines: above VIH = 2.0 V means 1, below VIL = 0.8 V means 0. Between is forbidden (red).
It reads each bit in the middle of its slot. With a little noise, every bit comes out exactly right .
The bottom row: an analog receiver reading the same wire gets a wrong value every time, by the full noise amount.
Drag noise up. Digital stays perfect until noise spikes reach the red forbidden zone , then bits start to fail. Press new noise to retry.
Charge never piles up at a junction, and voltage around a loop always sums to zero
Σ in = Σ out current law, at every node
Σ V = 0 voltage law, around every loop
Kirchhoff's current law: charge cannot collect at a junction, so whatever current flows into a node must flow out. Kirchhoff's voltage law: walk around any closed loop, adding every rise (a battery) and every drop (a resistor), and you get back to where you started, zero. A short circuit is a path with almost no resistance straight across the supply. By Ohm's law the current becomes enormous, I = V / (almost 0), and the I²R heat melts wires. In chips, CMOS gates are designed so that, once settled, there is never a path from VDD to ground; only a brief current slips through while a gate switches.
Supply 5 V; loop resistors are sliders A and B in ohms plus a fixed 20 Ω; short = 0.05 Ω (illustrative).
A node is any junction. Currents A and B flow in from the left.
All of it must leave: out = A + B . Charge never piles up at a node . Drag A or B and watch the output grow.
A loop : the battery raises 5 V, the resistors drop it back. The staircase on the right always returns to 0 .
Change A or B: the drops re-share, but they always add up to 5 V .
A short circuit : a wire straight across the battery. R ≈ 0, so I = V/R is huge , and I²R melts it.
Where LPULite hits these limits: its clock is 8× slower than the board's
divide the 50 MHz board clock by ÷1 ÷2 ÷4 ÷8 ÷16
9.84 MHz fastest the LPU logic can run (Fmax, slow 85 °C)
6.25 MHz the clock it actually uses: 50 MHz ÷ 8
1.1 V FPGA core supply pins
LPULite runs on a Cyclone V FPGA on a DE1-SoC board. The board provides a 50 MHz clock, a tick every 20 ns. Between two ticks, a signal must travel through the longest chain of logic and wires, and every step of that chain is an RC delay. Quartus reports the logic can tick at most 9.84 MHz, which means its longest chain takes about 101.6 ns. The design divides the 50 MHz clock by 8 with a 3-bit counter: 160 ns per tick, which leaves 58.4 ns of spare time (slack). ÷4 would give only 80 ns and fail.
Clock divider and comment:
repo/synthesis/rtl/de1_soc_hps_top.sv:6-20 · 50 MHz period:
repo/synthesis/rtl/de1_soc_hps_top.sdc:1 · clocks 20.000 / 160.000 ns:
repo/synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.sta.rpt:130-131 · Fmax 9.84 MHz:
repo/synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.sta.rpt:140 · setup slack 58.373 ns:
repo/synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.sta.summary:10 · device 5CSEMA5F31C6:
repo/synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.fit.rpt:75 · VCC 1.1 V, VCCIO 3.3/2.5 V:
repo/synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.fit.rpt:2203, 2215, 2176. 101.6 ns = 1/9.84 MHz (inferred arithmetic). The RTL comment's “~11 MHz” predates the reported 9.84 MHz. Next:
clock ,
timing ,
FPGA ,
board .
The FPGA chip gets power on separate pins: 1.1 V for the core logic, 3.3 V / 2.5 V for talking to the outside (I/O).
The board's crystal gives CLOCK_50 : a tick every 20 ns . Each tick, results must finish racing through the logic.
The longest path (the critical path ) is a chain of gates and wires, each an RC delay . Quartus's Fmax of 9.84 MHz means it takes ~101.6 ns .
Try ÷4 : 80 ns per tick. The path is still running when the next tick arrives: timing failure (red).
÷8 = 6.25 MHz , 160 ns. The path finishes with 58.4 ns slack (green). This is what LPULite ships.
Why does silicon conduct only a little ?
4 outer (valence) electrons per silicon atom
1.12 eV silicon bandgap at room temperature
10¹⁰ free electrons per cm³ in pure Si at 300 K
5×10²² silicon atoms per cm³
Every atom's outer electrons sit on an energy "ladder". In a metal the filled rung and the empty rung overlap, so electrons move freely. In glass the gap between them is huge (~9 eV), so nothing moves. Silicon's gap is small, 1.12 eV: heat alone kicks a very few electrons across. Each one leaves behind an empty spot called a hole. At room temperature that is only about one free electron per five trillion atoms, which is why pure silicon barely conducts.
Textbook values: Si bandgap 1.12 eV, nᵢ ≈ 1×10¹⁰ cm⁻³ at 300 K, 5×10²² atoms/cm³. SiO₂ gap ≈ 9 eV. Dot counts in the drawing are a log scale (illustrative).
A silicon atom has 4 outer electrons. It shares one with each of 4 neighbours, so every bond holds 2 electrons . Those bond electrons sit on the valence band , the low rung of an energy ladder. Moving freely needs the high rung: the conduction band . The gap between the rungs is the bandgap : 0 for copper, 1.12 eV for silicon, ~9 eV for glass. Heat shakes a few electrons loose. Each free electron leaves a hole in its bond: an electron–hole pair . Drag the temperature.
A hole is a missing electron that seems to move
Hop one electron Pause Reset
→ electrons hop toward +
← the hole drifts toward −
≈1400 electron mobility in Si (cm²/V·s)
≈450 hole mobility in Si (cm²/V·s)
When an electron leaves a bond, the bond has a gap. A neighbouring bond electron can jump into that gap — which leaves a new gap one place further back. Repeat, and the electrons each move one short hop to the right while the gap walks steadily to the left. It behaves exactly like a positive particle moving the other way, so we give it a name: a hole. In silicon holes are about 3× slower than electrons (lower mobility), so p-type paths carry less current.
Mobility values are textbook bulk silicon at 300 K (electrons ≈1350–1450, holes ≈450–480 cm²/V·s).
A row of bonds, all full except one: the empty spot is the hole (open red circle). A battery pulls electrons toward + (right). The electron just left of the gap hops in . Read the timeline rows top to bottom: each electron moved one step right, but the hole walked left . So a hole acts like a positive charge moving the opposite way. In silicon it is about 3× slower than an electron (mobility ≈450 vs ≈1400).
Add one foreign atom per ~5 million and silicon conducts
Pure silicon n-type (+ phosphorus) p-type (+ boron)
P = 5 phosphorus outer electrons → 1 spare
B = 3 boron outer electrons → 1 hole
10¹⁶ typical dopants per cm³ (illustrative)
n·p = nᵢ² more of one carrier means fewer of the other
Doping means swapping a few silicon atoms for a neighbour element. Phosphorus has 5 outer electrons: four fill bonds, the fifth is loose and free to move, and the phosphorus atom left behind is a fixed positive ion. That is n-type (negative carriers). Boron has only 3, so one bond is missing an electron: a hole, and the boron becomes a fixed negative ion. That is p-type. The carrier you added is the majority carrier; the few thermal carriers of the other kind are the minority.
Illustrative doping 10¹⁶ cm⁻³ with nᵢ = 10¹⁰ cm⁻³ gives minority = nᵢ²/N = 10⁴ cm⁻³ (mass-action law, textbook).
Pure silicon : every electron is locked in a bond. Almost no carriers.Swap in phosphorus (5 electrons). The 5th has no bond, so it wanders free : n-type. The phosphorus that lost it becomes a fixed + ion . It cannot move — only the electron can. Swap in boron (3 electrons). One bond is short: a hole that wanders, and a fixed − ion . Right chart (log scale): the added carrier is the majority (10¹⁶); the other kind drops to a tiny minority (10⁴). Toggle freely now.
Press p onto n and a wall builds itself
1 · apart 2 · just touched 3 · settled
≈0.7 V built-in voltage, silicon (depends on doping)
~µm depletion width, lightly doped (illustrative)
p-silicon is full of holes, n-silicon is full of electrons. Touch them and each carrier spreads into the side where it is rare (diffusion). Electrons and holes that meet cancel out (recombine). That strips the carriers from a thin zone at the boundary, leaving only the fixed ions: − on the p side, + on the n side. Those ions create an electric field that pushes back on further diffusion. The push-back balances at about 0.7 V in silicon: the built-in voltage.
Built-in potential of a silicon PN junction is ≈0.6–0.8 V for typical doping (textbook); width values illustrative.
Two separate pieces. p : holes + fixed − ions. n : electrons + fixed + ions. Each piece is neutral. Touch them: carriers diffuse across the boundary, meet, and recombine (flash). What is left at the boundary: fixed ions only , no free carriers — the depletion region . The + ions and − ions make an electric field that pushes carriers back. It balances at a built-in voltage ≈ 0.7 V . Across the junction: charge (−,+ blocks) → field (a triangle) → voltage (a 0.7 V step).
A diode is a one-way valve for current
0.6–0.7 V forward "knee" of a silicon diode
×10 current per +60 mV of forward voltage (ideal diode, 300 K)
pA–nA reverse leakage, small diode
5–1000 V reverse breakdown, depends on design
Push + into the p side (forward bias) and the battery fights the built-in wall, so it thins and carriers pour across; current rises exponentially — about ten times for every extra 60 mV — so it looks like a switch that turns on near 0.6–0.7 V. Reverse the battery and the wall gets wider; only a tiny leakage flows. Push hard enough in reverse and the field tears electrons loose: breakdown, a sudden large current.
Curve: I = Iₛ(e^(V/V_T) − 1), Iₛ = 10⁻¹⁴ A, V_T = 25.85 mV (300 K). Breakdown drawn at −4.5 V (illustrative; a Zener-style diode).
Three jobs a diode does every day
Rectifier · 1 diode Rectifier · bridge LED ESD protection
λ = 1240/E light wavelength (nm) from gap (eV)
2 kV common ESD test level, human-body model
Rectifier: a diode passes only the positive half of an AC wave; a bridge of four diodes flips the negative half up too (each path costs two 0.7 V drops). LED: when an electron falls across the bandgap it can emit one photon whose energy equals the gap, so the gap sets the colour. Silicon's 1.12 eV gap would be infrared, and silicon emits poorly anyway. ESD: chip I/O pins have diodes to the supply rails that dump a static spark away before it can punch through a transistor's 1–2 nm gate oxide.
λ(nm) ≈ 1240 / E(eV). Gaps shown: red ≈1.9 eV, green ≈2.3 eV, blue ≈2.75 eV (typical LED materials, approx.). 2 kV HBM is a common qualification level.
A MOSFET is a sandwich : metal, oxide, silicon
3D cut-away View from above hover any layer
4 terminals: source, gate, drain, body
~1–2 nm gate oxide thickness, modern chips (approx.)
L channel length: source-to-drain distance under the gate
W channel width: how wide the gate is
MOSFET = Metal–Oxide–Semiconductor Field-Effect Transistor. Start with p-type silicon (the body). Two heavily doped n+ islands are the source and drain. Between them, on the surface, a very thin insulating layer (silicon dioxide, or a "high-k" material like hafnium oxide in newer chips), and on top of that a conductive gate. The gate never touches the silicon: it only acts through the oxide with its electric field. L is how far current must travel; W is how wide the road is. Current ∝ W/L.
High-k metal gates (hafnium-based) entered volume production at the 45 nm generation (2007). Thicknesses drawn exaggerated.
Start with a block of p-type silicon : the body (substrate), full of holes. Dope two islands heavily n-type: n+ source and n+ drain . Grow a very thin insulator on top between them: the gate oxide (SiO₂ or high-k). Put a conductor on the oxide: the gate . It is insulated from everything below. Wire up the 4 terminals S G D B , and name the sizes: L (length, source→drain) and W (width, into the page).
How a gate voltage creates a wire in silicon
V_th threshold: channel forms (0.5 V here, illustrative)
≥60 mV gate volts per 10× current below V_th (room temp)
Put a positive voltage on the gate. First it pushes the holes away from the surface, leaving the fixed negative boron ions exposed — a depletion layer with nothing that can move. Raise it further and it attracts electrons from the n+ source into a very thin sheet right under the oxide. That sheet is n-type "inverted" silicon: a channel joining source to drain. The voltage where it appears is the threshold, V_th. Above it, current grows roughly with (V_gs − V_th)². Below it, current is not zero, just exponentially small (subthreshold leakage).
Curve: EKV-style smooth long-channel model, k = 2 mA/V², V_th = 0.5 V, V_ds = 1.0 V (illustrative). Subthreshold slope limit 60 mV/decade at 300 K (textbook).
Gate at 0 V : holes everywhere, two n+ islands separated by p-silicon. No path . Gate at 0.35 V : holes are pushed away . The exposed fixed − ions form a depletion layer . Still no current. Gate past V_th : electrons are pulled into a thin inversion channel . Electrons flow source → drain . Right: I_d vs V_gs . Almost flat until V_th, then rising like a square. Drag the slider yourself.
Small Vds : a resistor . Big Vds : the channel pinches off
linear V_ds < V_gs − V_th: I rises with V_ds
saturation V_ds ≥ V_gs − V_th: I nearly flat
The channel's depth at each point depends on how much the gate beats the local channel voltage. At the source end that is V_gs − V_th. At the drain end it is V_gs − V_th − V_ds. With small V_ds the channel is almost even, so the transistor behaves like a resistor whose value the gate sets. Once V_ds reaches V_gs − V_th, the drain end of the channel shrinks to nothing (pinch-off); extra drain voltage is absorbed in the pinched gap, so current barely grows. Digital gates slam between off and fully on; the linear region is what pulls an output to exactly 0 V.
Long-channel model, k = 2 mA/V², V_th = 0.5 V, channel-length modulation λ = 0.05 V⁻¹ (illustrative).
One curve per gate voltage: drain current vs drain voltage . The thick one is your V_gs; the dot is where you are. Left of the dashed line: linear region . Current ∝ V_ds, so it is a resistor (value shown). Right of it: saturation . The drain end of the channel pinches off and current goes flat. The dashed boundary is V_ds = V_gs − V_th . Drag both sliders and watch the channel taper in the cross-section.
PMOS is NMOS flipped : on when the gate is low
NMOS n+ in p-body · electrons · on when gate HIGH
PMOS p+ in n-well · holes · on when gate LOW
~2–3× electrons faster than holes
~2–3× typical PMOS widening (2.5× here, illustrative)
A PMOS swaps every doping: an n-type well, p+ source and drain, and a channel made of holes. Its source sits at the supply (VDD). It conducts when the gate is pulled below the source by more than the threshold — so a low gate voltage turns it on. Holes move more slowly than electrons, so the same-size PMOS gives less current. Designers make PMOS wider to compensate; that is why PMOS transistors look bigger in chip layouts.
Supply 1.8 V (the SKY130 corner used by LPULite's SRAM macro, asic/macros/sky130_sram_1kbyte_1rw1r_8x1024_8_TT_1p8V_25C.lib:25). Thresholds ±0.5 V, mobility ratio 2.5 illustrative.
One PMOS + one NMOS = a NOT gate that sips power
~0 current when input is steady 0 or 1
α·C·V²·f power spent switching
32 nW α=0.1, C=1 fF, V=1.8 V, f=100 MHz
100 MHz LPULite's ASIC clock target
PMOS on top connects the output to VDD; NMOS below connects it to ground. The same input drives both gates, and they are never both fully on: input 0 → PMOS on, output 1; input 1 → NMOS on, output 0. With a steady input one path is always off, so almost no current flows from VDD to ground. Current only flows briefly while the input passes through the middle, and to charge or discharge the wire capacitance C. That switching power is α·C·V²·f, which is why lower voltage and fewer toggles save so much energy.
100 MHz = 10 ns clock: asic/constraints.sdc:1 (-period 10.000), asic/README.md:3-4. 1.8 V: asic/macros/…TT_1p8V_25C.lib:25. Device model illustrative (matched k, V_th = 0.5 V).
Input 0 : PMOS on , NMOS off. Output is pulled up to 1.8 V . No path to ground → no current. Input 1 : NMOS on , PMOS off. Output pulled to 0 V . Again no through-path. Input halfway : both partly on → a brief current spike from VDD to ground. The transfer curve : output snaps from 1 to 0 around the switching point (0.9 V here, where V_out = V_in). Real power is mostly charging the load: P = α·C·V²·f . Drag the slider through the middle and watch the spike.
From 10 µm to "3 nm": and the name is now marketing
10 µm 1971: Intel 4004 process
~15–20 nm physical gate length at "3 nm" (approx.)
130 nm SKY130: LPULite's ASIC flow
28 nm Cyclone V: LPULite's FPGA (external)
For decades the "node" name was roughly the smallest gate length, and shrinking it made transistors faster, cheaper and lower power. Around the 1990s–2000s the name and the real dimensions drifted apart, and after transistors became 3D fins the names kept shrinking on schedule while the physical gate length stalled around 15–20 nm. So "3 nm" is a generation label, not a measurement. The real progress is in density: fins, stacked sheets, and tighter wiring.
Node years approximate (first volume production, Intel/TSMC). Physical gate lengths are approximate public estimates. SKY130: asic/README.md:3. Cyclone V family "Built on TSMC's 28 nm low-power process" — Intel Cyclone V Device Overview (external).
A chip is printed in layers, with light
dozens masks for one chip (approx.)
li + 5 SKY130 wiring: local interconnect + metal 1–5
Each layer is made the same way: coat the wafer with light-sensitive resist, shine ultraviolet light through a mask (a stencil of that layer's shapes), wash away the exposed resist, then etch, implant dopants or deposit material only where the resist is gone. Repeat for wells, isolation, gates, contacts, and every wiring layer. Transistors live at the bottom; everything above is copper or aluminium wiring separated by insulator.
SKY130 layer stack (local interconnect, metal 1 … metal 5): asic/gds3d_sky130_showcase.txt:7,46,150. Cross-section simplified and not to scale.
Start with a polished p-type silicon wafer . Every layer below is printed by the resist → light → develop → etch loop (right). Implant an n-well where PMOS will live; etch and fill oxide isolation trenches between transistors. Grow the thin gate oxide and pattern the gates (poly/metal). Implant n+ (NMOS) and p+ (PMOS) source/drain, self-aligned to the gates. Cover with insulator and drill contacts ; add local interconnect . Stack metal 1 … metal 5 with vias between them. SKY130 stops at metal 5; big modern chips use 10+.
A logic gate as it is actually drawn on silicon
4 transistors in NAND2
sc_hd SKY130 high-density cell library LPULite uses
A standard cell is a pre-drawn gate with a fixed height. Power (VDD) runs along the top edge and ground along the bottom, so cells snap side by side into rows and share rails. PMOS transistors sit in the top row, inside an n-well; NMOS in the bottom row. Vertical gate stripes cross both rows: wherever a gate crosses a diffusion strip, there is a transistor. In NAND2 the two PMOS share a middle output contact (parallel) while the two NMOS share an uncontacted middle node (series).
LPULite's ASIC flow uses the sky130_fd_sc_hd library: asic/README.md:60-61. Layout simplified (not the real cell geometry).
Two power rails : VDD on top, GND on the bottom. Every cell in the row has them at the same height. Two strips of doped silicon: p+ on top (inside the n-well ) for PMOS, n+ below for NMOS. Two vertical gate stripes , A and B. Each crossing of a gate over a strip is one transistor : 4 in total. Contacts + metal : PMOS in parallel (VDD–Y–VDD), NMOS in series (GND–·–Y). Output pin Y .Toggle A and B : lit crossings conduct. Y is 0 only when both are 1.
Every LPULite unit is, underneath, CMOS transistors
chip logic block gate transistor
28 nm Cyclone V FPGA on the DE1-SoC (external)
130 nm SKY130 ASIC showcase
1.8 V SKY130 SRAM macro corner
30 SRAM macros in the ASIC layout
LPULite runs on two kinds of silicon. On the board it is loaded into an Intel Cyclone V FPGA, a 28 nm CMOS chip whose lookup tables, flip-flops and memory blocks are themselves built from the MOSFETs on these pages, with SRAM cells choosing the wiring. For the ASIC showcase the same SystemVerilog is turned into SKY130 standard cells (130 nm CMOS, 1.8 V): real NAND/flip-flop layouts like the one before, plus 30 OpenRAM SRAM blocks, targeting a 10 ns clock.
FPGA device:
synthesis/project/quartus_init_de1_soc.tcl:11-12 (Cyclone V, 5CSEMA5F31C6);
README.md:119. 28 nm: Intel Cyclone V Device Overview (external). ASIC:
asic/README.md:3-5 (SKY130, 100 MHz),
asic/README.md:60-61 (sky130_fd_sc_hd, 10 ns),
asic/yosys_synth.ys:33 (30 SRAM macros),
asic/macros/…TT_1p8V_25C.lib:25 (1.8 V). Next:
the FPGA ·
the ASIC ·
logic gates .
Eight wires make a number. Hex makes it readable.
bits 72-bit row 96-bit instruction
4 bits = 1 hex digit
18 hex digits in a 72-bit LPULite row
Binary is counting with only two symbols. Each position is worth double the one to its right: 1, 2, 4, 8 … 128. Add the positions that hold a 1 and you get the everyday (decimal) number. Four bits make 16 patterns, exactly the 16 hex symbols 0–9 and A–F, so every 4 bits collapse into one hex digit. That is why engineers write 72-bit or 96-bit words in hex: 18 or 24 characters instead of 72 or 96 ones and zeros.
Row width:
repo/src/lpu_pkg.sv:20 (westbound_row_t [71:0]), instruction layout
repo/src/icu.sv:174-183. Used in LPULite:
buses ,
96-bit instructions .
Each switch is one bit . Its place value doubles going left: 1, 2, 4 … 128. Click the boxes. Add the place values of the 1s. That sum is the decimal number. Cut the 8 bits into two groups of 4. Each group (0–15) is one hex digit : 0–9 then A–F. Long words are unreadable in binary, so we write them in hex. A 72-bit LPULite row = 18 hex digits ; a 96-bit instruction = 24 .
Six gates, each a tiny rule: when is the output 1?
2n rows in a truth table with n inputs
6 basic gates
A truth table lists every possible input pattern and the output for each. Two inputs give 4 rows. AND says 1 only if both inputs are 1 (switches in series). OR says 1 if either is 1 (switches in parallel). NOT flips. NAND and NOR are AND and OR followed by a flip. XOR says 1 when the inputs differ, which is the sum bit of binary addition. Every digital circuit is made from these rules.
Two different circuits, the same answer for every input
NOT(A AND B) NOT(A OR B) A 0 B 1
4/4 input patterns match
4→0 gates after simplifying A·B + A·¬B
Boolean algebra is ordinary algebra for 0 and 1. De Morgan's law: NOT(A AND B) equals (NOT A) OR (NOT B), and NOT(A OR B) equals (NOT A) AND (NOT B). "Equal" means: for every one of the 4 input patterns, both circuits output the same bit. Tools use such laws to shrink circuits: A·B + A·¬B = A·(B+¬B) = A·1 = A, so four gates (a NOT, two ANDs and an OR) become a plain wire.
Symbols: · = AND, + = OR, ¬ = NOT.
Left: AND then NOT . Toggle A and B and watch the output. Right: NOT each input, then OR . A completely different wiring. Check all 4 input patterns : the two outputs agree every time. That is what "=" means in logic. Simplification : A·B + A·¬B always equals A, so 4 gates can be replaced by one wire.Switch to NOT(A OR B) : it equals NOT A AND NOT B. Swap AND↔OR and push the NOT inside.
Write any truth table, and the circuit draws itself
majority odd parity only 011 A 0 B 1 C 1
1 AND gate per output 1
1 OR gate joins them
Sum of products: for every row whose output is 1, build an AND gate that is 1 only for that exact input pattern (use the inverted wire where the input is 0). Then OR all those ANDs together. The OR is 1 exactly on the rows you marked. This works for any truth table, which is why any rule you can write as a table can become hardware.
Real tools then simplify the result (previous page). FPGAs skip the gates and store the table itself:
lookup tables .
Click the F column to choose which input rows should output 1.Every input comes as two wires: the true one and the inverted (¬) one. Each row with F=1 gets an AND gate tapping the wires that match that row (dots = connections). One OR gate joins them. Toggle A, B, C: F lights exactly on your marked rows.
A MUX is a switch controlled by a number
2:1 4:1 select bit 1 0 select bit 0 1
2k inputs chosen by k select bits
5:1 LPULite westbound bus MUX
A multiplexer has several data inputs, one output, and select wires. The select wires carry a number; the input with that number is connected to the output, the others are ignored. Inside, each input has an AND gate that opens only for its own select pattern, and one OR gate collects them. Put 72 of them side by side sharing the same select wires and you can steer a whole 72-bit row: that is a bus.
LPULite:
repo/src/shared_bus_mux.sv:19-20, instantiated with
N_SOURCES(5) at
repo/src/westbound_bus/westbound_bus.sv:47-50. See
the buses .
The idea: a rotary switch . The select number turns the arm to one input. Click D0/D1 and the select button. Inside: an AND gate per input . D0's AND opens only when select=0, D1's only when select=1. An OR joins them. 4:1 : two select bits make 4 patterns, so each AND checks both bits.A bus = 8 (or 72) copies sharing the same select wires, steering a whole number at once.
A decoder turns a number into exactly one lit wire
n→2n address bits → output lines
1 line on at a time (one-hot)
A decoder has n address inputs and 2ⁿ outputs. Each output has its own AND gate that recognises one address pattern, so exactly one output is 1: "one-hot". Memories use this to switch on one row (the word line). An encoder does the reverse: one-hot in, number out. A priority encoder lets several requests be on and reports the highest one.
LPULite: bus consumer decoders
repo/src/westbound_bus/westbound_consumer_decode.sv:18-51 ("at most one … consumer enable can be asserted per cycle"). See
memory rows ,
buses .
The address is a small number on 3 wires. Click the bits. An inverter gives each bit a ¬ copy , so every pattern can be matched. One AND per output , tapping the pattern it recognises. Exactly one output is 1: one-hot . That one hot line switches on one memory row (its word line). Encoder = the reverse. Click the request boxes: the priority encoder outputs the highest number that is on.
Comparing numbers is just gates, and ReLU is a comparator plus a MUX
n+1 gates for n-bit equality (n XNOR + 1 AND)
1 bit decides x < 0: the sign bit
Equality: XNOR each pair of bits (1 if they match) and AND the results. Less-than: compute A − B with one extra bit (so it cannot overflow); if the answer is negative its top (sign) bit is 1. Comparing with zero is even cheaper: a two's-complement number is negative exactly when its top bit is 1, so "x < 0" costs no gates at all. ReLU keeps x if it is not negative and outputs 0 otherwise: the sign bit drives a MUX's select.
LPULite ReLU:
repo/src/vxm.sv:211 (widen_lane(bias_lane_s1) < 0) ? '0 : widen_lane(bias_lane_s1). See
VXM unit ,
two's complement .
Equal? XNOR each bit pair ("same?"), then AND all four. Drag A and B.Less than? Subtract. If A − B is negative, its sign bit is 1.ReLU : the sign bit of x is the select of a MUX. Negative → pick 0, else pick x. Drag x. This is the exact line in LPULite's VXM.
From one column of addition to 8 bits, with the carry racing left
16 gate delays for an 8-bit ripple carry (illustrative 2 per stage)
8 NOT gates (plus carry-in = 1) turn the adder into a subtractor
A half adder adds two bits: sum = XOR, carry = AND. A full adder also takes a carry in: two half adders plus an OR. Chain 8 full adders and the carry from each column feeds the next. Each stage needs time to settle, so the high bits show wrong values until the carry arrives: the adder is slowest when a carry ripples all the way. Subtraction reuses the same adder: A − B = A + (NOT B) + 1.
Gate delay per stage is illustrative. LPULite adds in
repo/src/mac.sv:80 (
aligned_acc + aligned_product) and
repo/src/vxm.sv:202 (bias add). See
adder ,
MAC .
Half adder : 1+1 = 10 in binary. Sum bit = XOR , carry bit = AND .Full adder : add a carry-in with a second half adder; an OR merges the two carries.8-bit ripple : press launch. Red sums are not settled yet ; the carry needs 2 gate delays per stage.Subtraction : flip every bit of B, set carry-in = 1, and the same adder computes A − B.
Sliding bits sideways multiplies or divides by 2
0 gates in a fixed shift (just wires)
3 MUX layers shift 8 bits by 0–7
Moving every bit one place left doubles the number; one place right halves it (rounding down). For negative numbers the new top bit must be a copy of the sign bit, or −6 would turn into a large positive number: that is the arithmetic shift (>>>). A shift by a fixed amount is only wires. A shift by a variable amount is a barrel shifter: layers of 2-input MUXes that shift by 1, 2, 4 … chosen by the bits of the amount.
LPULite:
repo/src/vxm.sv:219 widen_lane(relu_lane) >>> 1;
repo/src/quant.sv:62 adjusted_value >>> shift_amount. See
requantization .
Shift left : every bit moves one place up; a 0 enters on the right. Value × 2 .Arithmetic shift right : the sign bit is copied in. −6 >>> 1 = −3, so value ÷ 2 even when negative.Logical shift right puts a 0 in instead: −6 becomes 125. Wrong for signed numbers.Barrel shifter : drag "amount". Layer 1 shifts by 1, layer 2 by 2, layer 3 by 4; each layer's MUXes obey one bit of the amount.
Every gate takes time, and the slowest path sets the chip's speed
flip A flip B flip C extra term B·C off on
3 gates on the critical path
1 unit of delay per gate (illustrative)
A gate does not answer instantly: its transistors must charge the next wire, taking picoseconds. A change at an input ripples forward one gate at a time. The longest input-to-output chain is the critical path; the clock cannot tick faster than that path settles. When two paths of different length reconverge, the output can briefly show a wrong value: a glitch. Here F = A·B + ¬A·C glitches when A falls; adding the redundant term B·C removes it.
Each gate adds 1 unit of delay . The circuit computes F = A·B + ¬A·C. The critical path (blue) runs A → NOT → AND → OR: 3 gates. Every other path is 2. The replay shows A falling 1→0 (B=C=1). Watch the time stamps: the short path turns off before the long path turns on, so F drops to 0 for one unit: a glitch . (A rising never glitches here: if you just made A rise, press flip A once more.) Turn on the extra term B·C : it holds F at 1 during the race. Flip A again: no glitch.
Compute every answer at once, then let a MUX pick one
6 results computed every time
3 op bits select one
An Arithmetic Logic Unit takes two numbers and an operation code. Hardware does not "choose" to run only one operation: the adder, subtractor, AND, OR, XOR and shifter all compute in parallel, all the time. The op code drives a 6:1 MUX that forwards one result. Flags describe the result: Z (all zero, a NOR of the bits), N (the sign bit), C (carry out of the adder). This is the "execute" part of a CPU.
LPULite's VXM names its 32-bit datapath width
ALU_W:
repo/src/vxm.sv:7. See
CPU ,
VXM .
Two 8-bit inputs A and B arrive on buses. Drag them. Six blocks compute all six answers at once : adder, subtractor, AND, OR, XOR, shift. The op code is the select of a 6:1 MUX. Click an op: only the arm moves, nothing is recomputed. Flags : Z = result is zero, N = sign bit, C = carry out.This is execute in fetch → decode → execute → write back.
Where LPULite uses MUXes, slices, comparators, shifts and adders
5:1 westbound bus MUX
0 opcode decoders in the ICU: bits wire straight to controls
Everything on this part appears in LPULite's source. The two buses are 5-input multiplexers. The instruction control unit does not decode an opcode: each field of the 96-bit instruction is sliced out and wired straight to a unit's control input. VXM's per-lane pipeline is an adder (bias), a sign-bit test driving a MUX (ReLU) and an arithmetic shift (÷2), each stage with a bypass MUX. Quantization shifts right until the largest value fits in 8 bits. The MAC multiplies, shifts to align scales, and adds.
Files:
repo/src/shared_bus_mux.sv:19-20,
westbound_bus/westbound_bus.sv:47-50,
lpu_pkg.sv:135-141,
icu.sv:169-172,
vxm.sv:202-220,
vxm.sv:354-364,
quant.sv:60-62,
quant.sv:112-115,
mac.sv:66,
mac.sv:77-80. Units:
bus ·
VLIW instruction ·
ICU ·
VXM ·
requant ·
MAC .
Bus = MUX . The select number picks which unit's 72-bit row drives the westbound bus. Drag select.ICU fields : instruction bits [2:0] are wired straight to the bus select. No decoding, just a slice of wires .VXM : add bias → ReLU (sign bit picks 0) → ÷2 (>>>1). Each stage has a bypass MUX set by a ctrl bit.Quant : shift right until the biggest value fits in 8 bits, then round-shift every lane by the same amount.MAC : multiply, shift to line up scales, then add into the accumulator.
Some circuits forget instantly. Some remember .
Press the button toggle cell holds 0 Kick the loop
0 memory in a plain gate
2 NOT gates in a loop = 1 stored bit
Combinational logic (AND, OR, adders) has no memory: its output is fixed by the inputs at this instant. Sequential logic also depends on what happened before — press a toggle light twice and it is off again, even though the button looks identical each time. Memory needs feedback: wire an output back to an input. Two inverters in a ring agree with each other in only two ways (A=0,B=1 or A=1,B=0), so whichever one they settle into, they stay in. That self-sustaining loop is the seed of every flip-flop, register and SRAM cell.
Next: turn the loop into a controllable latch.
No memory: a lamp wired to a button is ON only while held. Press it — after release it is always off.
Memory: a toggle cell. Same press, but the lamp's state depends on how many presses came before . That is “sequential”.
Where does memory come from? Feedback. Two NOT gates in a ring: B = NOT A, and A = NOT B. The loop agrees with itself.
The value circulates forever while powered. Kick the loop — force A from outside — and it flips and holds the new bit.
Two NOR gates holding hands make an SR latch
S (set) 0 R (reset) 0 tip: press S, release it, then press R
2 NOR gates
S=R=1 forbidden input
A NOR gate outputs 1 only when both inputs are 0. Cross-couple two of them: each output feeds the other gate's input. S=1 forces Q to 1; R=1 forces Q to 0. With S=R=0 neither is forced, and the feedback keeps whatever Q was — the latch remembers. S=R=1 drives both outputs to 0 (no longer opposites), and letting go of both at the same moment is a race whose winner nobody can predict. That is why the next circuit makes S and R impossible to assert together.
Textbook circuit (every digital logic course). Not a LPULite file.
Start with two NOR gates. A NOR outputs 1 only when both its inputs are 0.
Cross-couple them: Q feeds the lower gate, Q̄ feeds the upper gate. That feedback loop is the memory.
Press S : Q becomes 1. Release it: Q stays 1 (HOLD). Press R : Q becomes 0 and stays.
The timing trace records every change: Q only moves when S or R is pressed.
Now press S and R together : both outputs go 0 — the forbidden row, drawn red. Release both: a random race.
A D latch: a window that is open or shut
D (data) 0 EN (enable) 0 Run time +1 time step
1 data input, no forbidden case
EN 1 = transparent · 0 = hold
The D latch puts two AND gates and a NOT in front of an SR latch. S = D·EN and R = (NOT D)·EN, so S and R can never both be 1 — the forbidden case is gone. While EN=1 the latch is “transparent”: Q follows D, like a plain wire. When EN falls to 0, both S and R become 0 and the SR latch holds whatever D was at that moment. The waveform shows Q copying D only inside the yellow (EN high) columns.
Textbook circuit. The latch is the building block of the flip-flop on the next page.
D goes straight in, and through a NOT . Two AND gates let them through only when EN = 1 : S = D·EN, R = D̄·EN.
S and R drive the SR latch from the page before. S and R can never both be 1 — no forbidden case.
EN = 1: transparent , Q follows D live. EN = 0: hold , D can wiggle and Q ignores it. Try both switches.
The recorder: Q copies D only inside the yellow columns (EN high). Press Run time and flip D and EN.
Two latches back to back change only on the clock edge
2 latches: master + slave
↑ Q changes only at rising edges
A latch is open for the whole time EN is high, so a change in D can race through during that time. A flip-flop fixes this with two latches enabled on opposite clock levels. While CLK=0 the master is open (tracks D) and the slave is shut (Q holds). When CLK rises, the master shuts — freezing the value D had at that instant — and the slave opens, showing it on Q. When CLK falls again the slave shuts before the master reopens. Net effect: Q takes one snapshot of D per rising edge. Every “register” in a chip is built from these.
Textbook master–slave D flip-flop. LPULite writes them as always_ff @(posedge clk), e.g. src/icu.sv:158.
Put two D latches in a row: a master and a slave .
The clock drives them opposite ways: master EN = NOT CLK, slave EN = CLK. Never both open.
Press Clock . CLK = 0: master open (listens to D), slave closed (Q holds). CLK = 1: master freezes, slave shows it.
Waveforms: the plain latch follows D all the time CLK is high…
…but the flip-flop Q changes only at the red ↑ rising edges . One snapshot of D per tick.
A flip-flop needs D still around the edge — or it can't decide
80 ps setup (illustrative)
40 ps hold (illustrative)
Real transistors take time. Setup time: D must be stable for a moment before the rising edge so the master latch has settled. Hold time: D must stay put a moment after, while the master finishes shutting. Clock-to-Q: after the edge, Q needs a little time to show the new value. If D changes inside the setup/hold window, the feedback loop can balance halfway between 0 and 1 — metastability — and fall to either side after an unpredictable delay. Chips avoid it by timing analysis inside one clock domain, and by synchroniser flip-flop chains when a signal comes from outside.
All picosecond values are illustrative; real values depend on the process and cell library.
One rising clock edge at t = 0, the D input changing from 0 to 1, and the output Q .
Setup time : D must already be steady for a short window before the edge.
Hold time : D must stay steady for a short window after the edge too.
Clock→Q : even when all is well, Q appears a little after the edge.
Drag D's change into the window : Q goes metastable (red wobble) and falls to 0 or 1 unpredictably.
Reset now or reset at the tick — and how a flop holds
async resets immediately
sync resets at the next ↑ edge
Reset puts a flip-flop in a known value (usually 0 or IDLE) at power-up. An asynchronous reset acts the moment the reset wire is asserted, no clock needed. A synchronous reset is just another input checked at the rising edge — so a reset pulse that falls between two edges is missed. LPULite uses both styles. An enable is also just logic: a 2-input MUX in front of D picks the new value when EN=1, or feeds Q back to D when EN=0, so every tick the flop reloads its own value and nothing changes.
Async: src/rmsnorm.sv:175-177, src/row_fifo.sv:28-29. Sync: src/lut_softmax_div.sv:345-347.
A clock with 8 ticks and a reset pulse . Drag where it starts and how long it lasts.
Asynchronous reset: Q drops the instant rst rises — no clock needed.
Synchronous reset: Q drops only at the next rising edge while rst is high. Make the pulse short → it is missed .
Enable is a MUX: EN=1 loads new D, EN=0 feeds Q back into D so it holds. Flip EN.
A register is just a row of flip-flops sharing one clock
8 flip-flops = 8-bit register
32 bits in LPULite's pc register
One flip-flop stores one bit. Put eight side by side, give them the same clock and the same enable, and you have an 8-bit register: on a rising edge (with EN=1) all eight bits of the input bus are copied at once. Between edges the output bus holds steady no matter what the input does. Buses of 32, 64 or 256 bits are just wider rows. Drag the input: Q does not change until you press the clock.
logic [31:0] pc; — a 32-flip-flop register, src/icu.sv:68.
One D flip-flop stores one bit.
Eight side by side, fed by an 8-wire D bus , driving an 8-wire Q bus . Drag the value — Q waits.
They all share one clock wire , so all 8 bits update on the same edge . Press Clock tick .
A shared enable : set EN = 0 and ticks do nothing — the register holds.
Chain the flops and bits march one step per tick
serial in 0 Clock tick ↑ Run clock
1 wire carries a whole byte, serially
8 ticks of delay through 8 stages
Connect each flip-flop's Q to the next one's D. On every rising edge every stage copies its left neighbour at the same instant, so the whole pattern slides one place right. Feed it one bit per tick on a single wire and after 8 ticks you have a byte in parallel — that is how serial links (UART, SPI, the lanes of PCIe) move data with few wires. Read the far end and you get the input back exactly 8 ticks late: a delay line.
Textbook. Works because every stage samples on the same edge (previous page).
Eight flip-flops in a chain : each Q feeds the next D.
One shared clock . Press Clock tick : every bit moves one stage right , the last one falls out.
Set serial in and keep ticking: your bits come out the far end, in order.
Uses: serial links (one wire, a bit per tick) and delay lines (out = in, 8 ticks later).
A register plus a +1 adder counts — that is a program counter
2N states before it wraps to 0
÷8 3-bit counter makes LPULite's clock
Take a register, send its output through an adder that adds 1, and wire the result back into the register's input. Every rising edge loads “old value + 1”. With N flip-flops it can show 2^N values; after the largest it wraps to 0 because the carry out of the top bit has nowhere to go. Add a MUX that can pick a “load value” instead of +1 and you can jump. A CPU's program counter is exactly this: +1 to the next instruction, or load to jump. LPULite's instruction control unit (ICU) steps through its program the same way.
src/icu.sv:158-165 (pc: reset, load, +1). Clock divider:
synthesis/rtl/de1_soc_hps_top.sv:12-20 (3-bit counter, bit 2 = 6.25 MHz). Used in LPULite:
ICU ,
clock .
A register holds the count. The ring shows all 2N values it can hold.
Its output goes through a +1 adder and back into its own input : each tick loads old+1. Keep ticking — it wraps to 0.
A MUX can pick a load value instead: turn on jump , tick, and the count leaps. That is a CPU's jump.
The top bit toggles every 2N−1 ticks: a clock divider. Pick 3 bits — that is how the board makes 50 ÷ 8 = 6.25 MHz .
Data must cross the logic before the next tick — that sets Fmax
9.84 MHz LPULite Fmax (slow 85 °C)
58.4 ns slack at 6.25 MHz (160 ns)
Between two flip-flops sits combinational logic. At a rising edge, flop A launches new data (after its clock-to-Q delay); it ripples through the logic; and it must reach flop B at least a setup time before the next rising edge. So clock-to-Q + logic delay + setup ≤ period. The slowest such path in the whole chip sets the maximum frequency, Fmax = 1 / that time. Leftover time is “slack”. LPULite's timing report gives Fmax 9.84 MHz for its fabric clock, so it runs at 6.25 MHz (160 ns) with 58.373 ns setup slack.
synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.sta.rpt:140 (9.84 MHz QSYS_CLK);
…sta.summary:10 (slack 58.373);
synthesis/rtl/de1_soc_hps_top.sv:6-8. The 2 ns / 1 ns split is illustrative. See
timing .
Flop A → logic → flop B , both on the same clock. Data launched at one tick must be caught at the next.
Lay the path on a timeline: clock→Q , then the logic delay , then B's setup .
The next tick comes at the period . Left over = slack (green). Drag logic past it: violation (red).
Fmax = 1 / path . Press LPULite numbers : 160 ns period (6.25 MHz), ≈101.6 ns path (9.84 MHz) → 58.4 ns slack .
Cut a long path with registers: faster clock , but later answers
1 stage 2 stages 3 stages Clock tick ↑ Run clock
2.8× clock rate with 3 stages (31→11 ns)
3 ticks before the first answer
If one path of logic is too slow for the clock you want, put a register in the middle. Now each half only has to finish within one period, so the period can shrink. Every tick a new input enters while earlier ones move along — like an assembly line. The cost is latency: an input needs N ticks to emerge from N stages, and each register adds a little overhead. Cuts must be balanced; the slowest stage still sets the clock. Numbers here (10 ns blocks, 1 ns register overhead) are illustrative.
Illustrative numbers. Related: LPULite's FIFOs and registered block-RAM outputs add ticks of latency (
timing ).
30 ns of logic between two registers. The clock can tick at most once per 31 ns .
Choose 2 or 3 stages : extra registers (black bars) cut the path.
Press Clock tick : every token moves one stage per tick , a new one enters each tick.
Trade-off: results per second go up (green), but each answer waits N ticks (red). Balance the cuts.
A state machine = a register that remembers “where am I”
Moore Mealy car waiting 0 Clock tick ↑
3 blocks in every FSM
2 flip-flops for 4 states
A finite state machine (FSM) is a design pattern built from what you have seen: a state register (flip-flops holding a number that names the current state), next-state logic (gates that look at the state and the inputs and decide where to go at the next tick), and output logic. In a Moore machine outputs depend only on the state; in a Mealy machine inputs also feed the outputs directly, so they can react within the same tick. Draw it as bubbles (states) and arrows labelled with the condition that takes them.
Textbook example. LPULite's real FSMs are two pages ahead.
Next-state logic (gates) decides where to go; the state register (flip-flops) stores it at each clock tick.
The current state is fed back into the next-state logic — the same feedback idea as the latch.
Output logic turns the state into outputs. Toggle Moore / Mealy : Mealy also wires the inputs in.
A traffic light: 4 bubbles (states), arrows labelled with conditions . Toggle car waiting , press Clock tick .
The whole “where am I” is just 2 bits in a register . In Mealy mode the WAIT lamp reacts to the car without a tick.
“Start” → busy → “done”: the handshake every hardware block speaks
start 0 Clock tick ↑ Run clock
3 states: IDLE, BUSY, DONE
1 tick that done stays high
Hardware blocks that take several clock ticks to finish talk to each other with a handshake. The caller raises start; the block leaves IDLE and goes BUSY, counting through its work while busy=1; when finished it spends exactly one tick in DONE with done=1, then returns to IDLE ready for the next request. The waveform is what an engineer looks at in a simulator to debug it. LPULite's lookup-table divider is this machine with a one-tick LOOKUP state.
Real twin: src/lut_softmax_div.sv:19-23, 364-382. Handshake style in_valid/in_ready: src/softmax.sv:13-21.
Three states. start = 1 takes IDLE → BUSY; BUSY counts 3 ticks; DONE lasts one tick, then back to IDLE.
Outputs come straight from the state: busy = in BUSY, done = in DONE.
The waveform: turn start on, tick, turn it off — watch the state row walk I → B → B → B → D → I.
busy is high for the work, done pulses for exactly one tick . That pulse is the “finished” message.
Now read LPULite's real state machines, one tick at a time
rmsnorm softmax lut div icu pc row_fifo start_i 1 out_ready 0 Clock tick ↑ Reset
5 real sequential blocks here
1 line of SV per arrow
Each LPULite unit that needs more than one tick is one of the patterns from this part. RMSNorm captures CHUNKS rows, then emits them (2 states). Softmax captures rows, sums them one per tick, waits for the lookup-table divider, then normalises (4 states). The divider itself is IDLE→LOOKUP→DONE_ST. The ICU's program counter is a counter with load. The row FIFO is a 4-slot memory with two wrap-around pointers and a count. The highlighted code lines are exactly the ones that ran on your last tick.
src/rmsnorm.sv:28-31,170-217 ·
src/softmax.sv:32-37,152,170-245 ·
src/lut_softmax_div.sv:19-23,345-382 ·
src/icu.sv:66-68,147,158-165 ·
src/row_fifo.sv:17-49, instanced with DEPTH 4 at
src/lpu.sv:613-695. Units:
RMSNorm ,
Softmax ,
ICU .
Pick a unit. The bubbles are its states, the arrows carry the real signal names from the file. Yellow = current state.
Right: the real SystemVerilog lines with their line numbers. Press Clock tick — the lines that ran light up.
Flip the input switches (start_i , out_ready , wr_en …) and tick: you are the testbench.
Where LPULite remembers : 10,031 flip-flops
show all state machines counters FIFOs clock & timing
10,031 dedicated logic registers
6.25 MHz fabric clock
Every idea in this part is in the LPULite build. The FPGA fitter reports 10,031 dedicated logic registers (flip-flops). They hold the ICU's 32-bit program counter, the state registers of the RMSNorm, Softmax and divider FSMs, the pointers of the row FIFOs around the VXM, and the 3-bit counter that divides the board's 50 MHz down to 6.25 MHz because the slowest path only reaches 9.84 MHz. Click a card to open that unit.
…/lpu_lite_de1_soc_hps.fit.rpt:1957 (10,031;
fit.summary says 10,074 total registers);
README.md:441;
…sta.rpt:140;
de1_soc_hps_top.sv:6-20. Links:
ICU ·
RMSNorm ·
Softmax ·
timing ·
clock .
The fitter's count: 10,031 flip-flops in LPULite's FPGA build.
That is the chip's short-term memory. Clock 6.25 MHz ; slowest path 9.84 MHz ; 58.4 ns spare.
Filter by kind — state machines, counters, FIFOs, timing — and click a card to jump to the unit.
Memory is a wall of numbered boxes
2 things: address + data
2ⁿ boxes from n address wires
read copy out, box unchanged
write replace, old value gone
A memory is a long row of numbered boxes. The address is the box number, sent as a pattern of 0/1 wires: 4 wires can name 16 boxes, 14 wires name 16,384. The data is the pattern stored inside the box. A read copies the box onto the output wires and leaves it unchanged. A write happens only when the write enable wire is 1: the input pattern replaces what was there, and the old value is lost for good.
Each box has a number (its address) and holds an 8-bit value . Hover any box. Flip the address switches: 4 wires pick 1 of 16 boxes. Read : the chosen box's bits are copied onto the output. The box keeps them.Write : drag a new value, press write . Write enable = 1 for a moment and the old value is gone .
Fast memory is tiny , big memory is slow
size access time cost per GB
~10⁷× disk vs SRAM access time
~10⁴× SRAM vs disk price per GB
Every kind of memory trades speed for size and price. Flip-flops inside the logic answer in a fraction of a nanosecond but hold only bytes. SRAM on the processor chip answers in about a nanosecond but costs hundreds of dollars per gigabyte. DRAM is ~50× slower and ~50× cheaper. Flash and disks hold terabytes but take micro- to milliseconds. Computers stack them: a little fast memory close to the compute, a lot of slow memory further away. Bars are log scale: every tick multiplies.
Ranges: Patterson & Hennessy,
Computer Organization and Design 5e, §5.1 (register row illustrative). See
SRAM vs DRAM ,
the memory wall .
Register : flip-flops inside the logic. Fastest, holds only bytes.SRAM : on the processor chip. ~1 ns, but very expensive per bit.DRAM : separate chips. ~50 ns: already ~50× slower.Flash SSD : microseconds. Holds terabytes.Disk : milliseconds — ten million times slower than SRAM.Switch between size , access time and cost : every row wins one and loses another.
An SRAM bit is two inverters holding each other
read write next phase → stored 1 write value 0
6 transistors per bit
2 bit lines: BL and BLB
1 word line opens the doors
∞ holds while powered, no refresh
An inverter outputs the opposite of its input. Wire two inverters in a loop: if Q is 1, inverter 2 makes QB 0, and inverter 1 turns that back into Q = 1 — the loop holds itself. Two access transistors (M5, M6) connect Q and QB to the bit lines only when the word line is 1. To READ, both bit lines are pre-charged high, the word line opens, and the side storing 0 pulls its bit line down a little; a sense amplifier turns that small gap into a clean 0 or 1. To WRITE, strong drivers force the bit lines to the new value and its opposite, and the loop is overpowered and flips.
Textbook 6T cell (Rabaey, Digital Integrated Circuits , ch. 12); voltages illustrative. Used in LPULite: the ASIC build's SKY130 SRAM macros (src/mem.sv:38-57).
Two inverters in a loop : Q feeds inverter 2, QB feeds inverter 1. The green transistors are ON. It holds the bit by itself.Word line + two access transistors (M5, M6): the doors between the cell and the outside.Bit lines BL and BLB run down every column to a sense amplifier .With read selected, press next phase 3 times — precharge, open word line (watch current drain one bit line), sense. Press write , set the write value, press next phase : drivers force the bit lines, the word line opens, the cell flips .
A DRAM bit is a leaking bucket of charge
1+1 transistor + capacitor
64 ms JEDEC refresh window
read = erase then rewrite
A DRAM bit is a tiny capacitor behind one transistor: charged = 1, empty = 0. That is only 2 parts instead of 6, so DRAM packs far more bits per area. Two problems. A read connects the tiny capacitor to the long bit line, which holds about ten times more charge; the charge spreads out, the bit line moves by only tens of millivolts, and the cell is left half-empty — so every read must be followed by a rewrite. And the charge leaks away, so every cell is re-read and rewritten within 64 ms (JEDEC DDR standard; 32 ms at high temperature).
Refresh window: JEDEC DDR3/DDR4 (JESD79). Capacitances and leak rates illustrative. LPULite's FPGA board DRAM belongs to the ARM side; see
SRAM vs DRAM .
Press hold . One transistor (door) and one capacitor (bucket). Full bucket = 1. Press read (share) : the door opens and the charge spills into the big bit line. It moves only +45 mV, and the cell is now destroyed . Press rewrite : the sense amplifier pushes a full 1 back in. Every read ends with a write. The bucket leaks . Drag the leak slider to fast, then turn refresh OFF : the bit is lost. Refresh every 64 ms saves it.
A memory chip is cells in rows and columns
1 word line lit per read
8 bit lines read at once
14→16,384 LPULite row decoder
Cells sit in a grid. All cells in one row share a word line; all cells in one column share a bit line. The row decoder is a set of AND gates: for each possible address exactly one AND gate outputs 1, and that row's word line turns on. Every cell in that row puts its bit onto its column's bit line at once, a sense amplifier per column cleans each signal up, and together they form the word on the data bus. A column multiplexer can then pick single bits out of that word.
Standard memory-array organisation. Used in LPULite: MEM0/MEM1 rows of 72 bits (src/mem.sv:6-8,13-14).
32 cells : 4 rows (words) × 8 columns (bits). Hover a cell.The row decoder : one AND gate per row. Only the one matching the address outputs 1. That row's word line lights; all 8 cells drive their bit lines at once. Click another address. Sense amplifiers clean the bits and put the whole word on the data bus .The column mux picks one bit from the word. Drag it.
Synchronous memory answers one tick late
ideal async real block RAM play tick →
1 tick: simple synchronous RAM
2 ticks LPULite holds each read
Block RAM on an FPGA is synchronous: it looks at the address only at a rising clock edge, and its output is a register that also changes only at an edge. The address must be stable a little before the edge (setup) and a little after (hold). So if you ask for row 42 during tick n, row 42 appears on the output after the edge that ends tick n. LPULite's DE1-SoC build uses Intel altsyncram, which registers the address and the output, so its rows arrive two edges after the request. The compiler therefore emits every memory read for two instruction rows in a row, which is safe for both the one-tick simulation RAM and the two-register FPGA RAM.
src/mem.sv:125-134 (simulation RAM, 1 tick) ·
src/mem.sv:80-92 +
fit.rpt:3700 (FPGA altsyncram: input and output registers) ·
model/tools/compile_microgpt_lpu.py:72-76 (
MEM_READ_HOLD_CYCLES = 2),
:78-82 (write settle) ·
src/icu.sv:145-147. See
the ICU ,
clock .
The clock : every rising edge is one tick. Press play . The address and read enable must be steady around the edge: setup before, hold after. Synchronous RAM: data appears only after the next edge — 1 tick late. Compare ideal async . So LPULite's compiler writes the same read twice ; the consumer captures on the second tick. The exact Verilog below: stream_out <= sram_array[addr] inside posedge clk is the register that causes the delay (simulation model; the FPGA altsyncram adds an output register).
A memory can be a function
178 rows: softmax exp LUT
256 rows: reciprocal LUT
32 rows: RMSNorm 1/√x LUT
16 bits = any 4-input gate
A ROM (read-only memory) is filled once and never written. Treat the address as the input and the contents as the output, and it computes a function instantly: no multiplier, just "read row a". LPULite needs e^x for softmax; instead of computing it, it stores round(256·e^(−a/256)) for a = 0…177 and reads the answer. An FPGA uses the same trick for all logic: a 4-input LUT is 16 stored bits, and the 4 inputs are the address. Change the 16 bits and it becomes a different gate.
Checked all rows:
src/lut_softmax_exp.sv:42-225 = round(256·e^(−a/256));
src/lut_softmax_div.sv:52-315 = round(2³⁰ / (1+(i+0.5)/256)), 256 rows; RMSNorm 32 rows
src/rmsnorm.sv:48-81. See
FPGA LUT ,
softmax .
The exp table : 178 rows burned into LPULite's softmax. Drag a . The address is the input , the stored number is the output . No maths happens at run time. Plotted, the rows are the curve 256·e^(−a/256) : from 256 down to 128. An FPGA LUT is the same idea: 4 inputs address 16 bits . Flip inputs, switch the function.
LPULite fills 79% of the FPGA's memory tiles
all MEM0 MEM1 IMEM other fill the tiles
79% RAM blocks 314 / 397
61% memory bits used
144 M10K tiles each for MEM0, MEM1
144 KiB per data memory
An FPGA is not only programmable logic: it also has hard, pre-built memory blocks. On the Cyclone V these are called M10K because each holds 10,240 bits; the DE1-SoC's chip has 397 of them. LPULite's two data memories are 16,384 rows × 72 bits = 1,179,648 bits each, and the fitter spread each over 144 tiles. The instruction memory takes 10 more; the row FIFOs and the softmax score buffer take the last 16. 79% of the tiles are claimed, but only 61% of the bits, because each tile ends up only partly filled.
Quartus fitter:
synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.fit.summary:12-13; per-memory M10K counts (144 / 144 / 10 / 7 / 2 / 7) from
…fit.rpt:3699-3704. Device 5CSEMA5F31C6. See
FPGA .
Each square is one M10K hard memory tile. There are 397 . Press fill the tiles . MEM0 and MEM1 take 144 tiles each , IMEM 10 , row FIFOs 9 + softmax score buffer 7 = 16.Quartus reports 79% of tiles but only 61% of bits. Why: each memory's bits are spread over more tiles than bits alone would need. One row is 72 bits : 8 int8 lanes + an 8-bit scale. × 16,384 rows = 144 KiB .
The CPU talks to the FPGA by writing to addresses
0xFF200000 bridge base address
12 bytes per row
3 32-bit writes per row
0xC000 control register
To the ARM processor, the FPGA looks like memory. Linux maps the physical window starting at 0xFF200000, and a C program writes numbers into it. Those addresses are not RAM: the bridge turns each write into signals inside the FPGA. Offset 0x0000 lands in the instruction memory, 0x4000 in MEM0, 0x8000 in MEM1, 0xC000 in the control register that starts the chip. A 96-bit instruction row does not fit one 32-bit write, so it is sent as three writes at row×12, +4, +8; the last one commits the row.
synthesis/linux/include/lpulite_hps_mmio.h:18-19,22-29,31-32 · row write order
synthesis/linux/src/lpulite_hps_mmio.c:18-28. See
the board .
The ARM CPU runs Linux and sees a window of addresses starting at 0xFF200000. The window is split in four: IMEM , MEM0 , MEM1 , control . Pick one. One row = 3 × 32-bit writes at base + row × 12. Press send write ; word 2 commits . Pick control : one write of 1 to 0xC000 starts the chip running.
A FIFO is a queue made of two chasing pointers
4 slots per LPULite row FIFO
3 row FIFOs around the VXM
A FIFO (first in, first out) lets one block hand rows to another that may be busy. It is a tiny memory plus two counters. Push writes into the slot the write pointer names, then moves it on; pop reads the slot the read pointer names, then moves it on. When a pointer passes the last slot it wraps to 0, so the slots form a ring. A count tells FULL (writer must wait) from EMPTY (reader must wait). In LPULite, rows headed into the VXM wait in a 4-slot FIFO, and the VXM stalls when its output FIFO is full.
src/row_fifo.sv:19-26,34-47 · instances
src/lpu.sv:613-626 (256-bit),
:669-682 (64-bit),
:683-695 (32-bit scale), stall
:610. See
the buses .
4 slots in a ring. Press push a few times: the green wr_ptr moves on. Press pop : the red rd_ptr takes the oldest item first. Push until FULL lights: the next push is refused. Pop to empty: EMPTY . This is the real LPULite code. Keep pushing and popping: the pointers wrap from 3 back to 0.
For AI, the pipe from memory is the bottleneck
w × v bandwidth = width × speed
all weights read per token
To produce one new token, a language model multiplies its input by every weight once. With one user, that means the whole model has to travel from memory to the compute for every token. The rate at which bytes can move is the number of parallel wires times the speed of each wire. Divide that by the model size and you get a ceiling on tokens per second that no amount of faster multiplying can beat. That is why AI chips put memory as close as possible to the compute, or inside it.
Memory holds all the weights; compute needs every one of them for each new token.Width : how many wires in parallel. Drag it.Speed : bits per second on each wire. Watch the bytes flow.tokens/s ≤ bandwidth ÷ model size . Make the model bigger: the ceiling falls.
Every memory inside LPULite
ARM IMEM MEM0 MEM1 LUTs FIFOs registers
1,024 IMEM rows × 96 bits
16,384 MEM0 / MEM1 rows × 72 bits
3 LUT kinds: 178, 256, 32 rows
3 × 4 row FIFOs × slots
IMEM holds the program (1,024 × 96-bit rows). MEM0 is the scratchpad plus the K cache; MEM1 holds the model weights plus the V cache — both 16,384 × 72-bit synchronous block RAMs read with a one-tick delay. Softmax and RMSNorm use ROM lookup tables instead of maths. Three 4-slot FIFOs queue rows in and out of the VXM. The ARM CPU writes all of these through memory-mapped addresses, and after each token copies the new K and V rows into the caches.
Verilog is not a program that runs. It is a drawing of wires.
Pause Shuffle Verilog lines
1 line a CPU runs at a time
all Verilog parts working at once
A Python program is a list of instructions: the processor does line 1, then line 2, then line 3, and a loop repeats lines. Verilog (and its newer version SystemVerilog) looks like code but means something else: each line describes a PART — a gate, a wire, a memory cell. Once the chip is built every part exists at the same time and works continuously. That is why the order of Verilog assign lines does not matter, and why hardware can do many things in the same instant.
HDL = hardware description language. LPULite's hardware is written in SystemVerilog: repo/src/*.sv.
Software : the CPU runs ONE line per time slice. Watch the red ▶ and the blocks fill one by one.Verilog lines are not steps. Each line names a part . Hover one.The four lines become four gates . In every slice, all four are working — no waiting. Shuffle the lines: it is still exactly the same circuit. Order doesn't matter.
A module is a box with named pins
8 wires in [7:0]
9 wires in [8:0] (one extra for carry)
module … endmodule draws the outline of a box. input and output are its pins. logic means “a wire or a memory bit, the tool will work out which”. [7:0] is the width: wires numbered 7 down to 0, eight of them. Outside, nobody needs to know what is inside — they just connect to the pins. Inside, assign y = a + b; builds an adder. The output is 9 bits because 255 + 255 = 510 needs a ninth bit.
Example written for this page. Real LPULite modules look the same, e.g. repo/src/row_fifo.sv:3-15.
module adder … endmodule = the outline of one box named adder.input logic [7:0] a : eight wires come in. Drag a and b — each wire is 0 or 1.output logic [8:0] y : nine wires go out. The top one is the carry .Open the box: assign y = a + b is an adder circuit, always computing.
Slicing and gluing bits is just choosing wires
0 gates used by a slice
16 bits after sign-extending 8
logic [7:0] x is a bundle of 8 wires. x[7] picks one wire. x[3:0] picks the bottom four. Curly braces glue bundles side by side: {a, b} puts a on the high side. {8{x[7]}} repeats one wire eight times. Put together, {{8{x[7]}}, x} makes a 16-bit copy of a signed 8-bit number: the new top bits copy the sign, so −74 stays −74. None of this costs any gates — the tool just connects wires.
LPULite does this exact sign-extension: {{24{lane8[7]}}, lane8} at repo/src/lpu.sv:178, and {{24{regular_row_scale[7]}}, …} at repo/src/quant.sv:142.
x[7] is one wire: the sign bit of a signed number. Flip x7 above.x[3:0] is a slice : the bottom 4 wires.{ , } glues bundles: here the two halves swap places.{{8{x[7]}}, x} copies the sign bit 8 times: a 16-bit number with the same value .
assign and always_comb build logic with no memory
0 clocks involved
2→1 a MUX picks one of two wires
“Combinational” logic has no memory: its output is a fixed function of its inputs right now. assign y = a & b; is one AND gate. An always_comb block lets you write the same thing with if/else and case, which is easier for bigger logic. An if/else choosing between two values becomes a multiplexer (MUX): a switch whose position is set by sel. The “if” is not a decision taken once — both inputs are always wired in, and sel keeps choosing.
LPULite: assign ext_mem0_en = ext_en && (ext_target == EXT_TARGET_MEM0); repo/src/lpu.sv:99; the softmax/signed MUX unique case (ingress_mode_reg) repo/src/quant.sv:135-144.
assign y = a & b = one AND gate. Flip a and b: y follows instantly.always_comb + if/else = a MUX (a wire selector). Still no clock.Flip sel : the red path moves from b to a. Both inputs are always connected. “Comb” = no memory. Change an input and the output changes a fraction of a nanosecond later (the gate delay).
always_ff builds memory that updates on the clock edge
d 0 rst 0 Clock tick ↑ Auto clock Clear
1 moment per tick when q may change
10,031 flip-flops in LPULite's FPGA build
always_ff @(posedge clk) means “do this only at the rising edge of clk” (posedge = the instant clk goes 0→1). Anything assigned inside becomes a flip-flop: a 1-bit memory that copies D into Q at the edge and then holds it. The arrow <= reads “becomes, at the edge”. A bundle of flip-flops sharing a clock is a register. Between edges, D can wiggle all it likes; Q ignores it.
LPULite: always_ff @(posedge clk or negedge rst_n) with cycle_counter <= cycle_counter + 32'd1; repo/src/lpu.sv:107-113. Register count: “Dedicated logic registers 10,031” repo/synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.fit.rpt:1957 (fit.summary line 9 reports 10,074 total registers).
always_ff @(posedge clk) becomes a D flip-flop : one bit of memory.Set d = 1 . Look at the waveform: q does not move . Clock tick: at the rising edge (red line) q copies d. q <= d = “q becomes d at the edge”. Try Auto clock and flip d between ticks.Many flip-flops on one clock = a register . LPULite's FPGA build has 10,031 .
<= swaps two registers. = silently breaks the swap.
<= non-blocking = blocking Clock tick ↑ Reset
<= inside always_ff
= inside always_comb
With <= (non-blocking) every right-hand side is read first, using the values from before the edge, and all the left-hand sides update together. So a <= b; b <= a; is two flip-flops with crossed wires: they swap. With = (blocking) the lines act like software: a = b changes a immediately, and then b = a reads the new a. Both end up holding b's old value. The rule every designer follows: <= in always_ff, = in always_comb.
LPULite follows the rule: flops use <= (repo/src/row_fifo.sv:30-46), comb blocks use = (repo/src/quant.sv:94-99).
Two registers: a = 3 , b = 7 . With <= : read all right sides first, update all at the edge. Tick! They swap . The circuit is two flip-flops with crossed wires . Switch to = : line 1 changes a before line 2 reads it. Now b just feeds itself: both hold 7 and the swap is lost (red row).
An enum , a register and a case make a state machine
3 states
2 bits of state in the code (Quartus built it one-hot: 3 flip-flops, map.rpt:1325)
A finite-state machine (FSM) is a register that remembers “which step am I on” plus logic that decides the next step. typedef enum gives the 2-bit codes readable names. The always_ff block is the state register: each edge it loads next_state. The always_comb + case block is the arrows of the bubble diagram. This one is real: LPULite's softmax divider waits in IDLE, spends one clock in LOOKUP (reciprocal table × dividend), then raises done for one clock.
repo/src/lut_softmax_div.sv:19-23 (enum),
:345-358 (state register),
:360-383 (next state). Instantiated by softmax:
repo/src/softmax.sv:155. Used in LPULite:
Softmax unit .
typedef enum logic [1:0] : names for 2-bit codes. IDLE = 00, LOOKUP = 01, DONE_ST = 10.always_ff : a 2-flip-flop state register in the RTL. always_comb : the next-state logic, fed back in a loop.Each case branch is an arrow . Hover lines 366-367: the IDLE → LOOKUP arrow. Set start = 1 , then press Clock tick: IDLE → LOOKUP. Tick again: DONE_ST with done = 1 for one clock, then back to IDLE .
Modules go inside modules — LPULite's top box, and loops that stamp out copies
64 mac copies from one generate loop
2 copies of mem (u_mem0, u_mem1)
Writing mxm #(.mxm_size(MXM_SIZE)) u_mxm ( … ); puts one copy of the mxm box inside the current box. The #( ) part sets parameters — numbers baked in before the chip is built, like widths. u_mxm is this copy's name and the .pin(wire) list plugs wires into its pins. A generate for loop is run by the tool, not by the chip: it writes out N copies of its body. The whole LPULite chip is one top box, lpu, full of such copies.
Instances:
repo/src/lpu.sv:241 icu,
:315 /
:346 mem,
:499 sxm,
:528 mxm,
:613/669/683 row_fifo,
:697 vxm,
:401/416/449/462/481 buses. Loop:
lpu.sv:519-526; 64 MACs:
repo/src/mxm.sv:315-349. Used in LPULite:
MXM ,
VXM .
One instance line has 4 parts: module , #(parameters) , instance name , (pin connections) . The top box lpu holds icu, two copies of mem, sxm, three row_fifos, plus the bus blocks (westbound_bus, eastbound_bus, their decoders, mxm_eastbound_adapter — lpu.sv:401-481). mxm holds 64 MACs; vxm holds softmax, rmsnorm, quant, residual_add, vxm_rope.A generate for loop stamps out one copy per lane. Drag the loop count.
A packed struct is a bus with named bit ranges
72 wires in one westbound row
8 × 8 lanes × bits, plus 8 scale bits
typedef gives a bus shape a name so every file agrees on it; it creates no hardware. struct packed lays named fields end to end on one bus with no gaps; the first field listed gets the highest bits. LPULite's westbound row is 72 wires: the top 8 are a shared exponent (row_scale), the bottom 64 are eight signed 8-bit lanes. The real value of a lane is lane × 2^row_scale. Note: LPULite shares these names by `include-ing one file rather than using SystemVerilog's package keyword.
repo/src/lpu_pkg.sv:7-8, 15-27; scale is bits [71:64]:
lpu_pkg.sv:57;
`include "lpu_pkg.sv" at
repo/src/lpu.sv:2,
repo/src/quant.sv:2. Used in LPULite:
a row ,
buses .
One westbound row = 72 wires. typedef logic [71:0] westbound_row_t just names that shape. struct packed cuts it: row_scale = bits 71…64, packed_row = bits 63…0 (8 lanes).Meaning: value = lane × 2^row_scale. Drag row_scale : the lanes' bits don't change, the meaning does.
A testbench wiggles the inputs and checks the answer
Step time → Jump to end Back to 0
9 SystemVerilog testbenches in repo/tb/
2 clock edges of delay inside quant
Before building anything, you run the Verilog in a simulator: a program that computes every wire's value over time. A testbench is an extra module that is never built into the chip. It makes a clock (always #5 clk = ~clk), holds reset, feeds inputs to the “device under test”, waits, and checks the outputs, stopping with $fatal if wrong. The simulator can also dump every wire to a waveform viewer — the picture on the left.
repo/tb/quant_q8_8_tb.sv:17, 19, 41-76.
repo/Makefile:37-39:
make sim uses
iverilog + vvp,
make lint uses
Verilator ; board-level tests are Python
cocotb in
repo/synthesis/tests/. Used in LPULite:
how it was verified .
The testbench makes the clock and instantiates the real quant.sv as dut . Press Step time : the red cursor moves 5 ns. Hold reset for 2 edges, then release. Put 256 (= 1.0) on lane 0 with in_valid; 2 edges later out_valid rises with 32 . The check passes.
Synthesis turns text into gates, then into a real FPGA
100% 32,070 / 32,070 ALMs
87/87 DSP blocks used
10,031 registers
61% block-RAM bits
A synthesis tool (Quartus for LPULite) reads the Verilog and first builds a netlist of generic gates and flip-flops. It then maps them onto what the FPGA really has: ALMs (a small lookup table + flip-flops), DSP blocks (hard multipliers) and block RAM. A lookup table is a tiny memory whose stored bits ARE the truth table. Place-and-route picks a physical spot for each cell and the wires between them. The result is a bitstream file (.sof) loaded into the chip over JTAG; the Cyclone V forgets it at power-off, so it is reloaded after every power cycle.
Snippet:
repo/src/row_fifo.sv:17, 22, 26 (DEPTH = 4 at
repo/src/lpu.sv:615). Totals:
repo/synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.fit.summary:5-14; 10,031 = “Dedicated logic registers”
repo/synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.fit.rpt:1957 (fit.summary line 9 reports 10,074 total registers). Bitstream:
repo/synthesis/docs/microgpt_fpga_runbook.md:17,24. See
FPGA ,
LUT ,
timing .
Your Verilog : a 3-bit count register and empty = (count == 0) . Flip the count bits.Logic synthesis : 3 flip-flops and one NOR gate.Mapping : the NOR becomes a lookup table — 8 stored bits. The yellow row is the one being read.Place & route : each cell gets a spot on the grid; wires are routed between them.Bitstream : one file that sets every LUT bit and routing switch on the Cyclone V.All of LPULite went through this: 100% of logic and 100% of DSP blocks used.
Read a real LPULite file end to end: row_fifo.sv
Ports Storage Comb Flip-flops Case Write Read
51 lines in the whole file
3 copies inside lpu.sv
A FIFO (first in, first out) is a small queue. This one holds 4 rows. Writing stores data_in at mem[wr_ptr] and moves wr_ptr on; reading moves rd_ptr on, and data_out always shows mem[rd_ptr]. Both pointers wrap from 3 back to 0, so the 4 slots are used as a ring. count tracks how many rows are held, and two comparators turn it into full and empty. Every piece you have seen in this part — ports, widths, assign, always_ff with <=, case — is in these 51 lines.
Whole file:
repo/src/row_fifo.sv:1-51. Instances:
repo/src/lpu.sv:613 (256-bit),
:669 (64-bit),
:683 (32-bit), all DEPTH 4. Used in LPULite:
around the VXM .
Ports (lines 3-15): parameters DATA_W and DEPTH, then the pins.Storage (19-22): 4 rows, two pointers, a count.Comb (24-26): data_out = mem[rd_ptr] is a MUX; full/empty are comparators.Flip-flops (28-40): press Write twice and Read once — watch the pointers.Case (43-47): count goes +1, −1 or stays. Fill it: full = 1 blocks writes.
Where LPULite uses all of this
All files always_ff assign / comb generate enum case
25 .sv files in repo/src
727 lines in the top file lpu.sv
Every LPULite unit is one of these files. The construct tags were found by searching each file for always_ff, always_comb/assign, generate, typedef enum and case statements. Three files are in the folder but never placed in the chip. Click any row to jump to the page that explains what that hardware does.
File list and line counts: repo/src/ (25 files incl. westbound_bus/, eastbound_bus/). Uninstantiated modules: grep of src/ (see also FACTS_RTL.md §1.2). Tags from grep; comments may count.
wire , reg , logic are the same bits — until two things drive one
One driver Two drivers (bug)
1 driver a wire is allowed to have
X simulator's value when 2 drivers disagree
This part goes past
module basics and
assign/always_comb into writing and breaking real code.
wire is a plain connection: whatever drives it, it becomes, instantly, no memory.
reg/
logic are the same bits with a wider spelling — modern SystemVerilog just uses
logic everywhere and lets
always_ff decide if it becomes a flip-flop. What never changes: exactly
one thing may drive a given wire. Two
assign lines (or two
always blocks) targeting the same signal is a real bug class — some tools reject it at compile time, others let it through and the simulator shows the wire as
X (unknown) because two drivers disagree.
How LPULite avoids it on a shared bus: every producer writes into its own array slot , never the output wire directly — repo/src/westbound_bus/westbound_bus.sv:37-44 (producer_payloads[WB_SXM] = sxm_payload; …) — then one shared_bus_mux instance picks a single slot per cycle (westbound_bus.sv:47-57). A mux, not two drivers on one wire.
One driver : assign y = a; — a single, legal source. Flip a .Switch to two drivers (bug) : assign y = a; assign y = b; — the same wire, twice. When a ≠ b the wire has two different demands at once: real hardware shorts, simulation prints X . LPULite's fix pattern: each producer drives its own slot in an array; a MUX (not two wires tied together) picks one. Hover the bus diagram.
4 bits + 4 bits into a 4-bit box: the answer's top bit vanishes
16 max value 4 bits can hold (0…15)
30 max sum of two 4-bit numbers (15+15)
Every wire has a fixed width.
logic [3:0] y = a + b; computes the full 5-bit sum internally, then the assignment
keeps only the bottom 4 bits and throws the rest away — silently, no error, no warning from the simulator. This is
truncation . The opposite direction is safe: putting a narrow signal into a wider one
zero-extends (pads with 0 on top) for an
unsigned/plain value, or
sign-extends for a
signed one (see
bit slicing and the next page). Only widening never loses information; narrowing always can. Watch out: written exactly this way (
y,
a and
b all
[3:0]), even
Verilator 's width lint stays
silent — the narrow LHS width is propagated into the adder itself, so it computes the sum at 4 bits from the start and there is no wider intermediate value left to flag. Verified:
verilator --lint-only -Wall on exactly this module produces zero width warnings. A lint tool only catches this class of bug when the wide value is visible at the point it gets assigned — e.g. a wider intermediate signal, or a wide slice landing on a narrower port.
Real inferred bug in this repo's instruction decode, and it is the visible-wide-slice case a lint tool catches: the 96-bit VLIW carries 15-bit memory addresses but the port is 14 bits wide (MEM_ADDR_W = $clog2(16384) = 14, repo/src/lpu_pkg.sv:175-176, vs src/icu.sv:188,193: assign mem0_addr = current_instruction[28:14];) — the top address bit is dropped by assignment width (FACTS_RTL.md:176, inferred). Reproduced and confirmed with Verilator (make lint, repo/Makefile:63-64) on a minimal stand-in module: verilator --lint-only -Wall prints %Warning-WIDTHTRUNC: ⟨file⟩:2:20: Operator ASSIGNW expects 14 bits on the Assign RHS, but Assign RHS's SEL generates 15 bits. — exact text, run live, not simulated.
Drag a and b (0–15 each). The adder always computes the true 5-bit sum first. y is [3:0] (bug) : the top bit is sliced off before it reaches y. Push a+b past 15 and watch y wrap .Switch to [4:0] (fixed) : one more wire and the same sum is exact, every time. Read the bottom panel: this exact y=a+b form is invisible to Verilator's lint; the real LPULite address-truncation bug (a wide slice into a narrow port) is the shape a lint tool actually catches.
Forget signed and −5 becomes 251
−128…127 what 8 bits mean if signed
0…255 what the SAME 8 bits mean if not
The bits 11111011 never change. What they mean is entirely a declaration: as logic signed [7:0] they are −5 (two's complement — flip every bit of 5 and add 1). As plain logic [7:0] the same pattern is 251. mac.sv actually guards this twice : the ports are declared signed, and the multiply explicitly re-casts with $signed(input_i) * $signed(weight_i). That cast alone (verified in simulation) is enough to force the correct −5 × 20 = −100 even if signed had been dropped from the declaration — $signed() reinterprets the raw bits for that one expression regardless of how the variable was declared. The real bug needs both mistakes together: drop signed from the declaration and multiply the raw fields with no cast at all (input_i * weight_i) — only then do the same bits read as 251, and the product is off by thousands. This is the single most common numeric bug in fixed-point hardware — LPULite's int8 lanes are signed on purpose, and mac.sv's $signed() cast is a deliberate second line of defense against exactly this mistake.
repo/src/mac.sv:28-29:
input logic signed [INPUT_W-1:0] input_i, input logic signed [WEIGHT_W-1:0] weight_i;. The multiply:
assign product = $signed(input_i) * $signed(weight_i); mac.sv:66. Sign-extending the product into the wider accumulator:
product_ext = {{(ACC_W-PRODUCT_W){product[PRODUCT_W-1]}}, product}; mac.sv:67. Used in LPULite:
MAC ,
MXM (64 of these). Verified with
iverilog -g2012:
$signed(8'b11111011) * $signed(8'sd20) =
−100 whether or not the source variable was itself declared
signed; only removing the
$signed() cast entirely gives
5,020 .
Drag the input lane negative. Its 8 raw bits are shown — they never change. signed [7:0] (mac.sv) : those bits read as −5. Product = −5 × 20 = −100 . Correct.Switch to unsigned (bug) : the exact same bits now read as 251, and (in this mode) the multiply also drops the $signed() cast, reading the raw fields directly. Product = 251 × 20 = 5,020 . Off by 5,120 — from dropping signed and skipping the $signed() cast. Either one alone would not have been enough to break it: the MAC's real code carries both as independent safeguards.
Forget the else in always_comb and you built a latch
3 of 4 sel values handled by the buggy case
1 unhandled value → y freezes (a latch)
always_comb must promise an output for
every possible input, every time — that is what "no memory" means. Leave one
case branch unhandled (here
sel == 2'b11) and the synthesis tool cannot build plain gates for "do nothing": the only circuit that holds its previous value with no clock is a
latch (a level-sensitive loop, not the edge-triggered flip-flop from
always_ff ). This is almost always unintended and is exactly what
unique case is for: it tells the tool "I claim these are all the cases" and a lint tool will flag it if a `default` is missing and some value is left uncovered.
LPULite's real case statements always carry a
default:
unique case (ingress_mode_reg) … default: begin mux_word = regular_word; … end endcase repo/src/quant.sv:135-144; same pattern in the FSM's next-state logic,
default: next_state = IDLE; repo/src/lut_softmax_div.sv:378-380 (see
the FSM page , and
FSM coding style ).
Drag sel through 0,1,2,3. In No default (bug) , watch y for sel = 3 . sel = 3 matches no branch . always_comb can't leave y undriven, so the tool infers a latch (the looped SR gate lights up). y now holds its last value instead of computing one — a silent memory element nobody designed. Switch to unique case + default : sel = 3 now hits default, y is always driven, the latch disappears.
Reset can happen any time , or only on the clock — pick one on purpose
Async reset Sync reset d 0 en 1 Clock tick ↑ Assert rst_n now
@(posedge clk or negedge rst_n) async: rst_n in the sensitivity list
@(posedge clk) sync: rst_n only checked at the edge
always_ff @(posedge clk or negedge rst_n) means the block also wakes up the instant rst_n falls — reset acts immediately , off-clock, then the register stays in reset until the next real edge. always_ff @(posedge clk) alone checks if (!rst_n) only at the clock edge — reset has to wait for a tick to take effect. en (enable) is unrelated to reset: it's an ordinary if/else inside the block — when low, the register keeps its old value even though the clock still ticks. Async reset is simpler to guarantee at power-on; sync reset is easier to time-close at high clock speed and is glitch-free.
LPULite uses async, active-low reset throughout: always_ff @(posedge clk or negedge rst_n) with cycle_counter <= cycle_counter + 32'd1; repo/src/lpu.sv:107-113; the MAC uses async active-high : always_ff @(posedge clk or posedge rst) repo/src/mac.sv:110. row_fifo.sv:28 also gates on negedge rst_n. Enable-style gating: if (en) … else begin product_o <= '0; end mac.sv:121-133.
Set d = 1 , tick the clock: q copies d, same as the basic flip-flop . In Async mode, press Assert rst_n now mid-cycle (no tick): q drops to 0 instantly — the waveform shows it between edges. Switch to Sync , assert rst_n again: q does not move until the next Clock tick . Turn en off and tick: q holds even though the clock still ran — en is a separate hold, not a reset.
One generate for loop writes 64 copies of one MAC
N² MAC instances for an N×N array
64 MACs when N = mxm_size = 8
parameter is a number a module accepts when instantiated (#(.mxm_size(8))); localparam is a number derived inside and never overridden from outside. genvar declares a loop variable that only exists at build time — the tool runs the loop itself and writes out N separate copies of the body, each with its own wires; nothing about the loop survives into the chip. Two nested loops, r rows by c columns, is exactly how you get a grid of identical hardware units from six lines of text instead of copy-pasting sixty-four mac instantiations by hand.
genvar r, c; generate for(r=0;r<mxm_size;r++) begin: row for(c=0;c<mxm_size;c++) begin: col mac #(…) u_mac (…); assign mxm_out[r][c] = mac_accum_wire[r][c]; end end endgenerate —
repo/src/mxm.sv:315-349.
localparam int MXM_SIZE = 8; repo/src/lpu_pkg.sv:8; instantiated as
mxm #(.mxm_size(MXM_SIZE)) u_mxm (…); repo/src/lpu.sv:528 (see
instances & generate, part 1 ). Used in LPULite:
MXM ,
MAC .
The loop body is one line : an instance of mac, wired to row r's input and column c's weight. Drag mxm_size down from 8: the grid shrinks — the tool builds exactly N×N real copies, not one shared circuit reused N² times. At 8, that's 64 independent MAC circuits, each with its own accumulator register, all working the same instant — the same "every gate is always on" idea from page 1 of part A6 , at chip scale.
An array of registers becomes a real memory only if the read is synchronous
Sync read (mem.sv) Comb read (bug) Write row Read row
16,384 rows per LPULite memory, 72 bits wide
61% of the FPGA's block-RAM bits used
logic [71:0] sram_array [0:DEPTH-1] is just an array of registers — how it gets built depends entirely on how you read it. Read it inside always_ff (stream_out <= sram_array[addr], a registered read that returns data one clock later) and a synthesis tool recognizes the shape and maps the whole array onto one hard block RAM (a Cyclone V "M10K", or a SKY130 SRAM macro) — cheap and fast. Read it with always_comb instead (data appears the same cycle, no delay) and the tool usually can't use block RAM at all: it falls back to thousands of individual flip-flops and muxes — legal, but enormous. $readmemh("weights.hex", mem) is the separate, simulation/FPGA-init trick for pre-loading such an array from a text file of hex numbers at time 0.
Real code:
always_ff @(posedge clk) begin if (write_en) begin sram_array[addr] <= stream_in; end else if (ext_write_en) begin sram_array[ext_addr] <= ext_data_in; end if (read_en) begin stream_out <= sram_array[addr]; end end repo/src/mem.sv:125-135 (the
ext_write_en branch is the separate host/JTAG bypass write port; the diagram simplifies it away). Depth:
localparam int MEM_DEPTH = 16384; repo/src/lpu_pkg.sv:175. Row width 72 bits:
next page . FPGA hard-macro alternative in the same file:
altsyncram … outdata_reg_a("CLOCK0") mem.sv:80-97 (also registered). Block-RAM usage:
repo/synthesis/build/…/lpu_lite_de1_soc_hps.fit.summary:5-14. Used in LPULite:
MEM0/MEM1 .
Write row : sram_array[addr] <= data_in inside always_ff — an ordinary register write.Read row in Sync (mem.sv) : the address goes in, and the data appears one clock later — that one-cycle delay is the signature of block RAM.Switch to Comb read (bug) , read again: data appears the same cycle — looks convenient, but it can no longer map onto the chip's dedicated RAM. At 16,384 rows, that difference is thousands of flip-flops the tool has to build instead of one hard macro.
Two real bit layouts: a struct that names its fields, and one that doesn't
westbound_fixed_row_t (72 bits) 96-bit VLIW word (no struct)
72 bits: named by a packed struct
96 bits: named only by comments + slice indices
typedef struct packed { fixed_row_scale_t row_scale; fixed8_row_data_t packed_row; } westbound_fixed_row_t; lays two fields end to end with no gaps , first-listed field on top — so row.row_scale and row.packed_row are just names for bits [71:64] and [63:0]. LPULite's 96-bit instruction word does the same job the other way: it is plain logic [95:0], and every field is pulled out by hand with a slice, e.g. assign mem0_addr = current_instruction[28:14]; — no struct, no names in the type system, only comments. Both compile to identical hardware (bit position is all synthesis sees); the struct version is harder to get wrong by hand.
Struct:
repo/src/lpu_pkg.sv:24-27; row_scale = bits[71:64] via
westbound_row_scale() lpu_pkg.sv:53-58; shared via
`include "lpu_pkg.sv", e.g.
repo/src/lpu.sv:2 (LPULite uses `include, not SystemVerilog `package`). Bare-slice word:
logic [95:0] current_instruction; repo/src/icu.sv:69; field assigns
icu.sv:169-214 (e.g.
mem0_addr = current_instruction[28:14] at
:188,
mxm_start = current_instruction[48] at
:198); comment block "96-bit VLIW layout"
icu.sv:174. See
struct basics, part 1 ,
ICU .
westbound_fixed_row_t : hover a field name — its exact 8 or 64 bits light up in the 72-bit strip.Reading row.row_scale costs 0 gates : it's just "look at wires 71 down to 64", same as bit slicing . Switch to the 96-bit VLIW word : the same idea, 12 fields, but named only in a comment and 12 separate assign lines. Hover a field here too — same mechanism, no struct required, just more places a mistyped index can go unnoticed.
A queue that doesn't check full quietly overwrites data
row_fifo.sv (correct) no full-check (bug) Write (wr_en) Read (rd_en) Reset
4 rows deep, DEPTH=4
3 copies of this FIFO inside lpu.sv
A "valid/ready" handshake is the general rule for moving data between two things running at their own pace: the sender raises valid , the receiver raises ready (here, the FIFO not being full stands in for ready), and data only really transfers on a cycle where both are true. row_fifo guards this with one line: if (wr_en && !full) begin mem[wr_ptr] <= data_in; wr_ptr <= …; end — a write that arrives while full is high is simply not stored, and wr_ptr does not move. Delete the && !full guard and a write to a full queue instead overwrites row 0 (wr_ptr just wrapped there already) while count still says 4 — the oldest un-read row silently vanishes and nothing ever reports an error.
Real, correct code — the whole write/read/count logic:
repo/src/row_fifo.sv:28-49; the guard itself,
if (wr_en && !full) at
row_fifo.sv:34. Depth:
DEPTH=4, three instances in
repo/src/lpu.sv:613 (256-bit),
:669 (64-bit),
:683 (32-bit). Used in LPULite:
read the whole file, part 1 ,
around the VXM .
Press Write four times: rows fill 0→3, full lights up on the 4th. Press Write a 5th time in row_fifo.sv (correct) mode: nothing changes — the guard blocks it, count stays 4. Switch to no full-check (bug) and write again: row 0's data is overwritten — the value you wrote first is gone, silently. Now Read : you get back the corrupted row 0, not the original data. No error anywhere in the waveform.
Same state machine, two ways to type it
2-process (real lut_softmax_div.sv) 1-process rewrite start 0 Clock tick ↑
2 always blocks: state register + next-state logic
1 always_ff block in the 1-process rewrite
This is the same IDLE→LOOKUP→DONE_ST machine from
part 1 , redrawn to compare styles.
2-process (what LPULite's file actually does): an
always_ff that only ever does
state <= next_state, plus a separate
always_comb that computes
next_state and any Moore outputs from
state alone — outputs never glitch mid-cycle because they come from a clean comb block.
1-process : fold both into one
always_ff, assigning
state directly in each branch with
<=. It behaves identically here, but mixing next-state math and output timing in one block gets error-prone fast on bigger machines — most real RTL style guides require 2-process for exactly that reason.
Real 2-process file: state register
repo/src/lut_softmax_div.sv:345-358, next-state comb
:360-383, enum
:19-23. The 1-process version on this page is written for teaching and is not in the repo. Used in LPULite:
Softmax unit (instantiates it at
repo/src/softmax.sv:155).
2-process : the top block is pure state <= next_state — nothing else. Tick and watch it only ever copy.The bottom always_comb works out next_state from state and start , and never touches a register. Switch to 1-process : one block does both jobs — if (state==IDLE && start) state <= LOOKUP; …. Tick through IDLE→LOOKUP→DONE_ST in both styles: identical waveform, identical hardware — the difference is only how easy the code is to get right as it grows.
A function becomes gates. $display becomes nothing
synth function, always_comb/ff, assign, case
sim-only $display, #delay, initial (outside a tb)
A SystemVerilog function takes inputs, returns one value, and — as long as it only uses synthesizable operators — a tool inlines its body as combinational logic wherever it's called, exactly like typing the expression out by hand. A task is the same idea but can take time and have multiple outputs; real chips almost never use tasks outside testbenches. Separately, some constructs exist only for the simulator and describe no hardware at all: #5 (wait 5 time units — real gates don't "wait", they just have delay), initial begin … end (run once at time 0 — chips don't have a "time 0" script), and $display/$fatal (print to a console that doesn't exist on silicon). A synthesis tool errors or silently ignores these if you put them outside a testbench.
Real synthesizable function, clip-to-int8:
function automatic logic signed [7:0] clip_signed_q8(input logic signed [LANE_W-1:0] value); if (value > 32'sd127) clip_signed_q8 = 8'sd127; else if (value < -32'sd127) clip_signed_q8 = -8'sd127; else clip_signed_q8 = value[7:0]; endfunction —
repo/src/quant.sv:37-48, called at
quant.sv:125. Sim-only constructs used the correct way, inside a testbench:
always #5 clk = ~clk; and
$fatal(1, "…"),
repo/tb/mac_mixed_scale_tb.sv:16, 78 (see
next page ).
Drag value above 127 or below −127: clip_signed_q8 saturates it to 127 or −127 — real combinational logic, three comparators and a mux. Inside ±127 the function is a pass-through wire, zero extra gates. Look at the greyed-out lines: #5, initial, $display would be rejected (or silently dropped) by a synthesis tool — they only mean something to a simulator .
Write the test that would have caught the bug before the chip did
3 accumulate() calls checked in this real tb
PASS only if all 3 match, else $fatal stops the sim
A self-checking testbench does four things: (1) generate a
clock —
always #5 clk = ~clk; ticks forever with no external hardware; (2) hold
reset for a couple of edges, then release it; (3) drive
stimulus through a
task that packages "set these inputs, wait an edge" so the test reads like a list of operations, not raw wire wiggling; (4)
compare the DUT's (device-under-test's) output against a value you worked out by hand, and call
$fatal the instant it's wrong — never just print and continue, or a real bug scrolls past unnoticed. The exact numbers this real test checks: 2×2⁻⁴ + 3×2⁻² should accumulate to 14×2⁻⁴ regardless of which operand arrives first — the mixed-scale-order bug from
part 1's testbench page and the "consistently wrong" entry on
the bugs page .
Whole file: repo/tb/mac_mixed_scale_tb.sv. Clock: :16. Stimulus task: task automatic accumulate(…); … @(posedge clk); #1; en=1'b0; endtask :42-58. The check: if ((acc_o !== 32'sd14) || (acc_scale_o !== -8'sd4)) $fatal(1, "mixed-scale forward order failed: acc=%0d scale=%0d", acc_o, acc_scale_o); :77-79 (reverse-order re-check at :85-87). Pass message: $display("MAC_MIXED_SCALE_TEST_PASS"); :97. Run it: iverilog -g2012 -o sim.out repo/src/mac.sv repo/tb/mac_mixed_scale_tb.sv && vvp sim.out (matches repo/Makefile:54-56, VFLAGS = -g2012 -Wall -Wimplicit at Makefile:30); lint the same files with verilator --lint-only --language 1800-2017 -Wall … (VLTFLAGS at Makefile:31, lint: target at Makefile:63-64). Verified: this exact iverilog command compiles and runs clean, printing MAC_MIXED_SCALE_TEST_PASS.
Clock free-runs. Reset holds for 2 edges (grey), then releases. accumulate(2,1,-4,0) then accumulate(3,1,-2,0) — press Run next check to send them.The tb computes expected = 14, scale = −4 by hand (in the comment) and compares against acc_o/acc_scale_o. Run the reverse-order check: same two numbers, opposite arrival order — must still equal 14. Every check is green ; a real mismatch would print red and halt with $fatal, not silently continue.
Read a waveform: the cursor is on the wrong edge
Read too early (the real “sata” bug) Fixed: registered output Step cursor → Restart
0 every logit, in the buggy trace
1 clock cycle later fixes it
This is the real first bug LPULite hit on hardware. The FPGA's block RAM ("M10K") gives you data one clock
after you present an address — that's the registered-output timing shown on
the memory page . The consumer logic read the data-out wire
one edge too early : on the cycle right after the address changed, before the RAM's own register had caught up. Every weight and every logit therefore read as the RAM's power-up value,
0 . With every logit 0, "pick the biggest" always picks token 0, which decodes to the letter "a" — so the very first thing the chip ever printed was
sata instead of a real word. In a waveform viewer this is diagnosed by literally moving the cursor: put it on the edge where the
consumer samples
data_out, and check whether the RAM's own registered value has settled by then.
Symptom and fix named in the debugging log: "Block-RAM read timing: registered-output M10K sampled too early → weights and logits read as zero →
sata"
FACTS_MODEL_FPGA.md:758 (source:
lpu_debugging_and_generality_status.md:15-30; "sat → sata when every logit was zero, because token 0 is a"
FACTS_MODEL_FPGA.md:153). The registered read that fixes it:
always_ff @(posedge clk) if (read_en) stream_out <= sram_array[addr]; repo/src/mem.sv:132-134. Full writeup:
How it broke, and got fixed .
Press Step cursor repeatedly. In Read too early , the red cursor lands on the edge right after addr changes. At that edge the RAM's internal register hasn't captured the new address yet — data_out is still the old / power-up (0) value. Every logit reads 0 → argmax always picks token 0 = "a" → the printed word is sata , letter by letter, below the trace. Switch to Fixed : the cursor is placed one edge later — exactly where the registered data_out has the real value — and the printed word becomes correct.
Every construct here maps to a real file you're about to write
13 constructs covered across parts 1 + 1b
25 .sv files you'll be reading from, in repo/src
Nothing left to learn about Verilog syntax before starting the rebuild — everything from here is applying exactly these 13 ideas to bigger and bigger pieces of LPULite: a single MAC, then an 8×8 MXM built by generate, then memory with real timing, then the ICU's instruction bit-slicing, then a full FSM-driven unit like softmax, tested the way mac_mixed_scale_tb.sv was tested. Click any row below to jump straight to the LPULite unit page for that piece, or continue to the rebuild track.
wire/reg/logic + drivers → every module's ports, and westbound_bus.sv's mux.widths, signed math → mac.sv's signed [7:0] lanes — get this wrong and every MAC in the chip is wrong.always_comb, latches, case → quant.sv's mode mux; always_ff, reset → every register in lpu.sv.generate → mxm.sv's 64 MACs; arrays/memory → mem.sv's 16,384-row SRAM.structs, FSMs, FIFOs, testbenches, waveforms → lpu_pkg.sv, lut_softmax_div.sv, row_fifo.sv, and the debug flow that found sata .Ready: go build the first real module →
A computer only knows one thing: is this wire on?
0 / 1 low voltage / high voltage
256 patterns 8 wires can make
Inside every chip, a “bit” is just a wire whose voltage is either near 0 V (we call it 0) or near the supply voltage (we call it 1). Nothing in between counts. Eight wires side by side can show 2⁸ = 256 different on/off patterns, and we agree that each pattern means a number — that group is a byte. Everything a chip does is changing patterns like this, billions of times a second.
A transistor is a switch that electricity flips
3 terminals: gate, source, drain
billions on one modern chip
A MOSFET has three connections. Voltage on the gate pulls charge into a thin region under it, forming a channel that lets current flow from source to drain. No gate voltage, no channel, no current. So one wire's voltage controls whether another wire conducts — a switch with no moving parts that can flip billions of times a second. An NMOS turns on when its gate is high; a PMOS turns on when its gate is low.
Wire switches together and you get logic
4 transistors in a NAND gate
2 in a NOT gate (inverter)
CMOS logic pairs PMOS switches (pull the output up to 1) with NMOS switches (pull it down to 0). In a NAND gate the two NMOS are in series, so the output only drops to 0 when A AND B are both 1; otherwise one of the parallel PMOS pulls it to 1. NAND is “universal”: every other gate — AND, OR, XOR — can be built from NANDs alone.
From one kind of gate, build every gate
A NAND with both inputs tied together is a NOT. A NAND followed by a NOT is an AND. NOT each input then NAND them and you have OR. Four NANDs make XOR — “different?” — which is exactly the sum bit when you add two bits. Chips are built from a small library of such gates, placed millions of times.
NOT = a NAND with its inputs tied together.AND = NAND, then NOT.OR = NOT both inputs, then NAND.XOR (“are they different?”) = four NANDs.
A few gates in a row can add numbers
8 full adders for 8-bit numbers
~28 transistors per full adder (typical CMOS)
Adding two bits gives a sum bit (XOR) and a carry bit (AND). A full adder also takes the carry from the column to its right. Chain eight and you can add any two numbers from 0 to 255. The carry has to travel through every stage, which takes time — that delay is one reason chips have a maximum speed.
Eight bits, two meanings : how chips store negative numbers
−128 smallest INT8
127 largest INT8
Two's complement: the top bit is worth −128 instead of +128. That makes adding negative numbers use the exact same adder circuit — no special case. LPULite stores and multiplies INT8 numbers like this: small and cheap to multiply; it adds results up in 32-bit to avoid overflow.
Multiplying is just shifting and adding
8×8 bits in → 16 bits out
DSP FPGA's built-in fast multipliers
Binary long multiplication: for each 1-bit in the second number, add a copy of the first number shifted left. Hardware does all the shifted copies at once and adds them in a tree. Because multiplying is so common, FPGAs include hard “DSP blocks” that are pre-built fast multipliers — LPULite uses every single one on its board.
Multiply 13 × 11 in binary: 1101 × 1011 . Each 1 in the bottom number keeps a shifted copy of the top number. A 0 contributes nothing . Add the copies: 10001111 = 143 . That is the whole trick.
A chip remembers by waiting for the clock tick
100 MHz LPULite's ASIC clock target = 100M ticks/s
10,031 flip-flops in the FPGA build
A flip-flop is a tiny memory cell made of gates. It looks at its input D only at the instant the clock goes from 0 to 1, copies it to Q, and holds that value until the next tick. Every register in a chip is a row of flip-flops sharing one clock. Between ticks, logic computes the next values; the clock period must be long enough for the slowest path (like a long carry chain) to finish.
Two ways to store a bit: fast and big vs small and slow
6 transistors per SRAM bit
1+1 transistor + capacitor per DRAM bit
SRAM stores a bit in two cross-coupled inverters (plus two access switches): it holds its value as long as power is on and reads very fast, but takes lots of area. DRAM stores a bit as charge on a tiny capacitor behind one switch: far denser and cheaper, but it leaks and must be refreshed every few tens of milliseconds, and it lives on separate chips that are slower to reach. That trade-off is the heart of the LPU idea.
SRAM : two inverters feeding each other hold the bit forever (while powered).Two access switches connect it to the read/write lines. 6 transistors , very fast. DRAM : one switch and one tiny capacitor . Much denser.But the charge leaks — it must be refreshed every few tens of milliseconds, and it sits off the main chip.
A normal computer: one worker fetching from far-away memory
A CPU keeps a program counter pointing at the next instruction. Each cycle it fetches that instruction from memory, decodes what it means, executes it, and writes the result back. Data constantly crosses the gap between processor and memory — the “von Neumann bottleneck”. CPUs add caches and guess ahead (branch prediction) to hide the wait, which makes timing unpredictable.
The program counter points at the next instruction. Fetch : pull the instruction across the bus from memory.Decode : work out what it asks for.Execute and write back. Then repeat — every step crosses the memory gap .To hide the waiting, CPUs add caches and guessing . Fast on average, unpredictable in timing.
Add a fraction to an integer, for free
Q1.15 RMSNorm's reciprocal table — 16 bits
Q2.30 softmax's reciprocal — 32 bits
An
INT8 register only ever meant a whole number. Fixed point changes nothing about the wires or the adder — it changes the AGREEMENT about where the binary point sits. Write "Q
m .
f " for "
m bits before the point,
f bits after it". To read the value, take the raw integer and divide by 2^
f . Adding two Qm.f numbers is exactly ordinary integer addition — that's the whole appeal. Multiplying adds the two
f counts together, then you shift back down to whatever format you want to keep.
LPULite: RMSNorm's reciprocal-square-root table is Q1.15, "Precomputed Q1.15 reciprocal sqrt table for normalized range [1.0, 2.0)" (src/lut_rmsnorm.sv:15; same table in the live unit, src/rmsnorm.sv:48-124). Softmax's reciprocal LUT works in Q2.30, RECIP_FRAC_BITS=30 on a 32-bit word (src/softmax.sv:8; src/lut_softmax_div.sv:52-54).
Same 8 bits as INT8 — click any of them below. They start reading 107 , a plain integer.
Now drag fraction bits . A red line — the binary point — slides in from the right. The bits never move; only the reading changes.
Name it Qm .f : m bits before the point, f after. Value = raw integer ÷ 2^f .
LPULite's RMSNorm reciprocal lives in Q1.15 : 1 bit before the point, 15 after, in a 16-bit word.
Its softmax divider needs a bigger word for extra range and precision: Q2.30 , in 32 bits.
Build a floating-point number by hand
1·8·23 sign · exponent · mantissa bits
127 exponent bias
Fixed point picks ONE spot for the point across the whole range. Floating point lets the point float : it stores a sign, a normalized fraction ("mantissa", always 1.xxx), and a power of two ("exponent") that says how far to slide the point. Value = (−1)^sign × 1.mantissa × 2^(exponent − bias). The bias (127 for 32-bit) lets the stored exponent be a plain unsigned number while still representing negative powers.
IEEE 754 binary32 layout: sign 1 bit, exponent 8 bits (bias 127), mantissa 23 stored bits + 1 implicit leading 1 —
Wikipedia, IEEE 754 binary32 . LPULite's live datapath never uses this: no FP8/float anywhere in
src/; float RTL sits unused in
archive/legacy_float/ (FACTS_RTL.md §3.1). Its numbers are all block-scaled INT8.
Take 6.5 . In binary: 110.1 .
Normalize it like scientific notation — slide the point until one digit sits before it: 1.101 × 2² .
Store the exponent as true exponent + bias (127) : 2+127 = 129 = 10000001 .
Store the mantissa as the bits after that leading 1 — "101", zero-padded to 23 bits. The leading 1 itself is never stored; it's implied.
Below: all 32 bits for 6.5 are lit up. Click any bit to change it and watch the decoded value update live.
Why 0.1 + 0.2 ≠ 0.3 on almost every computer
0.1 = 0.0001100110011… binary, forever
≠0 leftover error at any finite bit count
1/3 has no exact ending in decimal: 0.333… goes forever, so any finite number of digits leaves a tiny error. 0.1 has exactly the same problem in binary — it's not a sum of powers of two, so its binary expansion repeats forever too. A 23-bit (FP32) or 52-bit (double) mantissa must cut that expansion off somewhere and round. 0.1 and 0.2 each pick up their own tiny rounding error; adding the two rounded values doesn't always land back on the value 0.3 itself rounds to.
Computed this session (Node.js, IEEE 754 double): 0.1+0.2 = 0.30000000000000004441… while the double closest to 0.3 is 0.29999999999999998890… — off by one ULP (last-place unit). In 32-bit float specifically these three round to the same neighbourhood by coincidence, but e.g. 1.1f32 + 2.2f32 = 3.30000019 vs the float32 closest to 3.3, 3.29999995 — also computed this session.
Watch the digits of 0.1 in binary appear one at a time below — computed live by repeated doubling, the same trick you'd use to convert any fraction.
Drag bits kept down to 1 or 2: the stored value is way off 0.1.
Drag it up toward 23: the error shrinks fast — but the pattern never stops repeating , so it never hits exactly zero.
Two numbers, each with their own leftover error, get added . The sum's error doesn't cancel — it's essentially a coin flip whether it lands on the rounded 0.3 or one tick away.
Every number format is a trade: range against precision
format FP32 FP16 BF16 FP8 E4M3 FP8 E5M2 INT8 INT4
More exponent bits stretch the number line further (huge range) but leave fewer mantissa bits, so each step is a coarser jump — less precision. More mantissa bits do the reverse: a narrow window, finely divided. INT8 gives up floating range entirely for one thing floats can't do cheaply: every step is exactly the same size, which makes block-scaled hardware (the next few pages) simple. FP8 pushes the trade to its limit: E4M3 keeps more precision in a narrow range (weights/activations), E5M2 keeps more range with less precision (gradients).
Push a number past its limit: wrap or stick ?
±127 LPULite's clamp on every stored lane
±2³¹−1 the MAC accumulator's saturation ceiling
An 8-bit signed register only has 256 patterns. Push its true value past +127 or below −127 and something has to give. Wraparound : the adder just keeps carrying and the top bit rolls over — +128 silently becomes −128. That's what plain two's-complement hardware does unless you stop it. Saturate : extra logic checks the result and, if it would overflow, clamps to the nearest limit instead. Wraparound is a silent bug waiting to happen; hardware for AI numbers almost always chooses to saturate on purpose.
LPULite's requantizer clamps every lane to exactly ±127, never wraps: clip_signed_q8(), src/quant.sv:37-48. Its MAC accumulator saturates to ±(2³¹−1) instead of wrapping through the sign bit: src/mac.sv:121-127.
The dial and bar below track an 8-bit signed register as its true value is dragged from −260 to +260.
Inside ±127, both modes agree.
Push past +127 in wraparound mode: the stored value jumps to −128 and keeps counting up from there — a huge, silent error.
Switch to saturate : the stored value sticks at +127 (or −127) no matter how far you drag. Wrong magnitude, right sign, no silent flip.
Inside a multiplier: a grid of AND gates, then adders
16 AND gates making the partial-product grid (4×4)
4 rows to add — one per bit of B
"Shift and add" from
the earlier multiply page is literally a grid: for a 4-bit × 4-bit multiply, every bit of A is ANDed with every bit of B — 16 tiny AND gates make 16 "partial product" bits, arranged as 4 shifted rows. Each row is A itself (shifted) if that bit of B is 1, or all zeros if it's 0. The rows are then summed with ordinary adders. This is the literal circuit; the earlier page showed the arithmetic trick, this page shows the gates that implement it.
General digital-arithmetic construction (any textbook array multiplier); LPULite's 64 MAC cells each build a 9-bit × 9-bit product this way (src/mxm.sv:30-31, MAC_OPERAND_W=9).
A = 1101 (13) is fixed along the top. B's four bits are the toggles above the figure.
Every cell in the grid is one AND gate : row i , column j = A[j] AND B[i]. 4×4 = 16 AND gates , computed all at once — no waiting.
A row where B[i]=0 is all zero — that AND gate throws the whole row away for free.
The surviving rows, each shifted one place further left, are added with plain adders (the ripple adder you already met) to get the final product.
Booth's trick: turn a run of 1-bits into two additions
0111₂=7 naive: 3 partial-product rows added
+8,−1 Booth: only 2 signed additions
Plain shift-and-add adds one row per 1-bit — a long run of 1s (like 0111 = 7) costs three separate additions. Booth's observation: 0111 = 1000 − 0001 (8 − 1). Scan the multiplier two bits at a time (current bit, and the bit to its right): "01" means a run is starting → add A here; "10" means a run just ended → subtract A here; "00" or "11" (inside a run, or all zero) → add nothing. A long run of 1s then costs exactly ONE add and ONE subtract, no matter how long it is.
Standard radix-2 Booth recoding (A.D. Booth, 1951); every modern integer multiplier (including FPGA DSP blocks, next page) uses a radix-4 variant of this idea to halve the number of partial-product rows.
B = 14 = 1110 . Naive shift-and-add: three 1-bits → three rows to add.
Drag B. Below, each bit pair is read against its right-hand neighbour and coloured: green = start of a run (add A), red = end of a run (subtract A), grey = nothing.
A run of 1s of ANY length becomes exactly one add + one subtract . Long runs (like B=14=1110, or B=7=0111) are where Booth pays off most.
Fewer rows to add means a faster, smaller adder tree downstream (next two pages) — this is a pure win, at the cost of slightly trickier control logic.
Why multiply costs so much more than add
n² partial-product bits to sum for an n×n multiply
n bits a plain n-bit add or subtract touches
An n-bit add touches n bit-positions once each — linear in n. An n×n multiply first has to build n² partial-product bits (the grid from two pages ago), then add them all . Summing a tall grid one row at a time (a "ripple" of adders) would take O(n) sequential adder stages. Real multipliers instead use a Wallace or Dadda tree : layers of "3-input, 2-output" compressors that each squash three rows into two, in parallel, so the grid's height shrinks by a third every layer — O(log n) layers instead of O(n) — leaving just two rows for one final fast adder.
Wallace (1964) and Dadda (1965) carry-save reduction trees; standard technique in every hardware multiplier, including FPGA DSP blocks and ASIC multiply cells. Gate-count scaling ~n² already appears on
the bit-width page for a plain array multiplier — this page adds WHY the summing step is also the bottleneck.
At n=8: the grid below is 8 rows × 8 columns = 64 partial-product bits to add.
Drag n up: the grid grows as n² , not n. By n=16 that's 256 bits.
A ripple-style sum (one adder row at a time) needs n sequential adder stages — slow for a wide multiplier.
A Wallace/Dadda tree compresses 3 rows → 2 rows at every layer, in parallel: the layer count grows only as log₂(n) — the bars on the right compare the two.
The chip's 87 hardened multipliers mostly sit outside the multiply array
unit MXM — 64 MAC cells VXM — RMSNorm + softmax
87 / 87 Cyclone V DSP blocks used — 100%
0 of them inside MXM's 64-cell array
An FPGA's
LUTs can build any multiplier out of generic logic — but that costs many LUTs and is slow, exactly like the grid two pages back built from ordinary gates. A "DSP block" is a small hardened multiply-accumulate circuit baked into the silicon at fabrication time: fast, tiny, free of LUT cost — if you have enough of them. Cyclone V on the DE1-SoC has only 87. The surprise: LPULite's 64-cell MAC array — the actual matrix-multiply engine — is built entirely from LUTs, using
zero DSP blocks. Nearly all 87 went instead to VXM, where RMSNorm's inverse-square-root and softmax's division need real multiply/shift hardware for their LUT-based reciprocal math.
Fitter report: "Total DSP Blocks : 87 / 87 ( 100 % )" (lpu_lite_de1_soc_hps.fit.summary, FACTS_MODEL_FPGA.md §4.1). Per-entity table, same report: MXM 0 DSP / 17,068 ALMs; VXM 86 DSP (rmsnorm 67, softmax 19, its reciprocal-divide LUT 3) / 10,911 ALMs.
The MXM tab: 64 MAC cells , each multiplying two 9-bit numbers — the literal heart of every matrix multiply on the chip.
Its DSP count: zero . All 17,068 ALMs of logic build the multipliers from LUTs instead — the Cyclone V simply doesn't have 64 DSPs to spare once the rest of the chip needs them too.
Switch to VXM: RMSNorm's reciprocal-square-root and softmax's reciprocal-divide (RMSNorm , softmax ) use 86 of the 87 DSP blocks between them.
So on this chip, "the multiply-heavy unit" and "the unit that uses the hardened multipliers" are two different blocks — a resource-budgeting choice, not a law of nature.
Summing many products needs more bits than any one of them
9 · 18 · 32 LPULite's operand · product · accumulator bits
64 MACs sharing one 32-bit accumulator per output
Multiplying two 8-bit numbers can produce a value up to 16 bits wide (e.g. −128×−128 = 16384, which needs 16 bits signed) — one multiply always needs roughly double the input width. Now add up many such products (a dot product, one MAC per input): the running total can grow by up to one more bit every time the count of terms doubles. Summing 64 products therefore needs about 16 + log₂(64) = 16 + 6 = 22 bits in the worst case, just to guarantee nothing overflows before you're done.
LPULite gives itself far more headroom than that bare minimum: each MAC operand is sign-extended to 9 bits, the product register is 18 bits (9+9), and the shared accumulator is 32 bits (src/mxm.sv:30-32 MAC_OPERAND_W=9, MAC_PRODUCT_W=18, MAC_ACC_W=32; src/mac.sv:18-19). The extra room absorbs the shifts block-scaling needs to align two products at different exponents before adding them (src/mac.sv:5-14).
One 8×8 signed multiply needs up to 16 bits to hold its product without overflow.
Drag "terms summed" up: the bar on the right — minimum accumulator bits — climbs by one every time the count doubles , not every time it grows by one.
At 64 terms that's 16 + 6 = 22 bits minimum.
LPULite's real accumulator is 32 bits — 10 bits of spare headroom above the bare minimum, used to align mismatched block scales rather than risk overflow.
Give a whole row of numbers just one exponent
reset to LPULite's own example
8 values sharing one scale — LPULite's row
32 values sharing one scale — industry MX format
Full floating point (
two pages back ) gives EVERY number its own exponent — flexible, but each exponent costs real bits per value. Block scaling is the middle ground already previewed on
the quant page : take a small block of numbers, find the one with the largest magnitude, pick ONE shared power-of-two scale from it, and store every value in the block as a small 8-bit integer relative to that one scale. One exponent, shared eight ways, is almost free.
Algorithm and worked example verified from the compiler and training code (identical in both):
absmax = max|v|;
scale = ceil(log2(absmax/127)), clamped to [−128,127];
lane = clamp(round(v·2^−scale), −127, 127) (
model/tools/compile_microgpt_lpu.py:107-113; same code,
model/microgpt.py:143-152). Industry version, verified this session: OCP Microscaling (MX) shares one 8-bit power-of-two scale (E8M0) across a block of
32 elements rather than 8 —
Rouhani et al., "Microscaling Data Formats for Deep Learning", arXiv:2310.10537 .
Eight sliders below hold LPULite's own worked example: 0.5, −1.0, 0.25, 0, 0, 0, 0, 0 .
The largest magnitude, absmax , is highlighted — here 1.0 (from −1.0).
Scale = ⌈log₂(absmax / 127)⌉ = ⌈log₂(1/127)⌉ = −6 . One number for the whole row.
Every value becomes an 8-bit integer: round(v ÷ 2⁻⁶) , clamped to ±127 → 32, −64, 16, 0, 0, 0, 0, 0 . Check: 32×2⁻⁶ = 0.5 ✓, −64×2⁻⁶ = −1.0 ✓.
Drag any slider — the scale and every 8-bit lane below recompute live, all sharing whatever the new absmax demands.
One outlier, and a shared scale punishes everyone
±0.5 max lane error, when values are similar-sized
→0 every quiet lane's precision, once one value dominates
Round-to-nearest quantization always has SOME error — at best, half a lane's worth (±0.5 in the stored integer, i.e. ±2^scale⁻¹ in real units). That's fine when every value in the block is a similar size. But the scale is picked from the block's largest magnitude. One outlier drags the scale up for ALL eight values — the seven quiet ones now round to a coarser grid too, and if the outlier is extreme enough, they all collapse to the same handful of codes: 0, ±1, ±2. Their relative error explodes even though nothing about THEM changed.
Direct consequence of the block-scale algorithm on the previous page (compile_microgpt_lpu.py:107-113): scale is a function of absmax alone, shared by every lane in the row. This is exactly why the MX spec's authors chose a smaller block (32, not the whole tensor) — the smaller the block, the fewer values one outlier can drag down (arXiv:2310.10537).
Seven quiet values (around 3-5) sit fixed on the left; one outlier on the right starts near them too.
Drag the outlier slider up. Watch the shared scale jump, and the seven quiet lanes' stored codes shrink toward zero .
The bar chart on the right shows each quiet value's relative error climbing — some approach ±100%, even though those values never moved at all.
This is why real quantizers either use small blocks (fewer victims per outlier), or clip outliers separately before choosing a scale.
Every extra bit and every extra step costs picojoules
view per operation operation vs memory
18.5× 32-bit float multiply vs 8-bit integer multiply
13,000× one DRAM access vs one 8-bit integer add
Every logic transition on a chip moves real charge and burns real energy — wider operands and floating-point's extra machinery (exponent handling, normalization) both cost more of it. The gap between the cheapest and most expensive operations here is enormous, and memory dwarfs all of it: fetching from DRAM costs roughly a thousand times more than any arithmetic on this list. That is the deep reason LPULite (and every AI accelerator) fights to keep numbers small AND keep them close to the compute.
Mark Horowitz, "Computing's Energy Problem (and what we can do about it)", ISSCC 2014, Fig. 1.1.9 — measured (his words: "rough energy costs") at 45 nm, 0.9 V, opened and read this session. Int add 8-bit 0.03 pJ / 32-bit 0.1 pJ. Int mult 8-bit 0.2 pJ / 32-bit 3.1 pJ. FP add 16-bit 0.4 pJ / 32-bit 0.9 pJ. FP mult 16-bit 1.1 pJ / 32-bit 3.7 pJ. Cache 8 KB 10 pJ / 1 MB 100 pJ. DRAM access 1.3–2.6 nJ (1,300–2,600 pJ).
The bars below are every arithmetic operation from Horowitz's ISSCC 2014 table, drawn to scale.
Integer operations are consistently the cheapest; floating point's exponent/normalization logic adds real cost at every width.
32-bit float multiply (3.7 pJ) costs about 18.5× an 8-bit integer multiply (0.2 pJ) — the same shape of gap the n² gate-count argument predicted three pages ago.
Switch to "operation vs memory": even the most expensive multiply is dwarfed by a single DRAM fetch — roughly 1,000× more energy just to move one number.
Every idea on this page, somewhere on the chip
technique Q-format saturation MAC width DSP vs LUT block scale
Nothing on this page was hypothetical: every technique is a real line of SystemVerilog inside LPULite. Click a technique above and its owning unit lights up in the floor plan below, with the exact file and line it comes from.
All citations repeated from earlier pages on this part: src/rmsnorm.sv, src/softmax.sv, src/lut_softmax_div.sv, src/quant.sv, src/mac.sv, src/mxm.sv, src/lpu_pkg.sv, FACTS_MODEL_FPGA.md §4.1 (fit report).
Q-format : RMSNorm's reciprocal table (RMSNorm ) is Q1.15; softmax's reciprocal (softmax ) is Q2.30.
Saturation : the requantizer clamps every lane to ±127; the MAC accumulator inside MAC saturates at ±(2³¹−1).
MAC width : 9-bit operands, 18-bit products, 32-bit accumulator, all inside MXM 's 64 MAC cells.
DSP vs LUT : MXM's 64 multiplies run on LUTs (0 DSP); VXM 's RMSNorm and softmax use 86 of the FPGA's 87 hardened DSP blocks.
Block scaling : every memory row and bus ("westbound ") carries 8 INT8 lanes plus one shared int8 power-of-two scale — the format the quant page first showed, built here bit by bit.
The simplest CPU: one giant slow step per instruction
4 stages every instruction pays for
1 clock cycle per instruction, always
The
CPU recap page showed fetch → decode → execute → write-back. The plainest way to build that is one giant circuit: in a single clock cycle, the program counter's address crosses the bus to memory, the instruction is decoded, the ALU runs, and the result is written back — all before the clock ticks again. It works for any instruction. The problem is the clock period: it must be long enough for the
slowest instruction's slowest stage, so a simple ADD waits exactly as long as a slow memory LOAD, every single time.
Textbook single-cycle datapath; see
A CPU in one picture for the fetch/decode/execute/write-back recap this builds on.
The program counter names instruction 0. Press Next instruction .
Fetch crosses the bus to memory — usually the slowest stage.
Decode reads the opcode and register numbers.
Execute runs the ALU; write-back stores the result and the PC steps forward.
Step through all three instructions — notice the cycle bar is the same length every time , even for the fast ADD.
Fix it like a laundromat : start the next load before this one finishes
— latency: cycles for one item
— total cycles for all of them
Washing one load at a time: wash, rinse, spin, dry, fold, then start the next — nothing overlaps. A laundromat with five stations instead overlaps them: load 2 starts washing while load 1 rinses, and so on down the line. A CPU pipeline is the same trick applied to fetch/decode/execute/memory/write-back (IF·ID·EX·MEM·WB): five instructions in flight at once, one finishing every cycle once the pipe is full. One instruction's own latency doesn't shrink — it still needs 5 cycles — but throughput approaches one instruction per cycle instead of one every 5.
Classic 5-stage RISC pipeline (Patterson & Hennessy, Computer Organization and Design ).
Toggle to serial : each load finishes completely before the next starts.
Toggle pipelined : loads overlap — machine 1 is already washing load 2 while load 1 dries.
Switch the label to CPU stages : the same grid is now IF·ID·EX·MEM·WB .
Drag instructions up — pipelined total cycles grows by 1 per instruction , not by 5.
Overlap breaks when one instruction needs the last one's answer
— bubble cycles inserted
EX→EX the forwarding path
ADD R1,R2,R3 then SUB R4,R1,R5 — SUB needs R1 before ADD has even written it back. Without help, SUB must stall : wait until ADD's result is written to the register file and SUB re-reads it, wasting cycles as bubbles. Forwarding adds extra wires straight from the ALU output (the EX/MEM latch) back into the ALU's own input mux, so SUB can use ADD's result the very next cycle — no wait for the register file at all.
RAW (read-after-write) hazard; forwarding a.k.a. bypassing — Patterson & Hennessy, ch. 4.
ADD computes R1 in its EX stage, one cycle before it reaches MEM .
SUB needs R1 as it enters EX — one cycle too early to read it from the register file.
Forwarding off : the pipeline inserts bubbles until the register file has the value.
Forwarding on : a wire carries ADD's ALU output straight into SUB's ALU input — zero bubbles .
This is exactly the EX→EX forwarding path real pipelines add in hardware.
The other break: the CPU doesn't know which way a branch goes yet
2 cycles fetched before the branch resolves
— flushed (wasted) cycles
A branch's own condition isn't known until it reaches EX — but IF already fetched two more instructions behind it, guessing "not taken" (just keep going). If that guess is wrong, those two are half-cooked and worthless: they're flushed (turned into bubbles) and the correct target is fetched instead. In a simple 5-stage pipe that's a 2-cycle penalty per misprediction — the whole reason branch prediction (next page) exists.
Control hazard / branch misprediction penalty — Patterson & Hennessy, ch. 4.
The branch enters IF . The pipeline immediately fetches the next instruction behind it, guessing.
A second instruction is fetched too, before the branch even reaches EX .
The branch resolves in EX : it was actually taken .
Both fetched instructions are wrong — they're flushed (turned red, discarded).
The real target is fetched instead, 2 cycles late.
Guess better with a tiny memory: the 2-bit saturating counter
pattern loop: T T T T T T T N alternating: T N T N Next outcome → Reset
— predictions correct so far
4 states, 2 bits per branch
One bit of history ("what happened last time?") gets fooled by a single odd outcome — a loop that runs 100 times and exits once flips a 1-bit predictor twice for nothing. A 2-bit saturating counter needs to be wrong twice in a row to change its prediction: it sits at 11 (strongly taken) through a long loop and only drifts to 10 (weakly taken) on the one exit, still predicting "taken" — and snaps right back next iteration. Real branch predictors store millions of these tiny counters, indexed by the branch's address.
Smith, "A Study of Branch Prediction Strategies", ISCA 1981 — origin of the 2-bit saturating counter.
Four states in a ring: 00 strongly-not-taken → 01 weakly-NT → 10 weakly-taken → 11 strongly-taken.
Predict taken when the state's top bit is 1 (states 10 or 11), else not-taken .
Each real outcome moves the counter one step toward the correct side — never jumping straight across.
Press Next outcome through the loop pattern: the lone N only nudges it to 10, still predicting taken.
Try alternating : the counter oscillates between the two weak states and is right only about half the time — no better than a coin flip.
Memory is 200× slower than the chip — so keep a copy nearby
0.5 ns L1 cache reference
100 ns main memory (DRAM) reference
— hits so far
— misses so far
Programs reread the same handful of addresses constantly (temporal locality) and touch nearby addresses together (spatial locality — one array scan). A cache exploits both: it's a small, fast copy of a few memory lines. A direct-mapped cache splits an address into a tag (which block), index (which cache line it's allowed to live in) and offset (which byte). Each index has exactly one line, so a new address with the same index evicts whatever was there — cheap hardware, but two hot addresses that collide on the same index will fight (a "conflict miss").
An 8-line cache, 4 bytes per line: address bits split into tag | index (3 bits) | offset (2 bits) .
Press Next access : the address's index picks one of the 8 lines — the only place it's allowed to be.
If that line's stored tag matches → hit , 0.5 ns. If not → miss , fetch from memory (~100 ns) and evict.
Watch address 4 hit on its very next visit — temporal locality paying off (it misses again later, once its line gets evicted below).
Watch two addresses that share an index keep evicting each other — a conflict miss , the cost of "direct-mapped".
Not one cache — a hierarchy , each ring bigger and slower
source measured (Skylake) rule-of-thumb (~2012)
32 KB L1 data cache, per core
8 MB L3 cache, shared
Bigger memory is always slower to reach, so real chips stack several: a tiny fast L1 next to each core, a bigger shared L2/L3, then DRAM across the memory bus. On a measured Intel Skylake core, L1 answers in about 4 cycles, L2 in 12, L3 in 42, and a real DRAM miss costs those 42 cycles plus another ~51 ns waiting on the memory chips themselves — call it roughly 1 ns → 3 ns → 10 ns → 60+ ns end to end. Nothing here is free: bigger and further apart always costs cycles.
L1 : 32 KB, ~4 cycles (~1 ns at 4 GHz) — small enough to check almost instantly.
L2 : 256 KB, ~12 cycles — bigger, one hop further from the core.
L3 : 8 MB, shared by all cores, ~42 cycles.
DRAM : off-chip. Add ~51 ns beyond L3 — a full-blown memory access is ~100× an L1 hit .
Same maths, same answer — 3 different loop orders, 3 different speeds
ijk ikj tiled (blocked) Play
— distinct B cache-lines touched
— loop order
C = A×B, three nested loops i/j/k, same 3 statements — but which loop is innermost decides how memory is walked. ijk reads B column-by-column: each step jumps a whole row's width, a cache line thrown away almost every read. Reorder to ikj (k in the middle) and B is read row-by-row — each cache line reused for several steps before moving on. Tiling (blocking) goes further: process a 4×4 sub-block of A, B and C at a time, small enough that all three stay resident in L1 while it's reused, then move to the next block.
Cache blocking / loop tiling — standard technique, e.g. Hennessy & Patterson, Computer Architecture: A Quantitative Approach , ch. 2 (memory hierarchy optimizations).
ijk : for each output cell, walk straight down a column of B — a big stride every step.
Press Play : watch the highlighted B-cell jump line to line. Most reads are misses .
Switch to ikj : the inner loop now walks a row of B — consecutive addresses, same cache line reused.
Switch to tiled : a small block of B is read once and reused for a whole block of outputs before moving on.
The distinct cache-lines touched stat drops sharply — same answer, far less memory traffic.
Stop doing it one number at a time — do 8 (or 16) at once
lane width SSE · 4×32-bit AVX · 8×32-bit AVX-512 · 16×32-bit Multiply lanes
— instructions for a 64-number dot product
256-bit one AVX YMM register
A scalar ALU multiplies one pair of numbers per instruction. A SIMD ("single instruction, multiple data") unit packs several numbers into one wide register and multiplies all the lane-pairs in one instruction — Intel's AVX YMM registers are 256 bits wide, which is exactly eight 32-bit floats; AVX-512's ZMM registers hold sixteen. A dot product (multiply each pair, then sum) is the perfect fit: it's the exact operation SIMD lanes do in parallel, then a short "horizontal add" tree combines the lanes — which is also most of what an AI matrix multiply is built from.
AVX register width and lane count:
Advanced Vector Extensions (256-bit YMM = eight 32-bit floats; AVX-512 ZMM = 512-bit = sixteen), per Intel's ISA documentation.
Two arrays of numbers, 64 entries each, need pairwise multiply-then-add — a dot product .
Scalar: one multiply per instruction — 64 instructions just for the multiplies.
Switch to AVX (8 lanes) : one instruction multiplies 8 pairs at once — 8 instructions total.
Switch to AVX-512 (16 lanes) : 4 instructions . Same answer, far fewer instructions issued.
More cores help less than you'd think — the leftover serial part wins
— speedup with N cores
— speedup even with ∞ cores
Split a program into a fraction P that parallelizes across cores and a leftover (1−P) that's stuck serial (setup, a shared lock, merging results). With N cores, time falls to (1−P) + P/N of the original — so speedup is 1 / ((1−P) + P/N) . Even at P = 90%, going from 1 core to infinite cores only buys 10× — the 10% serial slice becomes the whole bottleneck. This is why real multicore chips (and multi-chip AI clusters) obsess over shrinking that serial fraction, not just adding cores.
G. Amdahl, "Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities", AFIPS Spring Joint Computer Conference, 1967.
Drag fraction parallelizable — the rest is stuck serial , no matter how many cores exist.
Drag cores from 1 up to 256 (the slider steps in powers of 2).
The bar splits into a shrinking parallel part and a fixed serial part — speedup tracks the whole bar, not just the parallel piece.
Push cores high: speedup flattens well short of the core count. That ceiling is 1/(1−P) .
Don't let one slow instruction block everyone behind it
— cycle all 3 instructions finish
ROB reorder buffer
In strict program order, a slow instruction (a cache-missing load) stalls the pipeline even if an independent instruction right behind it is ready to go — it waits anyway. Out-of-order execution lets independent instructions issue as soon as their inputs are ready, skipping ahead of the stalled one. To keep the illusion of running in order (so results and exceptions land correctly), finished instructions sit in a reorder buffer and only commit — write their result where the program can see it — in the original order.
Tomasulo-style out-of-order execution with a reorder buffer — standard architecture technique, Patterson & Hennessy, ch. 4/App. C.
Instruction 2 is a slow load — it will take 4 cycles to get its data.
In-order : instruction 3, though ready, must wait behind it.
Toggle to OoO : instruction 3 issues and finishes early , out of program order.
All three still commit (become visible) in the original 1-2-3 order via the reorder buffer.
All of that cleverness stops helping the moment the "loop body" is 7 billion numbers
model 1B params 7B params 70B params stored as fp16 (2 B) int8 (1 B)
memory 1 channel DDR5-4800 2 channel DDR5-4800 GPU HBM3 (H100)
— bytes that must move per token
— tokens / second, memory-bound
Writing one
token means every one of the model's weights is read once — a 7B-parameter model in fp16 is 14 GB, none of which fits in a multi-
megabyte L1/L2/L3 (this part's earlier pages). So the caches, forwarding, branch prediction and out-of-order engine built to hide a ~100 ns DRAM latency are irrelevant: there's no reuse to cache, nothing to predict, no stall to hide around. Speed is simply
bytes ÷ memory bandwidth — see the
memory wall page.
Pick a model size — its weights, at 2 bytes each, must all cross the memory bus for one token.
Pick a memory: a single DDR5-4800 channel delivers 38.4 GB/s — a JEDEC number, not marketing.
tokens/second = bandwidth ÷ bytes-per-token . A 7B model on 1 channel: a few tokens per second, flat out.
Switch to int8 (half the bytes) or GPU HBM3 (~87× the bandwidth) and watch the number jump.
Either way — this is why AI accelerators chase bandwidth , not clever caching.
Everything that makes a CPU fast also makes it unpredictable
ordinary CPU LPULite-style Run it again
— cycle-count jitter across 2 runs
±0 LPULite jitter, by design
Every idea in this part — caching, branch prediction, out-of-order issue — is a
guess that pays off on average but makes exact timing depend on runtime state: was that address cached? was the branch mispredicted? So the same CPU run twice can take a different number of cycles. LPULite makes the opposite bet, following
Groq's design : no caches, no branch predictor, no guessing at all — a compiler decides every unit's schedule ahead of time and writes it into a
96-bit VLIW instruction that the
ICU just counts through. Same input, same cycle count, every single run.
Ordinary CPU : press Run it again twice — cache hits/misses and branch guesses differ each time, so cycle counts jitter .
That jitter is invisible in throughput benchmarks but deadly for anything needing an exact deadline.
Switch to LPULite-style : the schedule was fixed at compile time , not guessed at run time.
Run it again — identical cycle count, every time. No cache, no predictor, nothing to be wrong about.
That's the trade this whole part has been building to: guess well, on average — or never guess, exactly .
One neuron: multiply, add, then squash
— z = Σ(wᵢxᵢ) + b
— output = ReLU(z)
A neuron is three numbers turned into one: multiply each input by its own weight, add a bias, then pass the sum through an activation function. ReLU is the simplest one — it passes positive numbers through unchanged and clamps negatives to zero. Every layer in a neural network is thousands of these running in parallel, each with its own weights. This exact "multiply, accumulate, then activate" shape is one MAC (multiply-accumulate) cell in real hardware, run 64 times per token inside MicroGPT's mlp_fc1 layer — except MicroGPT's linear layers carry no bias term at all .
model/microgpt.py:139-141 (linear(), no bias) ·
:207 (ReLU) · Used in LPULite:
the MAC cell
Three inputs x₀, x₁, x₂ arrive on wires. Each is multiplied by its own weight. The three products are added together with a bias → z . ReLU(z) : if z > 0, output z; otherwise output 0.Drag the sliders — watch z and the output number change live.
A layer is just a matrix times a vector
6 multiplies (2 rows × 3 cols)
2 output numbers
A "layer" with 2 outputs and 3 inputs is a 2×3 matrix W. Each output row is its own neuron: dot the row against the input vector, one multiply per matching pair, then add them up (no activation shown here — that comes after). A 2×3 matrix costs 6 multiplies. MicroGPT's Q, K and V projections are each a 16×16 matrix against a 16-number input: 256 multiplies each, exactly the count of weights in that matrix — the multiply count of a matmul is always rows × cols.
Learning is sliding downhill on a loss curve
— loss = (w·x − target)²
— slope dLoss/dw
Pick one toy weight w predicting w·x against a fixed target; the loss (w·x − target)² is a parabola. The slope of that curve at the current w tells you which way is downhill and how steeply. A gradient step moves w a little in the downhill direction: w ← w − learning_rate × slope. Do that over and over and the dot slides to the bottom, where the slope is zero. MicroGPT doesn't use this plain rule — it uses Adam, which keeps a running average of the slope and its square before stepping — but the "follow the slope downhill" idea underneath is identical.
model/microgpt.py:215 (Adam: lr=0.01, β1=0.85, β2=0.99) · :245-253 (the update)
The curve is loss(w) for a fixed toy input and target. The dot marks the current w and its loss. The line through it is the slope — how loss changes as w changes. Press one gradient step : w moves opposite the slope, scaled by a learning rate. Repeat — the dot walks down to the minimum.
Backprop: the chain rule, one node at a time
A computation is a graph: numbers in, an operation at each node, one number out. The forward pass fills in every node's value, left to right. The backward pass runs right to left: the output's gradient is 1 by definition, and each node hands its incoming gradient to its children, multiplied by that operation's own local derivative (1 for a sum — the gradient just passes through — or the other input's value for a product). MicroGPT is trained by exactly this mechanism — a tiny Value class recording every operation's children and local gradients, then walking the graph in reverse.
model/microgpt.py:75-102 (Value: __add__, __mul__ store local_grads) · :104-117 (backward(): topological sort, then reverse)
Training carries three extra copies that inference doesn't
1× weight-sized buffers needed
Inference needs the weights and enough scratch space for one token's activations, then can throw that scratch away. Training needs all of that, plus: the gradient of every weight (same size as the weights), and — because MicroGPT uses Adam — two more same-sized buffers, m and v, tracking a running average of each gradient and its square. It also can't discard activations early, because the backward pass needs them to compute those gradients. Numbers here are relative multiples of the weight footprint, not measured bytes.
model/microgpt.py:216-217 (m = [0.0]*len(params), v = [0.0]*len(params)) · :104-117 (graph kept for backward) (relative, illustrative)
Inference : weights + one token's activations. Nothing else is kept.Training adds the gradient of every weight — one more weight-sized buffer.Adam adds m and v — two more weight-sized buffers, per-parameter. Activations from the forward pass must be kept , not discarded, until backward finishes with them. Toggle the switch — the bars show what's carried in each mode.
A character becomes a row of numbers
27 tokens: a–z + BOS
16 numbers per row (n_embd)
A neural network can't read letters, only numbers. A tokenizer assigns each character an integer id — MicroGPT's vocabulary is exactly a–z sorted alphabetically (ids 0–25) plus one special "beginning/end" token, BOS, at id 26. A 27×16 embedding table (wte) then gives every id its own row of 16 learned numbers — the id is nothing but a row lookup, not a computation. A learned position table (wpe) supplies a second row per position 0–15, added on top, so the model also knows *where* in the word a character sits.
model/microgpt.py:69-71 (vocab: uchars sorted + BOS) · :126 (wte, wpe both 16 wide) · :177-179 (lookup + add) · checkpoint: characters="a..z", bos_token_id=26 (values illustrative — real trained weights not loaded here)
Click a letter (or BOS) below the figure. Its token id is just its sorted position — a, b, c… → 0, 1, 2… That id picks out one row of the 27×16 token-embedding table. The model also adds a position row for where the character sits in the word.
Averaging the past is dumb — attention weighs it
3 toy tokens, 2 numbers each
Σ=1 softmax weights always sum to 1
A plain average of every past token treats a relevant word and an irrelevant one identically — that's the failure. Attention instead computes, per token, a Query (what am I looking for), a Key (what do I contain) and a Value (what do I offer). The dot product Q·K scores how well each past token matches the current query; a causal mask blocks positions that haven't happened yet by forcing their score to −∞; softmax turns the surviving scores into weights that sum to 1; the output is the weighted sum of every Value. Real head dimension on LPULite is 4, not 2 — kept small here so every number is checkable by hand.
mechanism:
model/microgpt.py:191-200 (Q·Kᵀ/√d, softmax, weighted V) · numbers here are illustrative (dim 2, not MicroGPT's real weights) · Used in LPULite:
attention on the chip ,
softmax unit
A plain average weighs every past token the same — it can't tell relevant from irrelevant . Compute Q for the current position, and K, V for every position up to and including it. Score each: Q · Kᵀ — one number per visible position. Causal mask : positions after the current one score −∞ (greyed out).Softmax the visible scores → weights that sum to 1.Output = the weighted sum of the Values — a mix the model actually chose.
Four narrow attentions beat one wide one
4 × 4 heads × head_dim = 16
64 MLP hidden width at ×4
MicroGPT's 16-wide Q, K, V are sliced into 4 heads of 4 numbers each; each head runs its own small attention (previous page) independently, then the 4 outputs are concatenated back to 16 and passed through an output projection attn_wo. The result is added back to the input (a residual connection — the model only has to learn the *change*, not re-derive the whole vector) and normalised by RMSNorm: divide every number by the root-mean-square of the row. Then an MLP widens to 4× (64), applies ReLU, and narrows back — with its own residual add.
model/microgpt.py:191-201 (heads, concat, attn_wo) ·
:202 (residual) ·
:171-174 (rmsnorm) ·
:132-133,204-209 (MLP 4×, ReLU, residual) · Used in LPULite:
RMSNorm unit
Split Q, K, V into 4 heads of 4 numbers each. Each head runs attention independently — different relevance per head. Concatenate the 4 head outputs back to 16, then project (attn_wo).Residual add : output = projection + original input.RMSNorm : rescale by 1/√(mean of squares).MLP: widen ×4 → ReLU → narrow back, another residual add.
One transformer block — then stack it
1 MicroGPT layer (n_layer)
32 Llama-3-8B layers
One block is: attention sub-layer with its residual add, then MLP sub-layer with its own residual add. Stacking N of these, each with its own separate weights, is literally the entire architectural difference between a tiny model and a huge one — MicroGPT's training loop runs this block just n_layer=1 time per token; a real 8-billion-parameter model runs 32 blocks, each 256× wider (hidden size 4096 vs MicroGPT's 16), in sequence. Nothing about the block's *shape* changes; only how many times it repeats, and how wide each one is.
model/microgpt.py:120,127-133,182-209 (n_layer=1, block body, loop) · Llama-3-8B: num_hidden_layers: 32, config.json, huggingface.co/NousResearch/Meta-Llama-3-8B (mirror of meta-llama/Meta-Llama-3-8B)
Attention sub-layer: normalise → attend → project → + residual . MLP sub-layer: normalise → widen → ReLU → narrow → + residual . That whole box is one block . Drag the slider: stack more blocks, each with its own weights. MicroGPT stops at 1. Llama-3-8B stacks 32.
Logits → softmax → pick → append → repeat
1 token out per full model pass
This is the loop every autoregressive model runs to produce text: the model outputs one score (a "logit") per vocabulary entry; softmax turns scores into probabilities; a next token is picked — always the top one ("greedy"), or randomly according to the probabilities ("sample", sharpened or flattened by a temperature); the chosen token is appended to the sequence and fed back in as the new input. MicroGPT's board demo always picks greedy (argmax) — this toy distribution is generic, not MicroGPT's real logits.
mechanism: model/microgpt.py:165-169 (softmax) · greedy pick on the board: synthesis/linux/src/microgpt_hps_runtime.c:1294-1304 (greedy_next(): argmax over 27 scores) · logits shown here are illustrative, not measured
Recomputing every past K and V is wasted work
36 B bytes / token (MicroGPT)
— cache so far
Every past token's Key and Value are needed for every future token's attention — without a cache, generating character N would redo all N-1 earlier K/V computations from scratch, work growing with every step. A KV cache stores each token's K and V the moment they're computed, so later steps just read them back. Bytes per token = 2 (K and V) × layers × width × bytes-per-number. MicroGPT: 2 × 1 layer × 16 numbers × 1 byte = 32 B, plus ~4 B of LPULite's shared block-scales = 36 B/token . Llama-3-8B, using its smaller key/value head count (grouped-query attention): 2 × 32 layers × 8 KV-heads × 128 head-dim × 2 bytes (bf16) = 128 KiB/token — over 3,600× more per token, because it's both deeper and wider.
model/microgpt.py:189-190,195-196 (keys[li].append(k), full 16-wide K/V) · synthesis/docs/lpu_attention_runtime_and_performance_audit.md:237-246 (K cache in MEM0, V cache in MEM1) · row packing: FACTS_MODEL_FPGA.md §2.2 (8 lanes + 1 scale per 72-bit row) · Llama-3-8B: num_hidden_layers=32, num_key_value_heads=8, hidden_size=4096, num_attention_heads=32 → head_dim=128, config.json, huggingface.co/NousResearch/Meta-Llama-3-8B, torch_dtype: bfloat16
Without a cache: token N recomputes K,V for every earlier position, again. With a cache: each K,V is computed once and stored the moment it's made. Formula: bytes/token = 2 × layers × width × bytes-per-number . MicroGPT: 2 × 1 × 16 × 1 byte ≈ 36 B/token (with LPULite's scale bytes). Llama-3-8B: 2 × 32 × 8 × 128 × 2 bytes = 128 KiB/token . Drag "tokens generated" — the cache grows linearly, one slice per token.
Reading the prompt is parallel ; writing new text is one at a time
compute-bound what limits prefill
memory-bound what limits decode
Prefill processes every prompt token's matrix multiplies together — lots of arithmetic per weight read, so it's limited by how fast the chip can multiply. Decode produces one new token at a time: each step must still read every weight at least once (nothing to share across positions), but does comparatively little math with each one — so it's limited by how fast weights can be *read*, not computed. On LPULite this is literal: even the 3-character prompt "sat" costs one full model pass per token — including the BOS token every sequence starts with — 4 passes total (prefill), then each generated letter is one more full pass (decode) — there's no batching to amortise the read.
Prefill : every prompt position's matmuls run together — heavy on arithmetic.More arithmetic per weight byte read → compute-bound . Decode : one new token, one more full pass — nothing to share with earlier steps.Every weight must still be read, for very little new arithmetic → memory-bound . Toggle the switch to compare the two shapes of work.
Three numbers price out every model
4,192 parameters
≈8,384 multiply+add / token
≈4.7 KB weight bytes / token, min.
Parameters count how much the model knows. Multiplies-per-token is close to the parameter count, because a matmul's multiply count equals rows × cols — the weight count of that matmul; total arithmetic (multiply + add) is commonly approximated as 2 × params per token, a rule that holds well once a model is big enough that attention's own extra math (scaling with context length, not parameter count) is a rounding error. Bytes moved per token, in the worst case with no batching, is at least the weight bytes themselves — every weight has to be read to be used. MicroGPT's own exact multiply count in its matmuls (excluding embedding lookups) is 3,504 — close to, not exactly, the 2×params shortcut.
MicroGPT: model/microgpt.py:134-135 (4,192 params, printed) · exact matmul multiplies = wq+wk+wv+wo+mlp_fc1+mlp_fc2+lm_head = 1,024+1,024+1,024+432 = 3,504 (:128-133) · weight bytes: 4,192 lanes + 524 scales, FACTS_MODEL_FPGA.md §2.2 · 2×params rule: widely used inference-FLOPs approximation, e.g. Kaplan et al. 2020, arXiv:2001.08361 (approx., illustrative) · Llama-3-8B: 8×10⁹ params, ai.meta.com/blog/meta-llama-3 ("8B and 70B parameters"); bf16 → 2 bytes/param
Parameters : MicroGPT 4,192 · Llama-3-8B 8 billion.Multiplies/token ≈ the weight count of the matmuls it runs.Total arithmetic (×++) is commonly approximated as 2 × params /token. Bytes moved/token , minimum: every weight byte must be read once.Toggle the model — the same three formulas, radically different scale.
An AI model is billions of multiply-and-adds
w·x one “neuron” = a dot product
MAC multiply–accumulate: the hardware for it
A layer of a neural network takes a list of numbers and, for each output, multiplies every input by a learned weight and adds the results. That is a dot product. Stack them into a grid and it is a matrix multiply. A language model does this with millions or billions of weights for every token it writes. So an AI chip is, first of all, a machine for doing multiply-accumulate (MAC) extremely many times.
Four inputs arrive: x . Each is multiplied by its own learned weight w . The products are added up (accumulated). That's one output. Do this for every output row → a matrix multiply . That is most of an AI model.
A language model writes one token , then runs again
1 token per full model pass (decode)
KV cache: remembered keys & values of past tokens
Generation is a loop. The model reads everything so far and predicts the next token (a character, in LPULite's tiny model). That token is appended and the model runs again. Reading the prompt the first time is “prefill”; producing each new token is “decode”. To avoid redoing old work, each layer keeps the keys and values it computed for past tokens — the KV cache.
The real bottleneck: moving the weights , not doing the maths
model numbers stored as 16-bit 8-bit
memory speed
To write one token, every weight of the model is read once. If the model is 140 GB, 140 GB must flow from memory to the processor for each token. The processor can usually multiply faster than memory can deliver the numbers — so token speed is roughly memory bandwidth divided by model size. This is the “memory wall”, and it is why the LPU idea puts fast memory right next to the compute.
Three bets: a few smart workers , thousands of simple ones , or an assembly line
A CPU has a few powerful cores that handle any program, with caches and prediction to hide slow memory. A GPU has thousands of simpler cores that do the same maths on lots of data at once, fed from very fast stacked memory. An LPU-style design removes the guessing altogether: a compiler plans every data movement ahead of time, so the chip runs like a fixed assembly line with predictable timing.
CPU : a few clever cores, caches, guesses. Great at anything, not at bulk maths.GPU : thousands of simple cores doing the same maths in parallel.LPU-style : no guessing — a compiler plans every move; the chip follows the plan, tick by tick.
Groq's idea: fast memory on the chip , and a plan decided in advance
A GPU keeps model weights in big, separate memory stacks and ferries them in. Groq's LPU keeps weights in SRAM on the chip — fast, but only ~230 MB per chip. A compiler schedules every operation ahead of time; no caches, no guessing. So big models are spread across hundreds of chips passing data along.
Groq's chip: instructions flow north , data flows east and west
900 MHz first chip's clock (14 nm)
220 MiB on-chip SRAM
<3% of area for instruction control
<49 µs ResNet-50 image, batch 1
Instead of many identical cores, Groq's 2020 Tensor Streaming Processor slices the chip by job: instruction control (ICU), memory (MEM), vector maths (VXM), matrix maths (MXM), data switching (SXM) and chip-to-chip links. Instructions travel north from the ICU into each slice; data streams travel east and west across the slices in parallel “lanes”. Work happens where an instruction crosses its data. There are no caches or arbiters, so every run takes exactly the same time. LPULite borrows this vocabulary and structure at toy scale.
The chip is sliced by function , not into identical cores. Instructions flow north from the ICU into every slice.Data streams flow east and west across slices, in parallel lanes .Computation happens where an instruction crosses its data. No caches → same timing every run.
Squeeze every number into 8 bits — with a shared scale
32→8 bits per number
~3.6× smaller than 32-bit, counting the scale
Big models usually train with 16- or 32-bit decimal numbers. For running a model, most of that precision is unnecessary. LPULite groups weights in blocks of 8, picks one power-of-two scale for the whole block, and stores each value as an 8-bit integer. To use a value, multiply the integer by the scale. Power-of-two scales are cheap in hardware: multiplying by 2ⁿ is just shifting wires.
Why 8-bit maths makes a chip so much smaller
64 partial-product bits for 8×8
1,024 for 32×32 — 16× more
Long multiplication in hardware makes one row of partial products per bit of the second number, each as wide as the first number: n × n little AND gates, then adders to sum them. So a plain multiplier's size grows roughly with the square of the bit width (a rule of thumb — real designs use tricks). Going from 32-bit to 8-bit numbers cuts that by about 16× and memory by 4×. That is why AI chips — and LPULite — live on INT8.
Two ways to build a chip: rewire a board or cast your own silicon
An FPGA is a chip full of small programmable lookup tables, flip-flops, memory blocks and multipliers, with programmable wiring between them. Load a bitstream and it becomes your circuit; load another and it becomes something else — perfect for students and prototypes, but slower and less efficient. An ASIC is a chip manufactured for exactly one design: fastest and most efficient, but it costs a fortune and months to make, and can't be changed. LPULite runs for real on an FPGA and has an ASIC layout as a showcase.
FPGA : a grid of tiny programmable blocks — lookup tables, flip-flops, memory, multipliers.Programmable wiring connects them. A bitstream sets every switch. ASIC : the circuit is printed permanently into silicon.FPGA: change anytime, slower. ASIC: fastest, but costly and fixed forever .
How an FPGA becomes any circuit : a tiny truth table in memory
32,070 logic modules (ALMs) on LPULite's FPGA
2ⁿ bits store any n-input function
An FPGA doesn't have gates you wire up by hand. It has lookup tables: a few memory bits plus a selector. The inputs pick which bit comes out. Store 0,0,0,1 and it behaves as AND; store 0,1,1,0 and it's XOR. Real FPGA logic modules use 6-input tables (64 bits) with flip-flops beside them, and the bitstream also sets the programmable wires between thousands of them.
Same chip budget, opposite bet: control or arithmetic
80B transistors, H100 die (TSMC 4N)
16,896 FP32 CUDA cores, H100 SXM5
A CPU core spends most of its area on things that make ONE instruction stream fast when it doesn't know what's coming next: big caches, branch prediction, out-of-order scheduling. A GPU throws almost all of that away. It keeps thousands of tiny arithmetic units and shares ONE instruction stream across a whole group of them — cheap per-core control because the cores next to each other are doing the identical operation. The drawing below is illustrative (not a real floorplan); the counts on the right are real.
A CPU core: a little arithmetic, a lot of cache + branch-prediction + scheduling logic around it. A GPU core (Nvidia calls it a CUDA core): almost nothing but an adder/multiplier. One instruction fetch/decode is shared by a whole group of GPU cores — they all run the same instruction. Drag the slider: pack more, smaller cores into the same area — each one gets less control logic, but there are far more of them.
One line of code, run by 65,536 threads at once
threads per block (blockDim) 4×4 8×8 16×16
32 threads per warp — run in lockstep
64 max warps resident per SM (H100)
A kernel is the function that runs on the GPU — here, C[row][col] = A[row][col] + B[row][col]. You launch it once, over a grid of thread blocks ; each block is a group of threads , each thread gets its own (blockIdx, threadIdx) and computes one output element. Hardware groups every 32 consecutive threads into a warp that executes the same instruction together (SIMT). Click a cell of the 16×16 matrix below to see its coordinates.
Weights live in HBM , a PCIe hop away from the CPU's DDR
compare H100 (data-centre) RTX 4090 (desktop)
3.35 TB/s H100 SXM HBM3, 80 GB
4.8 TB/s H200 SXM HBM3e, 141 GB
128 GB/s PCIe Gen5 x16, GPU↔host
~1.0 TB/s RTX 4090 GDDR6X, 24 GB
Data-centre GPUs stack HBM memory dies directly on the package next to the compute die — short, very wide connections. Desktop GPUs use GDDR on the board around the chip — cheaper, narrower, a few times slower. Either way, the GPU is a PCIe hop away from the host's plain DDR memory (where the OS and CPU program live): every byte that has to cross that link runs at PCIe speed, roughly 25–40× slower than HBM.
The GPU die sits on a package next to stacked HBM dies, or on a board next to GDDR chips. A PCIe link (128 GB/s on Gen5 ×16) connects the GPU package to the host. The host's own DDR memory holds the OS, the framework, everything not currently on the GPU. Toggle H100 vs RTX 4090: the data-centre part gets ~3–5× the memory bandwidth of the desktop part.
cuBLAS is fast because it reuses every number it loads
~19 TB/s A100 on-chip SRAM (est., 20MB total)
1.5–2 TB/s A100 HBM — the bottleneck
Naively, computing C = A×B re-reads a whole row of A and column of B from slow HBM for every single output. cuBLAS instead loads small square tiles of A and B into fast on-chip shared memory once, then does every multiply-add those tiles support before moving on — each loaded number is reused up to tile-size times. That trades HBM traffic for on-chip traffic, and on-chip memory is roughly 10× faster.
Naive: for every output cell, stream a full row of A and column of B from HBM . Tiled : load one small tile of A and one of B into on-chip memory, once .Compute every partial product those two tiles support before loading the next tile. Drag the slider: a bigger tile means each loaded number does more work before it's thrown away.
Decode uses 0.2% of the chip; prefill uses almost all of it
591 FLOP/byte ridge point (1,979 TFLOPS ÷ 3.35 TB/s)
~16.6 ms compute-bound: prefill 2,048 tokens at once
The roofline model caps achievable throughput at min(peak FLOPs/s, bandwidth × operational intensity) — low-intensity work is bandwidth-bound (flat diagonal roof), high-intensity work is compute-bound (flat ceiling). Reading one Llama-3-8B's ~16GB of bf16 weights to produce one token does about 2×8B=16GFLOP — 1 FLOP/byte, far left of H100's 591 ridge point. Batching B requests lets B tokens share each weight read: intensity ≈ B FLOP/byte, so the point slides right as you drag.
H100's ridge point : 1,979 TFLOPS ÷ 3.35 TB/s ≈ 591 FLOP/byte . Decode , batch 1: ~16GB read, ~16GFLOP done → 1 FLOP/byte . Deep in the bandwidth-bound region.Prefill , 2,048-token prompt: weights reused 2,048× → intensity 2,048 FLOP/byte, past the ridge → compute-bound , near peak.Drag decode's batch up past 591 : the point crosses the ridge and decode becomes compute-bound too.
More requests per batch: more throughput , slower per user
~209 tok/s per user, batch 1 (bandwidth-bound)
~123,700 aggregate tok/s ceiling, batch ≥ 591
Continuing the roofline math: aggregate throughput = achieved TFLOPS ÷ (2 × params). Below the ridge point (batch < 591) every extra request is almost free — the GPU was waiting on memory anyway — so aggregate throughput scales with batch while each user's own rate barely drops. Past the ridge, the chip is out of arithmetic, not bandwidth: aggregate throughput flattens and each extra request divides the same ceiling, so per-user speed falls roughly as 1/batch. This is a simplified model — a real server also spends time on attention and scheduling.
Every remembered token costs 128 KiB — for real, per sequence
128 KiB KV cache per token, per sequence (Llama-3.1-8B)
131,072 max context the model config allows
Every decoded token adds one key and one value vector, in every layer , to the KV cache — kept so the model never recomputes the past. Bytes = 2 (K,V) × layers × kv-heads × head-dim × bytes/elem × tokens × batch. For Llama-3.1-8B (32 layers, 8 KV-heads via grouped-query attention, head-dim 128, bf16): 2×32×8×128×2 = 131,072 bytes = 128 KiB per token . An 8K-context request alone costs ~1 GiB — on top of the ~16GB of weights.
Between two fast kernels, the GPU can just sit there idle
5 small kernels 1 fused kernel / CUDA graph
1.70× Mask R-CNN speedup from CUDA graphs (272 GPUs)
3× traffic cut fusing sum(abs(x)) (3GB→1GB)
Launching a GPU kernel is CPU work — the driver validates arguments and queues it — so back-to-back tiny kernels leave visible gaps where the GPU waits on the CPU. Fusion merges several operations into one kernel, keeping intermediates in on-chip registers instead of round-tripping through HBM. CUDA graphs capture a whole sequence of kernels once and replay it with a single launch, skipping the per-kernel dispatch cost on every repeat.
Attention rewritten to never materialise the big matrix
19 TB/s on-chip SRAM, A100 (est.)
1.5–2 TB/s HBM, A100 — 10×+ slower
3× GPT-2 (seq 1K) training speedup
Standard attention writes the full seq×seq score matrix to HBM, then reads it back for softmax, then again for the weighted sum — three round-trips through slow memory. FlashAttention processes small tiles of Q, K, V entirely inside on-chip SRAM, keeping a running (not-yet-normalised) softmax so the full matrix is never written out. Same maths, exact result — just far less HBM traffic, which is what the roofline says actually limits attention.
Standard attention: compute all of QKᵀ, write the full matrix to HBM. Read it back for softmax , write the result to HBM again. Read again to multiply by V. Three HBM round-trips of a seq×seq matrix. FlashAttention : tile Q/K/V into on-chip SRAM, keep a running softmax, never write the full matrix out.Same exact answer — the saving is entirely in bytes moved , not FLOPs.
One model, many GPUs: split the matrix , or split the layers
Tensor parallel Pipeline parallel Play ring all-reduce
900 GB/s NVLink, H100, GPU↔GPU
128 GB/s PCIe Gen5 — 7× slower
Tensor parallel splits one big matrix multiply column-wise across GPUs; each computes a partial output and they all-reduce (sum) their partials — one all-reduce per layer, so it needs the fastest possible link. Pipeline parallel instead gives each GPU a different range of layers and streams activations forward through them like a relay — far less traffic, but a GPU can sit idle waiting for the one before it (a “bubble”).
One decode step: mostly waiting on weights , barely any maths
Reading the roofline page's own numbers: at batch 1 a decode step does ~16GFLOP against a 1,979 TFLOP/s chip — under a hundredth of a millisecond of pure arithmetic — while pulling ~16GB through a 3.35TB/s link takes ~4.8ms. The bar below sketches that gap alongside two more real, cited costs: the CPU-side kernel-launch overhead from the previous page, and attention/KV-cache lookups. Proportions are illustrative; the memory-read share is the one grounded number.
The GPU die and its memory are built separately , glued together after
814 mm² H100 GPU die (Nvidia-stated area)
6 HBM3 stacks placed beside it (5 active + 1 spare on H100)
Neither the GPU logic die nor the HBM stacks are grown together — each is its own chip, from its own fab. TSMC's CoWoS (chip-on-wafer-on-substrate) places them side by side on a silicon interposer : a passive chip whose only job is dense, very short wiring — using through-silicon vias (TSVs) — between the logic die and the memory stacks, before the whole assembly is mounted on the package substrate. That short, wide interposer wiring is what lets HBM hit terabytes per second where a normal board trace could not.
The GPU logic die (814 mm² on H100) is fabricated on its own, at TSMC. HBM stacks are fabricated separately, at a memory maker, then pre-stacked with TSVs.Both sit side by side on a silicon interposer — dense short wiring, no active transistors of its own. The interposer assembly mounts on the package substrate that finally meets the board.
For one user waiting on one answer, a GPU is the wrong shape
Everything on this page is the SAME defect, seen four ways Decode at batch 1 uses ~0.2% of the chip's arithmetic (roofline page); every request still pays a full PCIe/NVLink hop and a HBM read of the whole model; kernel-launch gaps cost real microseconds per tiny step; and batching — the GPU's own fix — trades away the one thing a single live user wants: low latency, right now.
A GPU is built to be filled: thousands of identical cores, kept busy by batching hundreds of requests so the chip stays in the compute-bound region. A single interactive user is the opposite workload — one thin stream of tokens, mostly waiting on memory, unable to benefit from more cores or a bigger batch. That mismatch is exactly the gap
Groq's LPU and its scheduled, cache-free
Tensor Streaming Processor were built to close — and it's why the next part turns to a chip built the opposite way from a GPU.
Compute : batch-1 decode uses ~0.2% of peak FLOPs (this part, roofline page).Memory : every token still reads the whole model — the wall doesn't care about batch size.Launch overhead : many small ops per token, each with a CPU-side dispatch cost.Batching fixes throughput but directly hurts the one metric a live user feels: latency.Next: LPULite's machine — no warps, no batching, a plan fixed at compile time.
Who decides what happens next : the chip , or the compiler ?
<3% of the TSP's area is instruction control
0 arbiters or caches in its data path
A normal CPU decides as it goes: is this value in cache? which way will that branch go? Those guesses are made at runtime, by hardware, and a wrong one stalls the pipeline for an unpredictable number of cycles. Groq's Tensor Streaming Processor moves that decision earlier: a compiler walks the entire program before the chip ever runs and writes down, tick by tick, exactly what every unit does. At runtime the chip's instruction control unit (ICU) just counts upward through that fixed list — which is why it is under 3% of the chip's area. Nothing is decided at runtime because everything already was.
A CPU fetches an instruction and only then decides what to do: is this in cache? which way will this branch go?
Guess wrong, or miss the cache, and the pipeline stalls — a different number of cycles almost every run.
Groq's compiler instead walks the whole program ahead of time and writes down what every unit does, every tick.
At runtime the ICU just counts up through that fixed list — there is nothing left to guess.
Press Replay : the CPU-style row finishes at a different tick each time; the compiler-scheduled row finishes on the same tick, always.
Inside the chip: instructions flow north , data flows east & west
16 lanes = one "superlane"
20 superlanes on-chip
320 max vector length (elements)
<3% chip area spent on the ICU
Instead of many identical cores, the chip is cut into vertical functional slices. Instructions issued by the ICU travel northward , one per tick, into every slice below it. Data — operands and results — streams east and west between slices in parallel lanes. Computation happens exactly where a vertical instruction crosses its horizontal data stream, like a loom. 16 lanes make a "superlane"; 20 superlanes span the chip, so a vector can run from 16 up to 320 elements.
Abts et al., ISCA 2020, §II — "instructions flow Northward from the ICUs... data (operands and results) flow East and West between functional slices"; superlane/lane counts from §III.B. Mirrored at toy scale:
LPULite's own floorplan .
The chip is sliced by function , not copied into identical cores.
Every tick, the ICU sends one instruction north into each slice below it.
Data streams flow east and west between slices, in parallel lanes.
16 lanes = one superlane ; 20 superlanes on-chip → vectors from 16 up to 320 elements.
Compute happens where a vertical instruction crosses its horizontal data stream. Click a slice to read its job.
Delete the guessing hardware, and every run takes exactly the same time
design TSP-style (no caches) CPU/GPU-style (caches + prediction) Run 20 times
Caches, branch predictors and prefetchers help the average case a lot — but by their nature they react to what just happened, so a cache miss or a wrong guess costs a different number of cycles each time. The TSP removes arbiters and every other reactive element from its data path, so nothing in the hardware can react differently between two runs of the same program: same instructions in, same ticks out, always.
Abts et al., ISCA 2020, §I & §VI.F: "caches, branch predictors, and prefetchers help tremendously in improving performance, [but] do not bound worst-case performance"; "The TSP's hardware eliminates arbiters and other reactive elements in the data path, making performance deterministic." The histogram shape below is illustrative — not measured latency data.
A CPU/GPU's cache might have your data — or might not. A branch predictor guesses which way an "if" goes.
Right guesses are fast; wrong guesses stall the pipeline — a different cycle count nearly every run.
The TSP deletes arbiters and caches from its data path entirely — nothing is left that can react differently.
Click Run 20 times : TSP-style always lands on the same tick; CPU/GPU-style spreads out.
230 MB of very fast memory — fast enough, but you need hundreds of chips
model 7B 70B 405B stored as 8-bit 4-bit chip generation gen-1 (230 MB, 80 TB/s) Groq 3 LPX (500 MB, 150 TB/s)
Every weight of the model must live somewhere the chip can read it. Groq's bet is on-chip SRAM: vendor-stated up to 80 TB/s of bandwidth on the first chip (150 TB/s on the 2026 chip) — far faster than shipping bytes in from off-chip memory. The cost of that speed is capacity: about 230 MB per chip (500 MB on the 2026 chip). A large model simply does not fit on one chip, so Groq's product is a rack of chips passing data along, not a single card.
One chip holds about 230 MB of SRAM (first-gen) — plenty fast, but small.
A 70-billion-parameter model at 8-bit weighs about 70 GB .
70 GB ÷ 0.23 GB per chip ≈ 305 chips — just to hold the weights, before any compute happens.
Drag the controls: bigger models and finer bits need more chips; the newer, bigger-SRAM chip needs fewer.
Chain the chips: each layer on its own chip , the token flows through
Play rack size node · 8 chips rack · 72 chips Groq 3 LPX rack · 256 chips
72 chips per rack: 9 nodes × 8, one node spare
~14 GB/s bandwidth per chip at ~10,440-chip scale (from ~50 GB/s small-scale)
Each TSP contributes its 220 MiB into a memory pool the paper calls "logically shared, but physically distributed". Packaging stacks 8 chips into a node and 9 nodes into a rack (one node held as a spare) — 72 chips. A software-defined network, scheduled by the same compiler that plans the on-chip instructions, carries data between them. But bandwidth per chip falls as the system grows — from around 50 GB/s at small scale to about 14 GB/s per chip once the system reaches its largest measured scale. The interconnect, not the compute, becomes the new limit.
Abts et al.,
"A Software-defined Tensor Streaming Multiprocessor" , ISCA 2022 — node/rack packaging, "software-defined Dragonfly topology", bandwidth falling to "about 14 GB/sec" at scale. Groq 3 LPX rack size (256 = 32 trays × 8) from the NVIDIA blog cited on the next page.
Packaging: node = 8 chips ; rack = 9 nodes × 8 = 72 chips (one node is a spare).
A software-defined network carries data chip-to-chip; the compiler schedules it too — same idea as on-chip.
Bandwidth per chip: about 50 GB/s at small scale, falling to about 14 GB/s at the largest scale measured.
So the interconnect — not the compute — becomes the limit as you add chips.
Press Play : watch one token's work move chip to chip through the pipeline.
One user, one stream: milliseconds per token , side by side
4.15 ms per token — Groq, measured
~29 ms per token — one H200, derived upper bound
Groq's independently measured rate on Llama-2 70B was 241 tokens/second for one user (Feb 2024 benchmark) — about 4.15 ms/token. For comparison, one NVIDIA H200's own bandwidth-derived ceiling on a similar-size model (4.8 TB/s ÷ ~140 GB of FP16 weights, batch 1, ignoring the KV cache) is only about 34 tokens/second, ~29 ms/token. This is not an apples-to-apples benchmark: Groq's number spans many chips at lower precision; the H200 number is a single-GPU upper bound with no batching. With 8-bit weights (~70 GB) the same one-GPU ceiling roughly doubles to ~68 tokens/second, and splitting the model across several GPUs raises it further — real GPU serving stacks run well above the single-GPU FP16 figure. It shows the shape of the bet, not a fair race.
Every new token needs the model's weights read from memory once more.
Groq's measured rate for one user on Llama-2 70B: 241 tokens/second (independent benchmark, Feb 2024).
One H200's derived ceiling for a similar-size model, batch 1: about 34 tokens/second — not vendor-measured.
Different precision, different chip counts — not a fair fight by itself. Press Play to watch both tick for 200 ms.
Fast for one user is not the same question as cheap per token
requests served together 1 at a time 8 batched 32 batched
Two different questions Tokens/second for one user, and total answers delivered per chip-hour, are separate axes. A chip that wins one can lose the other — measure both before comparing.
Reading the model's weights from memory costs the same whether the result serves 1 request or 32 at once. Batch many requests behind the same weight-read and the total tokens delivered per second climbs sharply — which is why GPU serving stacks batch aggressively for compute-bound, high-throughput serving. A design tuned to hand one user replies as fast as possible doesn't automatically win on total throughput per chip; it is optimising a different quantity. Neither this page nor its sources state a dollar cost per token — that number depends on pricing, utilisation and contracts none of the cited sources publish.
Reading the weights from memory costs the same whether it answers 1 request or 32 at once.
Batch many requests behind that one read, and total tokens/second across all of them climbs.
A design tuned for the fastest single-user reply is optimising a different number than total throughput.
So "fast per user" and "cheap per token" don't imply each other — check which one a claim is actually about.
2026: Nvidia's Groq 3 LPX — what's published, what isn't
view per chip per rack (256 chips)
500 MB SRAM per chip (up from 230 MB gen-1)
315 PFLOPS FP8, per 256-chip rack
Per chip (LP30): 500 MB of compiler-managed SRAM, 150 TB/s on-chip bandwidth, and 96 chip-to-chip links at 112 Gbps each (2.5 TB/s). A rack is 32 liquid-cooled trays of 8 chips = 256 chips, 128 GB total SRAM, 315 PFLOPS FP8. The new twist is "Attention-FFN Disaggregation": Vera Rubin GPUs run attention over the KV cache while LPUs run the feed-forward/MoE layers, swapping activations every token — GPU strength (big memory, flexible) paired with LPU strength (fast, scheduled weight streaming). Nvidia reported 3,400 output tokens/second on Gemma 4 31B at 100K context in production (Aug 2026). Not published in any source checked this session: die size, clock speed, exact per-chip FLOPS, and price; the Samsung 4 nm process comes from press reports, not an Nvidia/Groq primary source.
Per chip: 500 MB SRAM, 150 TB/s on-chip bandwidth, 96 C2C links at 112 Gbps (2.5 TB/s).
One rack: 32 trays × 8 chips = 256 chips , 128 GB total SRAM, 315 PFLOPS FP8.
"AFD": Vera Rubin GPUs run attention over the KV cache; LPUs run FFN/MoE, swapping activations per token.
Production claim (Aug 2026): 3,400 output tokens/second, Gemma 4 31B, 100K context.
Not published anywhere found: die size, clock speed, exact process detail, price.
Google's bet: a matrix-multiply ASIC, chips wired together by light
generation TPU v1 · 2017 Ironwood v7 · 2025
65,536 MACs in the original systolic array
9,216 chips per Ironwood pod
The original 2017 TPU's heart was a 65,536-MAC, 8-bit systolic array — 92 TOPS peak, 28 MiB on-chip memory — reported 15–30× faster and 30–80× more TOPS/Watt than a contemporary CPU or GPU (a 2017-era comparison; don't read it as current). Unlike Groq, Google kept large off-chip memory: Ironwood (7th-gen, 2025) has 192 GB of HBM and 7.37 TB/s of bandwidth per chip , scaling to 9,216 chips per pod (42.5 Exaflops). To connect pods of that size, TPU v4 added optical circuit switches that dynamically reconfigure the network's topology — Google states this is cheaper, lower-power and faster than a fixed electrical fabric (Infiniband), at under 5% of system cost and under 3% of system power.
2017: a 65,536 -MAC systolic array — an 8-bit grid where partial sums flow cell to cell.
2017 result: 15–30× faster, 30–80× more TOPS/Watt than a contemporary CPU/GPU.
2023: optical circuit switches dynamically reconfigure the pod network — under 5% of cost, under 3% of power.
2025 (Ironwood): 192 GB HBM and 7.37 TB/s bandwidth per chip — the opposite memory bet from Groq.
Scale: up to 9,216 chips per pod, 42.5 Exaflops peak. Toggle the generation to compare.
Cerebras' bet: don't cut the wafer into chips at all
Show normal die-cut lines
44 GB on-chip SRAM, one wafer
46,225 mm² die size — the whole wafer
A normal chip is one small rectangle cut from a silicon wafer after manufacture, with hundreds of identical rectangles discarded or packaged separately around it. Cerebras' WSE-3 leaves the whole wafer intact: 46,225 mm² of silicon, 4 trillion transistors, 900,000 AI-optimised cores — as one chip. Its 44 GB of on-chip SRAM alone is larger than a whole 72-chip first-generation Groq rack's combined SRAM (72 × 230 MB ≈ 16.6 GB — arithmetic from the cited numbers, not a Cerebras claim). Because the array is never diced, those 900,000 cores are wired together at manufacture instead of over the board- and rack-level links every other design on this page has to cross.
Cerebras,
WSE-3 announcement (4T transistors, 900,000 cores, 44 GB SRAM, 5 nm TSMC) ·
cerebras.ai/chip (46,225 mm² die size). No official on-chip bandwidth figure was found in either page this session, so none is printed here.
A normal wafer is diced into hundreds of separate chips after manufacture.
WSE-3 stays whole: 46,225 mm² , 4 trillion transistors, 900,000 cores — one chip.
44 GB on-chip SRAM: more than a whole 72-chip Groq rack's combined SRAM, on one piece of silicon.
Toggle the cut lines: every design elsewhere on this page pays a chip-to-chip cost this wafer never has to.
Three more bets: open silicon , in-memory compute , and hard-wired transformers
Tenstorrent In-memory compute Transformer ASIC
120 Tensix cores, Blackhole card
32 GB commodity GDDR6, not custom SRAM
Where each bet sits: capacity vs bandwidth , flexible vs efficient
capacity vs bandwidth flexibility vs efficiency
This is a qualitative sketch, not a measured plot — positions summarise the cited numbers from this part, arranged by rank rather than exact coordinates. On capacity vs bandwidth: Groq's SRAM-only chips sit at low capacity / very high bandwidth; GPUs and TPUs sit at high capacity / high bandwidth (off-chip HBM); Cerebras sits at unusually high capacity and bandwidth for one chip, because the "chip" is a whole wafer. On flexibility vs efficiency: a GPU is the most flexible (any program) at the cost of some efficiency; a statically-scheduled dataflow chip or a transformer-specific ASIC gives up flexibility for efficiency on exactly the workload it was built for.
Positions are this page's own summary of the numbers cited earlier on this part and on
the LPULite vs Groq page — treat the map as a way to remember the trade-offs, not as data.
Toggle the axis: capacity vs bandwidth , or flexibility vs efficiency .
Groq's chips: tiny capacity, huge bandwidth — fast, but needs hundreds of chips for a big model.
GPUs and TPUs: large off-chip memory, still fast, and far more flexible for arbitrary programs.
Cerebras: one wafer gives both high capacity and high bandwidth, at the cost of never leaving that wafer.
What LPULite actually rebuilt — and at what scale
40× fewer lanes: 8 vs Groq's 320
~782× less on-chip memory: 288 KiB vs 220 MiB
Borrowed directly: the unit names ICU, MEM, VXM, MXM, SXM come straight from Table I of the 2020 paper, and the core idea — a compiler writes the whole schedule in advance, so the chip has no branches, loops or runtime guessing — is genuinely the same architecture. Not attempted: chip-to-chip scaling (Groq chains up to 10,440 chips; LPULite is one FPGA), and the lane count (8 vs 320). None of this is a flaw in a student project — the team's own documentation says so directly.
Abts et al., ISCA 2020 (unit names, lane counts, 220 MiB, 900 MHz, 10,440-chip max system from ISCA 2022) · LPULite README (unit names, 288 KiB memory, 8-lane datapath) ·
synthesis/rtl/de1_soc_hps_top.sv:8 and
synthesis/docs/lpu_debugging_and_generality_status.md:19 ("Clock reduced to 6.25 MHz... could not meet timing at the original 50 MHz") — the site's own "6.75 MHz" appears to be a typo; the repo says 6.25 MHz. See the full side-by-side on
LPULite vs Groq's LPU and the honest accounting on
what's real, partial, and what it isn't ; the whole floorplan is on
the machine .
Borrowed directly: the names ICU, MEM, VXM, MXM, SXM — straight from Table I of the 2020 paper.
Borrowed the idea : a compiler writes the whole schedule in advance; no branches, no loops, no guessing.
Not attempted: chip-to-chip scaling (1 FPGA vs up to 10,440 chips) and the lane count (8 vs 320).
The team says so themselves — see what's real, partial, and what it isn't and the full comparison .
The whole chip in one drawing — six parts and two roads
6 units: ICU · MEM0 · MEM1 · MXM · SXM · VXM
2 buses: narrow 72-bit, wide 264-bit
The ICU is the conductor: it holds the program and, every clock tick, sends one 96-bit instruction that tells every other part what to do at the same time. MEM1 holds the model's weights; MEM0 is scratch space. MXM does matrix multiplies, VXM does everything row-by-row (normalising, softmax, adding, re-squeezing to 8 bits), SXM rearranges rows. They pass data over two shared roads: a narrow “westbound” bus carrying 8-bit rows and a wide “eastbound” bus carrying 32-bit results. The repo doesn't explain the names, but Groq's 2020 paper describes data flowing “East and West between functional slices” — which the names likely echo.
src/lpu.sv · src/lpu_pkg.sv:12-21, 135-165
The ICU holds up to 1,024 instructions and steps through them, one per tick. MEM1 = the model's weights. MEM0 = scratch for results.MXM (8×8 grid of multipliers) does the matrix maths.VXM does row work: normalise, softmax, add, re-squeeze to 8 bits. SXM rearranges rows.Everything travels on two roads: a narrow 8-bit bus and a wide 32-bit bus . An ARM processor outside loads the program, starts it, and reads the answer.
The chip's unit of data: a row — 8 small numbers and 1 shared scale
72 bits in a memory row (8×8 + 8)
264 bits on the wide bus (8×32 + 8)
Every value in memory is part of a row: eight signed 8-bit “lanes” (bits 0–63) and one signed 8-bit exponent (bits 64–71). The real number is lane × 2^exponent. A 16-number vector takes two rows. Results of multiplications are wider, so the eastbound bus carries 8 × 32-bit lanes plus the same 8-bit exponent — 264 bits.
src/lpu_pkg.sv:13-22 · packing compile_microgpt_lpu.py:90-99
Two shared roads, and one sender per tick
narrow road (8-bit) MEM1 → MXM VXM → MEM0 MEM0 → SXM
wide road (32-bit) MXM → VXM SXM → MEM1 MEM0 → VXM
A bus is a bundle of wires everyone is connected to. If two units drove it at once their signals would clash, so a multiplexer lets exactly one producer through per clock tick, and a decoder wakes exactly one consumer. The instruction word spends 12 of its 96 bits (3 each) naming the sender and receiver for both roads. Note the asymmetry: MXM only listens on the narrow road and only talks on the wide one; MEM1 only talks narrow and listens wide. “West/east” echoes Groq's paper, where data streams flow east and west across the chip while instructions flow north.
src/shared_bus_mux.sv:15-23 · lpu_pkg.sv:135-165
One instruction = 96 switches that set every unit at once
Hover any field. There is no opcode: each group of bits drives one unit directly.
96 bits per instruction
0 opcode field — slices drive units directly
1,024 instruction slots
8 bits reserved, unused
A normal CPU instruction is short and needs decoding (“this pattern means ADD”). LPULite uses a Very Long Instruction Word: 96 bits where each slice is itself a control wire — memory read/write and address, bus sender and receiver, MXM load/start/clear, SXM load/emit, VXM mode bits. One word sets up many independent actions in the same tick; that is how the compiler squeezes parallel work out of simple hardware.
src/icu.sv:169-221 · model/tools/lpu_vliw_compiler.py:56-87
Bits 0–11: who sends and who receives on each road. Bits 12–45: MEM0 and MEM1 read/write and a 15-bit address each. Bits 46–62: MXM — load input or weight, start, clear, which row to output. Bits 63–87: SXM load/emit and VXM mode (ReLU, softmax, norm, residual op). Bits 88–95: reserved . All 96 bits act in the same tick .
The conductor doesn't think: it just counts
PC+1 every tick, that's all
900 instructions per loaded page
The ICU is instruction memory plus a counter. Each clock tick the counter goes up by one and the word at that address drives the chip. There are no branches (“if”), no loops, no dynamic decisions: waiting is written out as empty instructions — hold a memory read for 2 ticks, let a write settle for 2, wait 16 ticks for a result to come back from VXM. The ARM loads a page of instructions, runs the chip for an exact number of ticks, then loads the next page.
src/icu.sv:158-166 · waits compile_microgpt_lpu.py:72-82, 334
One multiply–accumulate cell, with scales that don't match
9→18→32 bits: operand, product, accumulator
64 of these cells in the MXM
Each MAC multiplies an input by a weight and adds the product to a running total. Because every number carries its own power-of-two scale, a product is lane×lane with scale = input scale + weight scale. Before adding, the total and the new product must be lined up to the finer scale — otherwise you'd add “3 of 2⁻²” to “2 of 2⁻⁴” as if they were the same unit. The authors call this mixed-scale fix one of the most important in the project. The total saturates at 32 bits instead of wrapping.
src/mac.sv:5-14, 66-68, 121-130 · test tb/mac_mixed_scale_tb.sv:74-95
First product: 2 × 1 at scale 2⁻⁴ (real 0.125). Second product: 3 × 1 at scale 2⁻² (real 0.75). Line them up to the finer scale: 3 × 2⁻² = 12 × 2⁻⁴ (shift left 2). Add: 2 + 12 = 14 at scale 2⁻⁴ = 0.875. Correct.
The matrix unit: 64 cells multiplying in the same tick
Start (accumulate) New rows Clear
8×8 MAC cells
1 tick for all 64 to accumulate
The MXM captures one 8-lane row as “input” and one as “weight”. On start, every cell [r][c] adds input[r] × weight[c] to its total — an outer product, all 64 in parallel. Press start again with new rows and each cell keeps adding. After clear, everything resets. One chosen output row can be sent out on the wide bus.
src/mxm.sv:315-349 (“MXM computes an outer product” — compile_microgpt_lpu.py:159)
The clever trick: build W · x out of repeated outer products
140 instructions per 8-output block (16 inputs)
A layer needs y = W·x, not an outer product. The compiler gets it anyway. For each input element x[k], make a row with x[k] copied into all 8 lanes (“broadcast”), load weight row k (the 8 weights that multiply x[k]), start. Row 0 of the grid accumulates x[k]·W[c][k] for every output c. After all inputs, row 0 holds 8 outputs of W·x. Repeat for the next 8 outputs. The weights were pre-packed in exactly this order (“input-major”) so each load is one memory read.
compile_microgpt_lpu.py:156-174, 348-366, 423-438
Goal: y = W · x . Take x[0] and copy it into all 8 lanes (broadcast ). Load weight row 0 = the 8 weights that multiply x[0]. Start : row 0 of the grid gains x[0]·W[c][0]. Next input x[1], weight row 1, start: row 0 gains x[1]·W[c][1]. …after every input, row 0 holds Σ x[k]·W[c][k] = 8 outputs of W·x. Row 0 → wide bus → VXM re-squeezes to 8 bits → MEM0.
SXM doesn't do maths — it turns rows into columns
SXM stores an 8×8 “tile” of rows. On load it captures 8 rows; on emit it sends 8 rows out where output row e is column e of the tile — a transpose. Attention needs Q·Kᵀ, so the K matrix (positions × dimensions) is flipped to (dimensions × positions) here. Load the same row 8 times and emit, and you get a broadcast: row e becomes eight copies of lane e — exactly what the W·x trick needs.
src/sxm.sv:75-126 · broadcast test tb/sxm_scaled_broadcast_tb.sv:44-68
Transpose : the 8 rows loaded in come out as the 8 columns .Broadcast : load one row 8 times → each output row repeats one lane 8 times.
The vector unit: an assembly line that each row rides through
4 ctrl bits: bias · ReLU · ÷2 · softmax
Q8.8 internal number format (×256)
VXM takes a 32-bit row off the wide bus and runs all 8 lanes in parallel through stages the instruction switches on or off: add a bias, ReLU (negatives → 0), halve, then either softmax or straight through; optional RoPE, RMSNorm and a residual accumulator; and always a final requantizer that squeezes the row back to 8-bit lanes plus a scale. Small 4-row FIFOs buffer its input and output; if the output is full, the whole line pauses.
src/vxm.sv:194-264, 351-471
A row arrives from the wide bus (Q8.8: value × 256). Switchable stages: bias add , ReLU , ÷2 . Branch: softmax (many rows) or straight on. Optional RoPE , RMSNorm , residual . Always last: requantize to 8-bit lanes + scale, then out.
RMSNorm with a 32-entry lookup table instead of a square root
32 table entries (Q1.15)
2 rows normalised together (16 numbers)
RMSNorm scales a vector so its typical size is 1: divide every value by √(mean of squares). Square roots and division are expensive in hardware, so the chip finds the highest set bit of the mean, uses the next 5 bits to index a 32-entry table of 1/√ values, shifts by half the bit position (with a 1/√2 correction for odd positions), then multiplies. Worked from the code: rows of 1s and 3s give 114 and 342 in Q8.8, against the exact 114.5 and 343.4.
src/rmsnorm.sv:98-168 · test tb/rmsnorm_chunk_tb.sv:51-96
Inputs: one row of 1s, one row of 3s (16 values). Sum of squares = 8·1 + 8·9 = 80 ; mean = 5 . No square root: highest bit of 5 is bit 2 → next bits index the table → shift by 1. Multiply: 1 → 114 , 3 → 342 (exact: 114.5 and 343.4). One table read, a few shifts.
Softmax needs ex and division — the chip uses two tables
178 entries: e−i/256 ×256
256 entries: reciprocals 2³⁰/m
177 = ln 2 × 256
Q0.7 8-bit probability out
Softmax turns scores into probabilities: eˢ ÷ Σeˢ. First subtract the largest score so every exponent is ≤ 0. Then e^q is computed as a table entry shifted right: split q into whole multiples of ln 2 (each is just ÷2, a shift) plus a remainder looked up in a 178-entry table. Add all exponentials, look up ONE reciprocal of the total in a 256-entry table, and multiply every lane by it. All 434 table entries were checked against their formulas.
src/softmax.sv:32-240 · lut_softmax_exp.sv:22-236 · lut_softmax_div.sv:52-343
After the maths, squeeze back to 8 bits — by counting shifts
2 ticks of latency
±127 output lane range
Results come out of multiplication as wide 32-bit numbers. The quantizer finds the largest magnitude, counts how many right-shifts bring it under 127, shifts every lane by that much (rounding), and records the shift as the new scale (minus 8, because inputs were Q8.8). The row is back to 8-bit lanes plus one exponent, ready for memory. This exact example is the project's own test.
src/quant.sv:101-125 · test tb/quant_q8_8_tb.sv:46-74
Input in Q8.8: [256, −512, 128, −64] = real [1, −2, 0.5, −0.25]. Largest magnitude 512. Shift right until ≤ 127: 3 shifts . Shift every lane: [32, −64, 16, −8] . Scale = 3 − 8 = −5 . Check: 32 × 2⁻⁵ = 1.0 ✓
Two memories: the model and the scratchpad
Hover any block. Every address is decided by the compiler before the chip runs.
16,384 rows each (RTL)
974 rows of model image in MEM1
4,192 model weights (8-bit)
0 caches
MEM1 holds the model: 524 rows of the checkpoint, 448 rows of weights re-laid in the order the MXM trick reads them, and 2 constant rows, plus the V cache and staging areas. MEM0 is scratch: token and position, normalised X, Q, K, V, attention results, MLP, logits, and the K cache. Every instruction names an exact row — there is no cache, no guessing, no virtual memory.
src/lpu_pkg.sv:173-176 · layout compile_microgpt_lpu.py:177-217, 443-479
Take one real row from the compiled image — what does it actually do?
real row row 2 · load lhs row 13 · add rhs row 31 · write MEM0[8]
3 real rows decoded, byte for byte
0 guessed bits — every value is bit-shifted from the actual hex
Part C explained the 96-bit word conceptually. Here are three rows LIFTED VERBATIM from g_microgpt_vliw[] in the compiled MicroGPT image, decoded with the exact bit ranges icu.sv uses to drive the chip — no simulation, just shifting real hex. Row 2 and row 13 both ride the WIDE eastbound bus into VXM's residual accumulator; row 31 rides the NARROW westbound bus back out. That asymmetry — wide in, narrow out — is why the chip has two buses of different widths at all.
synthesis/linux/include/microgpt_hps_image.h (g_microgpt_vliw[2,13,31]) · decode src/icu.sv:169-221 · defaults model/tools/lpu_vliw_compiler.py:19-88
Pick row 31 — the instruction that finally writes the answer back to memory.
Hex: w0=0x000220C3 , w1=0x06000000, w2=0x00000000 — three 32-bit words, exactly as the ARM writes them over MMIO.
Slice the 96 bits into the same fields icu.sv assigns — one coloured block per field, hover any of them.
Read off the non-zero fields: westbound_sel=3 (VXM) , westbound_consumer_sel=3 (MEM0) , mem0_write_en=1 , mem0_addr=8 .
In English: VXM drives the narrow bus, MEM0 listens, and writes row 8 — that's write VXM to MEM0[8], verbatim from the compiler's own trace note.
Ten real ticks: the chip's very first computation, cycle by cycle
10 real instructions, g_microgpt_vliw[13..22]
8 of them are byte-for-byte identical
These are ticks 13 through 22 of the actual compiled program — the second half of add_rows_to_mem0(TOKEN0, POS0, X0, "embedding add row0"): MicroGPT's very first arithmetic, computing the residual stream X0 = token-embedding + position-embedding. Eight of the ten instructions are IDENTICAL hex, because the ICU has no branches: "wait for VXM's pipeline to finish" is written out as eight repeated instructions, not a loop.
synthesis/linux/include/microgpt_hps_image.h g_microgpt_vliw[13..22] · schedule model/tools/compile_microgpt_lpu.py:368-372, 666
A memory read isn't instant — trace the real 2-tick hold
2 MEM_READ_HOLD_CYCLES
1 cycle of REAL register latency
mem.sv's read is synchronous: stream_out <= sram_array[addr] fires on the clock edge AFTER read_en, one cycle of true latency. So why does the compiler hold read_en for two cycles, not one? Because the FPGA build swaps that behavioral array for an Intel altsyncram with a registered output (outdata_reg_a="CLOCK0") — same 1-cycle latency in principle, but the compiler holds for 2 to be safe across both the simulator and the real block RAM in one schedule.
src/mem.sv:80-135 · hold model/tools/compile_microgpt_lpu.py:72-76, 233-236 · valid register src/lpu.sv:387-396
One MAC cell, two different ways to align mismatched scales
standalone MAC inside MXM (real)
2 alignment strategies in the same file
64 cells share ONE shift inside MXM
mac.sv actually contains two alignment strategies behind one parameter, EXTERNAL_SCALE_CONTROL. Standalone (the testbench mode) has every cell work out its own shift. Inside the real MXM grid, that would mean 64 separate comparators redoing the SAME comparison — so mxm.sv computes mxm_acc_shift/mxm_product_shift ONCE, combinationally, and wires the identical shift into all 64 cells. One comparison, not sixty-four.
src/mac.sv:70-107 · shared shift src/mxm.sv:213-226, 335-338
Switch to inside MXM — the mode the real hardware uses.
First product: 2 × 1 at scale 2⁻⁴ (real 0.125).
Second product: 3 × 1 at scale 2⁻² (coarser — real 0.75).
mxm.sv compares the two scales once and hands every cell the same product_shift: shift the coarser one left 2 → 12 × 2⁻⁴ .
Add: 2 + 12 = 14 × 2⁻⁴ = 0.875 ✓, saturating at 32 bits (mac.sv:122-127).
64 identical copies of one line: the real generate loop
Clear Load random rows Start
1 mac instantiation, inside a double for-loop
64 synthesized copies (genvar r, c)
There's no hand-written 8×8 array of MAC cells in the source. genvar r, c; generate for(r<8) for(c<8) ... mac #(...) u_mac(...) — one instantiation, unrolled 64 times at synthesis. The one subtlety a diagram usually hides: each cell's clock enable is mxm_mac_en_feed[c], indexed by COLUMN only — a whole column pauses together while that column's weight is still loading, regardless of which row.
src/mxm.sv:315-349 (loop) · :291 (per-column enable)
Clear — resets every wght_reg AND every acc_o to zero.
Load a random input row and weight row into the ingress registers.
Start — all 64 mac instances fire on the SAME tick, hover any cell to see its formula.
Start again — the grid is a true ACCUMULATOR, not a snapshot.
Two magic numbers, 5A5 and A5A — and why the board skips them
12'h5A5 OP_TRANSPOSE_LOAD
12'hA5A OP_TRANSPOSE_EMIT
sxm.sv isn't a generic crossbar — it's literally two opcodes, 5A5 to start loading a tile and A5A to start emitting it transposed, driving a transpose_loading/transpose_emitting state pair and two index counters. But the audit found this exact RTL "corrupts on board" for ordinary broadcasts — stale rows, dropped exponents, lanes 1..7 instead of 0..7 — so the shipped runtime does ordinary broadcasts on the ARM instead (--broadcast host) and reserves real SXM hardware only for the K-transpose in attention.
src/sxm.sv:31-126 · bug + fix table row, synthesis/docs/lpu_debugging_and_generality_status.md ("FPGA SXM broadcasts") · default rationale synthesis/linux/README.md:85-86
Five real registers, s0 through s4 — and three 2-to-1 muxes
vxm_ctrl bits: bias ReLU ÷2 softmax
5 pipeline registers: s0_data_reg…s4_handoff_reg
3 cascaded 2:1 muxes, one per lane × 8 lanes
The conceptual VXM page showed "stages you can switch on or off". The RTL is three literal ternaries chained through registers: mux1_out = ctrl[0] ? bias_add_out : data, then ctrl[1] ? relu : bias_lane_s1, then ctrl[2] ? (x>>>1) : x — one generate block instantiates this eight times, once per lane, all running in lock-step.
src/vxm.sv:56-84 (registers) · :189-222 (muxes, generate loop)
Toggle bias off — mux1 now passes data straight through, unchanged.
Bias back on.
Toggle ÷2 on — every lane's value is arithmetic-shifted right by 1 in stage 3.
Toggle softmax on — stage 4 now routes to the softmax core instead of straight to the quantizer.
Softmax is a 4-state machine — walk it with 4 real scores
4 states: CAPTURE, SUM, RECIP_WAIT, NORM
1 reciprocal lookup for the whole row
softmax.sv's state_e is exactly four values. CAPTURE writes each chunk in and tracks global_max combinationally. SUM re-reads every chunk, runs each through the 178-entry exp LUT (lut_softmax_exp), and accumulates global_sum. RECIP_WAIT is the only place the chip divides — ONE lookup in lut_softmax_div's 256-entry table gives a Q2.30 reciprocal for the entire row. NORM multiplies every lane by that single reciprocal and streams out 8-bit probabilities.
src/softmax.sv:32-240 · src/lut_softmax_exp.sv:1-45 (178-entry table, LN2=177) · src/lut_softmax_div.sv:19-58 (256-entry reciprocal table)
Requantizing takes exactly 2 clock edges — proved by the testbench
2 always_ff edges from input to output
−5 the testbench's own expected scale
quant.sv has exactly one always_ff block with two jobs, one cycle apart: register the input, THEN (one edge later) register the mux'd result. In between, a combinational loop counts right-shifts — while (shifted_max_abs > 127) { shift>>=1; row_shift_next++; } — it's counting, not dividing. The testbench's own numbers (256, −512, 128, −64 → 32, −64, 16, −8, scale −5) are reproduced here exactly.
src/quant.sv:67-145 · tb/quant_q8_8_tb.sv:46-82
Cycle N: in_valid=1, x_input = the four Q8.8 values.
Edge: ingress_data_reg <= x_input (quant.sv:76) — now sitting in a register.
Combinationally, THIS cycle: count shifts until the largest magnitude (512) fits under 127 → 3 shifts .
Edge: q_row_out <= mux_word, out_valid<=1 — the 8-bit lanes and scale −5 land one tick later.
Check by hand: 32 × 2⁻⁵ = 1.0 ✓, −64 × 2⁻⁵ = −2.0 ✓ — exactly what the testbench's $fatal guards asserted.
The bus is two tiny modules: a mux and a decoder
producer SXM MEM0 VXM MEM1
consumer MXM SXM MEM0 VXM
1 shared_bus_mux.sv, reused for BOTH buses
always_comb no latency — pure wires
The whole "shared road" idea is exactly one 9-line module: shared_bus_mux takes a 3-bit select and N payloads, and outputs payload_i[select_i] — that's it. westbound_bus.sv just wires the 4 real producers into it. On the other side, westbound_consumer_decode.sv is a plain case statement that sets AT MOST one *_west_en wire, and explicitly nothing if westbound_valid is low.
src/shared_bus_mux.sv:1-24 · src/westbound_bus/westbound_bus.sv · src/westbound_bus/westbound_consumer_decode.sv
Pick MEM0 as the producer on the narrow (westbound) road.
Pick MXM as the consumer — this is the everyday "load a weight row" path.
Switch producer to VXM instead — same mux, same wires, different select value.
Only ONE producer and ONE consumer light up per tick, on each of the two independent roads.
The compiler is a Python program that writes the chip's program
974 rows the compiler packs into MEM1
4,920 96-bit instructions it emits into IMEM
Two data structures do all the work. Mem1Image.add_rows() just appends: every tensor's base address is "however many rows came before it" — no gaps, no padding, no allocator. ScheduleEmitter.emit() appends one 96-bit instruction AND one human-readable trace note per call. The 32-row sequence decoded on the two previous pages is the unrolled output of a SINGLE Python call: add_rows_to_mem0(TOKEN0, POS0, X0, "embedding add row0").
model/tools/compile_microgpt_lpu.py:134-227, 156-217, 368-372 · export synthesis/scripts/export_microgpt_hps_headers.py:49-54, 78-82
MEM1 layout: every tensor's address is just "rows written so far" — sequential, no gaps.
The weight re-layout trick: transposed_matvec_rows() repacks W[out][in] as W[input_index][output_block] — input-major, for MXM's outer-product trick.
emit() appends one row to instructions[] AND one {pc, note} to trace[] — every instruction in the image has a matching English sentence, if you know where to look.
The chip's whole first computation — 32 real instructions — is ONE Python call, unrolled.
What the ARM does 900 instructions at a time, forever
900 instructions per IMEM page
3×32-bit MMIO writes per row, w2 LAST
Every row — instruction OR data — crosses the HPS/FPGA bridge as three separate 32-bit stores at addr+0, addr+4, addr+8, and the comment in the driver is explicit: word 2 "commits the wrapper's assembled row and must remain last". The ARM also does jobs the fabric never touches at all: copying K into MEM0's cache and V into MEM1's cache, and the final greedy argmax over 27 logits — a plain C for-loop, not hardware.
synthesis/linux/src/lpulite_hps_mmio.c:18-34 · synthesis/linux/src/microgpt_hps_runtime.c:459-524, 586-621, 1081-1111, 1294-1304
Quartus says the logic tops out at 9.84 MHz — here's the report
9.84 MHz Fmax, slow 1100mV 85°C corner
÷8 50 MHz → 6.25 MHz, a 3-bit counter
The board's 50 MHz clock is fed through a 3-bit counter; bit 2 of that counter (an implicit ÷8) drives the whole LPU fabric at 6.25 MHz. Quartus TimeQuest's own sta.rpt says the placed-and-routed logic could run at 9.84 MHz at the slow 85°C corner (9.62 MHz at 0°C) — comfortably above 6.25 MHz, with 58.373 ns of setup slack left on a 160 ns tick.
synthesis/rtl/de1_soc_hps_top.sv:6-20 · synthesis/build/lpu_lite_de1_soc_hps/lpu_lite_de1_soc_hps.sta.rpt
CLOCK_50 (board) feeds a free-running 3-bit counter.
qsys_clk = qsys_clk_div[2] — bit 2 of the counter is an implicit ÷8.
TimeQuest's Fmax summary: 9.84 MHz slow-85°C, 9.62 MHz slow-0°C.
58.373 ns of setup slack remains on the real 160 ns (6.25 MHz) tick — comfortably closed, deliberately conservative.
Honest gap: the plain-text report only exports the Fmax SUMMARY — it does not name which register pair is the critical path; that lives only in Quartus's HTML report, not checked into this repo.
The model it runs is tiny — on purpose
compare with MicroGPT alone GPT-2 small (124M) Maya voice core (2.12B)
4,192 weights (8-bit)
1 layer · 16 wide · 4 heads
16 character context
27 tokens: a–z + start/end
MicroGPT is Andrej Karpathy's dependency-free microgpt at its original size (1 layer, width 16, 4,192 weights), retrained here with 8-bit rounding in the loop. It reads and writes one character at a time and was trained on 32,033 first names. In 60% of training steps it saw one of 9 fixed names (saksham, satvik, surya, michael, evan, xander, aymaan, yash, kenny), so it reliably completes those — the demo is proving the hardware computes a real transformer correctly, not that the model is smart.
model/microgpt.py:19-41, 120-135 · checkpoint metadata
One transformer layer, coloured by which part of the chip does it
Every step of the model is owned by one unit. Every matrix multiply (Q, K, V, output projection, the two MLP layers, the final LM head) goes to the MXM. Normalisation, ReLU, softmax and residual adds go to the VXM. SXM flips K for attention. The ARM still handles a few steps on the real board: copying K/V into the cache, the causal mask, re-scaling K tiles, broadcasting rows, and picking the most likely character.
compile_microgpt_lpu.py:664-744 · synthesis/docs/lpu_attention_runtime_and_performance_audit.md:174-187
Token + position embeddings are added (VXM ) and normalised (VXM ). MXM computes Q, K, V — three 16→16 matrix multiplies.Attention: K flipped (SXM ), scores QKᵀ (MXM ), softmax (VXM ), weighted V (MXM ). Output projection (MXM ) + residual add (VXM ). MLP: 16→64 (MXM ), ReLU (VXM ), 64→16 (MXM ), add (VXM ). LM head 16→27 (MXM ) → 27 scores → ARM picks the top character.
The compiler turns a model into 974 rows and 4,920 ticks
974 MEM1 rows (weights)
4,920 instructions per token
1,020 more for attention
900 per loaded page
Training produces float weights. Packing groups them into 8-value blocks with one power-of-two scale (the model was trained with that rounding already in the loop). The compiler lays every tensor out at a fixed address and writes the full schedule: every memory read, bus choice and unit start, tick by tick, including the waiting. The result is a hex image and a C header the ARM program includes. Because the 4,920 instructions don't fit in the 1,024-slot instruction memory, the ARM loads them in pages of 900.
model/tools/compile_microgpt_lpu.py · synthesis/linux/include/microgpt_hps_image.h:29-50
Train in Python: 4,192 float weights, with 8-bit rounding applied in every forward pass. Pack: blocks of 8 + a scale → 974 rows at fixed addresses in MEM1. Compile: every read, bus choice and start, tick by tick → 4,920 96-bit instructions. Export to a hex image and a C header → the ARM loads it in pages of 900 .
Attention, done by ordinary units in sequence
61 ticks: K transpose
120 ticks: Q·Kᵀ scores
552 ticks: softmax
148 ticks: P·V
Attention asks: for the current character, how much should each earlier position matter? Q·Kᵀ gives a score per position; softmax turns scores into weights; the weighted sum of V is the answer. On LPULite there is no special attention block — SXM transposes K, MXM multiplies, VXM runs softmax, MXM multiplies again, repeated for each of 4 heads, then VXM merges the heads. The ARM divides scores by 2 (√4, done as an exponent change) and writes −127 into future positions so the model can't peek ahead.
section sizes microgpt_hps_image.h:39-50 · host steps microgpt_hps_runtime.c:833-1078
From X: Q , K , V (MXM). SXM transposes K → Kᵀ (61 ticks).MXM : Q·Kᵀ = one score per position (120 ticks).ARM: ÷2 and mask the future (−127). VXM : softmax (552 ticks). MXM : weights × V = this head's output (148 ticks). ×4 heads, then VXM merges.
Follow one character through the machine: v → i
~8,100 FPGA clock ticks per character (estimated)
~1.3 ms of FPGA time at 6.25 MHz
The prompt was “sat”; the chip already predicted “v”. Now it runs character “v” at position 4. The ARM writes the embeddings in, the FPGA runs two normalisations, the ARM broadcasts rows, the FPGA computes Q/K/V, the ARM copies K and V into the cache, then 4 attention heads alternate between ARM staging and FPGA sections, then the FPGA runs the projection, MLP and LM head. The ARM reads 27 scores back, picks “i”, and goes again.
synthesis/linux/src/microgpt_hps_runtime.c:1144-1474 (trace from FACTS_MODEL_FPGA §7)
Reading the prompt vs writing new letters: every letter is a full pass
4 + 2 passes for sat → satvik
16 positions max (context)
LPULite processes one position per full model pass, even for the prompt: the start token plus “s”, “a”, “t” is 4 passes (prefill), each writing its K and V into the cache. Then each new letter is one more pass (decode). “sat → satvik” needs only 2 decode passes because generation stops as soon as the text equals a trained name — “k” completes it. Counts are from the project's audit log.
synthesis/docs/lpu_attention_runtime_and_performance_audit.md:21-50
What it actually prints: sat → satvik
20.7 characters / second
145 ms prompt → completion
41.3 model passes / second
6 passes for sat → satvik
These outputs are from the project's own board logs. Generation stops when the text exactly equals one of the 9 trained names. The earlier audit measured about 28 passes/second; software-only changes on the ARM (no hardware change) brought it to 41.3.
README.md:124-125, 411-412 · lpu_attention_runtime_and_performance_audit.md:21-50
One board, two brains : an ARM computer and an FPGA
2 ARM Cortex-A9 cores running Linux
0xFF200000 where the chip appears in ARM memory
The Terasic DE1-SoC has a Cyclone V SoC: an ARM processor and FPGA fabric in one package. The ARM runs Linux and a C program. The LPU lives in the fabric and shows up to the ARM as a 64 KB window of memory addresses: write there and you're writing instructions, weights or scratch rows; one control address starts the chip for an exact number of clock ticks. Each 96-bit row is written as three 32-bit words — the third write commits it.
synthesis/linux/include/lpulite_hps_mmio.h:18-29 · lpulite_hps_mmio.c:24-28, 236-257
The ARM side: Linux, the terminal, the C runtime. The FPGA side: the LPU core built out of programmable blocks. They talk over a bridge : the chip's memories appear as addresses the ARM can read and write. Control address: “run exactly N ticks”, then the ARM polls until it's done.
The design fills the FPGA to the last block
100% logic (32,070 ALMs)
100% DSP multipliers (87)
61% memory bits
28:39 minutes to compile
An FPGA has a fixed amount of each kind of block. LPULite uses every logic element (ALM) and every hard multiplier (DSP block), 79% of the memory blocks and 61% of the memory bits. The matrix unit alone takes about 17,068 ALMs and the vector unit about 10,911 — of which the RoPE block, which this model never uses, takes about 2,083. Being completely full is one reason the design is hard to make faster or bigger on this board.
README.md:436-444 · fitter report (Quartus 25.1)
The chip ticks at 6.25 million times a second — because it has to
50 MHz (board clock) 9.84 MHz (fastest it can) 6.25 MHz (used)
9.84 MHz: fastest the logic can go (Fmax)
160 ns per tick at 6.25 MHz
58 ns of spare time per tick
100 MHz ASIC target
Every tick, signals must race from one set of flip-flops, through the logic, into the next before the clock edge. The slowest such path sets the maximum frequency: here about 9.84 MHz. The board's own 50 MHz clock is 5× too fast, so the design divides it by 8 and runs at 6.25 MHz. A custom chip would have far shorter wires; the ASIC showcase targets 100 MHz, but no timing result for it is published.
lpu_lite_de1_soc_hps.sta.rpt · synthesis/rtl/de1_soc_hps_top.sv:6-20 · asic/constraints.sdc:1
Where a character's time goes: mostly not the chip
~24 ms per model pass (41.3/s)
~1.3 ms of that: FPGA computing (estimate)
~95% ARM + bus traffic (estimate)
Counting the instructions for one character gives about 8,100 clock ticks, about 1.3 ms at 6.25 MHz. A full pass measured about 24 ms. So roughly 95% of the time is the ARM loading instruction pages, copying rows one 32-bit word at a time over the bridge, staging attention, and polling. That is an estimate from the code, not a measurement — but it shows the lesson of the memory wall again, at small scale: moving data dominates computing it.
cycle count from run_token + section sizes · speed README.md:124
From SystemVerilog to a silicon layout — as a showcase
130 nm SkyWater SKY130 open process
30 SRAM macros (1,024 × 8 bits each)
~4.1 mm square showcase core
0 published timing / area results
Chip design flow: write the circuit in a hardware language (SystemVerilog), synthesise it into standard logic cells, place and route them, and export GDS — the file a factory prints from. The asic folder sets this up for the open SKY130 process with OpenLane, shrinks memory to 1,024 rows, and uses 30 open-source SRAM blocks. The GDS files checked in are drawn illustrations of that floorplan (their own script says so); no routed result, timing or area is in the repo, and the config file the flow needs is missing. It is a showcase, not a tape-out.
asic/README.md · asic/make_showcase_gds.py:31-41 · asic/constraints.sdc
SystemVerilog : the circuit described as code (src/*.sv).Synthesis : turned into standard logic cells from a library.Place & route : cells and 30 SRAM blocks positioned, wires drawn.GDS : the layout file a fab prints. Here: drawn as a showcase, not a routed tape-out.
It printed “sata” , then nonsense — here's what fixed it
14 changes logged in the debugging notes
0 of them were “hardcode the answer”
When every output score was zero, the ARM's “pick the biggest” chose token 0, which is “a” — so it printed “sata”. Once data flowed, leftover values from the previous run made answers random. Once runs were clean, arithmetic bugs made answers consistently wrong — the worst was adding numbers with different scales as if they were the same. Each fix was a real data-movement, timing, scaling or scheduling repair.
synthesis/docs/lpu_debugging_and_generality_status.md:15-63
“sata” : memory was read a tick too early → all scores 0 → token 0 = “a”.random : instruction pages overran and old values survived → different answer each run.consistently wrong : scales mixed up — MAC added 2⁻⁵ and 2⁻⁸ numbers raw; quantizer off by 8; RMSNorm halves.correct : sat → satvik, matching the CPU reference.
How do you know a chip is right? Compare at every level
Hardware can't be debugged with print statements, so the project checks the same computation at several levels. A CPU INT8 reference in Python gives the expected numbers (not float PyTorch — rounding differs on purpose). cocotb testbenches drive the SystemVerilog in simulation and compare. A full-chip simulation uses the exact memory-mapped interface the ARM uses. Then the real board, with a hardware cycle counter proving it ran exactly the scheduled ticks. When something mismatches, move down one level at a time.
README.md:361-385 · synthesis/tests/microgpt_wrapper_tb.py
CPU reference : bit-accurate INT8 Python — all 9 names complete.Unit testbenches : each block (MAC, quantizer, softmax, RMSNorm) against worked numbers.Full-chip simulation : the same interface the ARM uses → “sat” gives “v”.Physical board : sat → satvik, and a cycle counter proves the exact tick count.Mismatch? model → schedule → interface → handshake → arithmetic , one level at a time.
What's real , what's partial , what it isn't
A genuinely impressive student-scale build A working, tested, statically scheduled INT8 transformer accelerator with its own compiler, running on a real board — and unusually honest documentation about its gaps.
None of this is a flaw in the idea; it is the difference between a learning project and a product. The repo itself lists every limitation shown here.
Same ideas , wildly different scale
What it would take to run a real 8-billion-weight model
~27,000× more weight memory than LPULite's 288 KiB
~35 first-gen Groq chips just to hold it
This is arithmetic, not a measurement. An 8B model at 1 byte per weight is 8 GB; LPULite's memories hold about 288 KiB. Groq's first chip holds about 230 MB, so even Groq needs ~35 chips for those weights (and hundreds for 70B). The compiler would have to handle many layers and wide matrices, the clock would need an ASIC, and the ARM's per-token orchestration would have to move onto the chip. The architecture ideas scale; the student implementation is a proof of the path.
arithmetic from README (memory), FACTS_CONTEXT §0 (230 MB/chip)
LPULite today: 4,192 weights in ~288 KiB of block RAM. An 8B model at INT8: 8 GB — ~27,000× more memory. Even Groq's gen-1 chip (230 MB) needs ~35 chips just for the weights. Plus: a general compiler, an ASIC clock, and the ARM's work moved on-chip.
Where to go from here
Every word, one line each
Nine months, one chain: every month breaks the one before it
12 h per week assumed (≈1.5 h weekdays + 4 h weekend)
14 Sep 2026 start · ends mid-June 2027
6 mo fast track: fold months 5–9 into 2
This plan copies the structure that made Milen Patel's video click: one continuous build, and each new part introduced only because the previous one ran into a wall. Month 1 builds a computer in a simulator. Month 2 asks what a gate physically is. Month 3 puts the computer on a real FPGA in Verilog. Month 4 asks why it is slow. Months 5–9 ask what an AI model computes, why CPUs are the wrong shape for it, and end with you designing a small accelerator and taping it out.
Every pick passed the Milen test — scored from its real narration
all months 1–2 months 3–4 months 5–9
40+ transcripts read
7 teaching moves checked, 0–2 each
1–3 /14: NPTEL & university lectures
The seven moves come from Milen's own transcript: start from what you know, introduce each part through the failure of the last, do it by hand before automating, show the surprise before naming it, admit confusion, keep one continuous build, and narrate conversationally. Captions for each candidate were downloaded and read (first ~12 minutes plus a mid-video concept). NPTEL and big university lectures were scored too, as controls; they land at 1–3 out of 14, which is why NPTEL appears in this plan only as problem sets and a certificate, never as the first place you meet an idea. For diodes and MOSFETs no video scored as a spine, so that month uses the interactive pages in this explainer plus hands-on circuits.
Scoring rules and quotes: research/MILEN_TEST.md, research/score_m12.md, score_m34.md, score_m59.md
How to not become someone who knows many names and nothing else
1 of 5 stages is watching
5 min recorded explanation per week
Every week runs the same loop. Watch the spine section once without pausing much. Rebuild the thing yourself without the video open. Break it on purpose and predict what goes wrong before you run it. Record a five-minute voice note explaining it as if to a friend — gaps show up instantly. Measure one number (a current, a delay, an Fmax, a speed) so the idea is attached to reality. If you cannot do stages 2–5, rewatch; do not move on.
Month 1: how does a switch become a computer ?
Month 2: what is a gate made of, and why is it slow and hot ?
Month 3: describe hardware in text and put it on a real chip
Month 4: what limits speed, and how do real CPUs go faster ?
Months 5–9: from what a model computes to your own accelerator on silicon
INT8 matrix-vector multiplier you build in month 6
1 tile Tiny Tapeout silicon in month 8
The AI half follows the same rule. Karpathy's videos scored highest of all the machine-learning material (14 and 13 of 14): he builds micrograd and then GPT by hand, and microgpt — the exact model LPULite runs — is the bridge. Once you have counted how many multiplies and bytes one token needs, the accelerator months answer the obvious question: how do you build hardware shaped like that? You then read LPULite with everything you have built, rebuild its matrix unit on your FPGA, and tape out a small version.
NPTEL: not the teacher — the problem sets and the certificate
closed Jul–Dec 2026 enrolment (4 Aug)
Jan 2027 next certificate run (not announced yet)
All NPTEL lectures are free to watch any time, but the 2026 July semester closed enrolment on 4 Aug, so the next certificate chance is the January–April 2027 run, expected to open for enrolment around Nov–Dec (not confirmed). By January you will be in Month 5, which is exactly when two January-only courses fit: GPU Architectures and Programming (IIT Kharagpur) and Memory Device Technology for AI/ML Computing (IIT Hyderabad). Use earlier courses only as worked problem sets for the week you are on.
Run dates from NPTEL's course-preview API, read 13 Sep 2026 · research/nptel.md
What to buy , what is free , and when you need it
≈₹5.5k total hardware for months 1–4 (approx.)
₹0 month 1 — simulators only
Month 1 needs nothing but a laptop: the Digital simulator (hneemann) lets you draw every part of Milen's computer and run it. Month 2 needs a breadboard kit and a cheap multimeter. Month 3 needs a Tang Nano 9K FPGA board, which Bruno Levy's RISC-V tutorial supports directly; everything also runs in simulation with Icarus Verilog if the board is late. A cheap USB logic analyser helps from Month 3 on. Tiny Tapeout costs money per tile and is optional. Prices are approximate; check before buying.
Month 1 : free simulators only.Month 2 : breadboard kit + multimeter.Month 3 : Tang Nano 9K FPGA (+ optional logic analyser).Month 8 : optional Tiny Tapeout tile.
Auto-saved every snap, note and highlight lands here by itself — nothing to file away.
Your notebook : every snap, note and highlight
All
Snaps
Notes
Highlights
⭐
Export page
Export JSON
Import
Nothing saved yet. Press s to snap the page, or n to jot a note — they show up here on their own.
Every tool we plugged in — and what we didn't
One tap on 🧪 Try it live near a page's heading opens the tool already set up for that idea, with one small task. Green = wired into a page. Amber = worth knowing about, but linked out instead of embedded. Red = looked at and rejected, with the reason.
Watch and take notes, lying down
▶ Watch
📄 Read
Open
Tip: if a site refuses to load here, open it in a new tab, then copy text or take a screenshot and paste it back into this page — it lands in your notebook.
Lying down mode: voice, read-aloud & the remote
1 · Get set up — one tap each
🎙 Turn on voice say commands out loud
🛌 Lazy mode a big remote, huge text
▶ Auto pages turn themselves
2 · Ten things you can just say
"next" moves to the next step
"back" the previous step
"go to roofline" jump straight to any page by name
"where am I?" hear the page and step out loud
"read this" hear the caption
"read more" hear the extra text too
"stop" stop reading out loud
"slower" slow the voice down
"lazy mode" open the big remote
"start from zero" back to page one
Tap any card above to try it right now, even without a microphone.
3 · The keys, if your hands are free
v voice
r read
l lazy mode
? help
k command box
Hold v to talk once, without switching voice on for good.
4 · Try the mic right now
🎙 start test
Say something…
1 tap every action on this page
6 giant buttons in the remote
12s default auto-advance
0 forms to fill in
Say "voice" or press v to start listening. After that, turning pages, hearing the caption, jotting a note down — every one of them is a single word or a single tap. No speech support in your browser (mostly Firefox)? Everything here still works by touch.
Logic lab: build it , don't just watch it
↺ Undo
↻ Redo
🐢 Slow motion
clock
2 Hz
⏭ Step
▶ Run
💾 Save to notebook
📂 Load saved circuit
⚠ unstable — oscillating
✅ Check my circuit
💡 Hint
↺ Reset challenge
Next challenge →
Waveform strip tap a wire's ◎ to record it here
Verilog lab: rebuild LPULite , one module at a time
A real Icarus Verilog compiler runs entirely in this tab (WebAssembly) — nothing leaves your browser. Write the module, hit Run grader , read the waveform.
Ladder
Playground
Loading Verilog compiler (~3 MB, first run only)…
▶ Run ⌘/Ctrl+↵
✓ Run grader
stuck? peek
reset to stub
waveform
‹ prev
next ›
console
run it to see output here.
waveform
zoom in
zoom out
fit
scroll to pan · ⌃/⌘+scroll or pinch to zoom · click a name to hide/show
move the mouse over the trace for a cursor readout.
no waveform yet — run a design that has $dumpfile/$dumpvars.
Practice: cards that remember what you forget
card
★ mine
tap · space · swipe to flip
❌Again key 1
🤔Hard key 2
✅Easy key 3
Nothing due right now — nice.
Review anyway
Practice gym: every topic , one question at a time