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.

RV32I shift-and-add14,589 cycles at 40.37 MHz
361.4 µs
RV32I byte loads + shift-and-add14,077 cycles at 40.37 MHz
348.7 µs
RV32I quarter-square3,403 cycles at 40.37 MHz
84.3 µs
DOT4585 cycles at 35.94 MHz
16.3 µs
Each bar is simulated cycles divided by its core's median post-route clock over nextpnr seeds 1–5. RV32I kernels run on the core built without DOT4. DOT4 runs on the core built with it. The comparison that matters is the yellow bar against the black one. Estimates from Icarus simulation and nextpnr, not board measurements. Source: ablation.json, benchmark.json.

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

How the 5.2× speedup is computed
Fewer cycles3,403 ÷ 5855.82×
Slower clock35.94 ÷ 40.37 MHz0.890
Faster in time5.82 × 0.8905.18×

Rounded, that's 5.2×. Pairing the slowest and fastest route seeds of each build gives 4.6× to 5.7×.

The cost

Same core with DOT4 off and on. Median over nextpnr seeds 1–5, iCE40 HX8K.
ResourceOffOnChange
Logic cells4,7675,671+904 (+19%)
LUT4s3,1764,144+968
Flip-flops1,6921,692none
Clock (MHz)40.3735.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

No DOT4compared build
DOT4compared build
DOT4, shippedmake timing, headline
Dots are seeds, the tick is the median. The axis runs 33 to 45 MHz, not from zero, because position is what's compared here. The DOT4 and no-DOT4 ranges do not overlap, so the clock cost is bigger than seed noise. The two compared builds set the DOT4 parameter the same way. Setting it differently changed the netlist of identical logic and moved the median by -0.1 MHz, which is why the shipped build sits apart. Source: ablation.json.

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 00800000 where Spike writes ff800000.

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

Reports: benchmark.json, ablation.json, implementation.json, isa-compare.json, verification.json, dot4-sweep.json, mutation.json, board.json.