Build Notes · RISC-V core · Draft
I built a RISC-V core and measured what one custom instruction costs
A small five-stage RV32I processor, one extra instruction for int8 dot products, and a question I could actually measure. Does the instruction pay for the area and clock speed it takes?
Simulation and post-route estimates only. The core has not run on a physical board yet.
Short answer
On a 256-element int8 dot product, the custom instruction is 5.2× faster than the strongest pure RV32I kernel, after counting the clock speed it costs. It takes 19% more logic cells and 11% of the median clock. Against the naive baseline it looks like 22.2×. Most of this post is about why the smaller number is the right one.
Time for one 256-element dot product. Shorter is better.
Why build one, and how
The project asks a question with a number at the end: what does it cost to add hardware for one specific job? Answering it means understanding how a pipeline handles one instruction that needs the result of the one right before it.
One thing up front. I built this with an AI coding assistant. The repo started as a placeholder README in October 2026. The RTL, tests and build flow were generated with its help. So this isn't a "look what I designed from scratch" post. It's about reading the core, measuring it and trying to break it. The README says the same thing.
Five stages, three ways to get it wrong
The core has the textbook five stages: fetch, decode, execute, memory and writeback. A new instruction can start every cycle. The hard part is that up to five instructions are in flight at once, and some need results that haven't reached the register file yet.
- Forwarding. If an instruction needs a value the one before it just computed, execute takes it straight from the memory or writeback stage. When both have it, the newer one wins.
- Load-use stall. A load's data only exists at the end of the memory stage. If the very next instruction needs it, the core holds fetch and decode for one cycle and lets a bubble through.
- Branch flush. Branches resolve in execute. The core keeps fetching the next address, so a taken branch or jump throws away the two instructions behind it. That's two lost cycles.
Those rules account for every cycle. The benchmark checks this identity on every run:
cycles = retired + load_use_stalls + 2 * flushes + 3
For the 256-element DOT4 kernel that's 454 + 0 + 2 × 64 + 3 = 585 cycles.
See it cycle by cycle from real RTL traces in the pipeline machine and the load-use explainer.
One extra instruction
RV32I has no multiply instruction at all. DOT4 treats each 32-bit register as four signed 8-bit numbers, multiplies them lane by lane and adds the four products.
dot4 rd, rs1, rs2 rd = a0·b0 + a1·b1 + a2·b2 + a3·b3
It lives in custom-0, the opcode space RISC-V leaves for extensions (opcode 0x0b, funct3 0, funct7 0). Every other custom-0 encoding traps. The four 8×8 multiplies and their adder tree fit inside the existing execute stage, so DOT4 forwards and stalls like any other instruction. Int8 dot products are the inner loop of a lot of quantized ML inference, which made this a reasonable thing to try.
Try your own bytes in the DOT4 machine.
What it buys, and what it costs
Counting cycles isn't enough. Extra logic in execute can lengthen the slowest path through the chip, which lowers the maximum clock. So the same core was built twice, once with DOT4 and once without, and both were routed for an iCE40 HX8K FPGA.
The baseline decides the headline
Without a multiply instruction, RV32I code multiplies in software. The benchmark first specified the classic shift-and-add loop, the one libgcc uses. Against that, DOT4 is 22.2× faster. That number is real, but it flatters DOT4, because shift-and-add is slow.
So the benchmark also has a quarter-square kernel. It turns a multiply into two table lookups and a subtraction:
a·b = ⌊(a+b)²/4⌋ − ⌊(a−b)²/4⌋
Illustration, toy numbers
a = 7, b = -3. ⌊(4)²/4⌋ = 4. ⌊(10)²/4⌋ = 25. 4 − 25 = -21, which is 7 × -3.
With a 512-word table of squares, it's the fastest pure RV32I kernel in the benchmark: 3,403 cycles, against 14,589 for shift-and-add. It's the honest baseline because it's the one DOT4 actually has to beat. It also uses 2,048 bytes of data memory that DOT4 doesn't, and this comparison doesn't charge it for that.
Counting the clock
| Fewer cycles | 3,403 ÷ 585 | 5.82× |
|---|---|---|
| Slower clock | 35.94 ÷ 40.37 MHz | 0.890 |
| Faster in time | 5.82 × 0.890 | 5.18× |
Rounded, that's 5.2×. Pairing the slowest and fastest route seeds of each build gives 4.6× to 5.7×.
The cost
| Resource | Off | On | Change |
|---|---|---|---|
| Logic cells | 4,767 | 5,671 | +904 (+19%) |
| LUT4s | 3,176 | 4,144 | +968 |
| Flip-flops | 1,692 | 1,692 | none |
| Clock (MHz) | 40.37 | 35.94 | −4.43 (−11%) |
These are FPGA logic cells, not chip area. The HX8K has no hard multiplier blocks, so DOT4's four multipliers are built from ordinary logic. No flip-flops are added because DOT4 finishes inside the existing execute cycle.
One route seed is noise
Place and route starts from a random seed. Same logic, different seed, different clock. On the shipped build the five seeds ranged from 34.63 to 37.60 MHz. That's why the headline is the median, 36.05 MHz, and not whichever seed ran first.
Post-route clock estimate (MHz) for each of five nextpnr seeds
How it was checked
- 253 programs (directed, random and generated) pass in both Icarus and Verilator. All 11,928 retired instructions and every byte written to memory match a separate Python reference model.
- Spike. The core matches the standard RISC-V ISA simulator on 39/39 RV32I tests from riscv-arch-test 3.10.0, plus 50 random programs compared retirement by retirement. That's an external check, not certification.
- Hazard bins. A generator wrote 108 programs to cover every producer, consumer and distance from one to three instructions: 327/327 bins hit.
- DOT4 sweep. All 262,144 single-lane input pairs run through the full core and match a scalar oracle. Sums across several lanes are covered by directed and random programs, not exhaustively.
- Mutants. 38 of 38 deliberate single-bug edits to the RTL are caught by the check meant to catch them. For example, making SRA shift in zeros instead of copying the sign bit makes the sra-01 test write
00800000where Spike writesff800000.
The mutants test the tests. A passing suite says little until it has been seen to fail on a real bug.
All of it as charts on the evidence page.
What isn't done yet
- No hardware run. A board image for the iCE40-HX8K Breakout builds, and its self-test passes in simulation. It routes at 38.21 MHz post-route against the board's 12 MHz oscillator. It has not run on a board yet, so every clock number here is an estimate.
- Formal checks are bounded. riscv-formal's checks pass to 20 cycles from reset, but they leave out CSR instructions, and the newer ACT4 tests aren't integrated. Spike is the only reference simulator.
- The extras aren't in these numbers. A ZICSR=1 build adds machine-mode traps and interrupts, and MEXT=1 adds a multiplier that takes 34 cycles per operation. The board image and every figure here use the default core.
- No hardware multiply baseline. The M extension now exists, but I haven't run the dot-product benchmark against it.
Next test: flash the board, run the self-test at 12 MHz and check the signature against simulation. Then run the benchmark on the MEXT=1 core and see how much of the 5.2× survives a real multiply.
Explore it
- Pipeline machineStep through real RTL traces, stage by stage.
- Load-use explainerWhy a load costs one bubble, and when.
- DOT4 machineFour byte lanes, four products, one sum.
- EvidenceSpeedup, cost, seeds and verification as charts.
- VideoThe short version.
- Source on GitHubRTL, tests, reports and the full README.
Reports: benchmark.json, ablation.json, implementation.json, isa-compare.json, verification.json, dot4-sweep.json, mutation.json, board.json.