A RISC-V emulator that tells you what a program cost, not just what it computed.
Most emulators are functional only: they produce the right answer and say nothing about why one version of a program is faster than another. This one models a five stage pipeline and a configurable cache, so the output is a performance report you can act on.
$ ./rvsim programs/traverse_col.s --analyze
instructions retired 148105
cycles 328896 IPC 0.45 CPI 2.22
ideal (no stalls) 148105 45% of achieved
where the extra 180791 cycles went
load-use stalls 16384 forwarding cannot cover these
branch mispredicts 129 387 cycles lost
cache stalls 164020 cycles waiting on memory
branch predictor 99.2% over 16641 branches
cache (32 KB, 4-way, 64 B lines, 128 sets)
accesses 164489 148105 fetch, 16384 data
misses 16402 9.97% miss rate
compulsory 1026 first ever touch, unavoidable
capacity 0 working set exceeds the cache
conflict 15376 evicted by mapping, not by size
worst offending instructions
pc 0x00001030 16384 misses (99.9% of all)
Two programs sum the same 128x128 array. They execute exactly the same instructions in the same numbers. The only difference is which loop is on the outside.
./rvsim programs/traverse_row.s --analyze # for i { for j { a[i][j] } }
./rvsim programs/traverse_col.s --analyze # for j { for i { a[i][j] } }| instructions | cycles | IPC | miss rate | |
|---|---|---|---|---|
| row major | 148,105 | 175,136 | 0.85 | 0.62% |
| column major | 148,105 | 328,896 | 0.45 | 9.97% |
1.88x slower for the same work. Row major walks consecutive addresses, so one 64 byte line serves the next sixteen accesses. Column major strides 512 bytes, so every access lands on a fresh line.
Everyone reads that row major beats column major. This shows it happening, in cycles, and names the instruction responsible.
The report used to print "conflict misses dominate, more associativity would help", which is the textbook remedy. The classification is correct: a fully associative cache of the same capacity really would have hit, which is the definition of a conflict miss. The remedy is useless here, and the full sweep says so:
ways sets misses conflict
1 512 16434 15408
2 256 16402 15376
4 128 16402 15376
8 64 16402 15376
16 32 16402 15376
32 16 16402 15376
64 8 16402 15376 32x the associativity, not one miss avoided
128 4 8738 7712
256 2 1026 0
From 2-way to 64-way the miss count is identical. Not similar, identical. The reason is arithmetic rather than luck.
At a fixed total capacity, sets x ways is constant: 32 KB of 64 byte lines is 512 lines however you arrange them. The access stride is 512 bytes, which is 8 lines, so only every 8th set is ever touched, giving sets_used = sets / 8. The lines actually available to this access pattern are therefore
sets_used x ways = (sets / 8) x ways = 512 / 8 = 64 lines
Constant at every associativity. The working set needs 128 lines and receives 64 no matter what, so the miss count cannot move. It only breaks once sets falls below the 8 line stride and every access collapses into a single set, at which point the ways themselves become the capacity: 128 ways is borderline, 256 ways finally holds the whole column. At 32 KB, 256 ways means 2 sets, which is a fully associative cache in all but name, and nobody builds one.
The classification was right and the standard remedy was wrong. What actually fixes it is capacity:
cache misses conflict cycles
32 KB 16402 15376 328896
64 KB 1162 136 176496
128 KB 1026 0 175136 <- identical to row major
The report now says something accurate instead.
No dependencies beyond a C++14 compiler. No RISC-V toolchain needed, because the assembler is part of the project.
make # builds ./rvsim
make test # builds and runs the test suite
./rvsim programs/hello.s
./rvsim programs/sum.s --analyze
./rvsim programs/loadstore.s --traceOptions:
--analyze report cycles, stalls and cache behaviour
--trace print every instruction as it executes
--no-forwarding disable pipeline forwarding, to show its value
--cache-kb N cache size in KB (default 32)
--ways N associativity (default 4, 1 means direct mapped)
--line-bytes N line size (default 64)
--miss-penalty N cycles per cache miss (default 10)
RV32I interpreter. All 40 base integer instructions. Registers, memory, and the fetch-decode-execute loop, in cpu.cpp.
Assembler. Two pass, with labels, pseudo-instructions (li, la, mv, j, ret, nop, neg, not, beqz, bnez) and data directives (.string, .word, .byte, .space, .align). Writing a generator is the only real proof you understand an encoding: a decoder can be accidentally right when fed valid input, a generator has to place every bit deliberately.
Five stage pipeline model. IF, ID, EX, MEM, WB with:
- Load-use hazard detection. The one case forwarding genuinely cannot fix, because a load's value only exists after MEM and the next instruction needs it in EX. You cannot send a value backwards in time, so one stall cycle is unavoidable.
- Forwarding, switchable, so its benefit is measured rather than asserted.
- Two bit saturating branch predictor. It takes two consecutive surprises to change a prediction, so a loop running 100 times mispredicts once on exit instead of flip-flopping.
Cache simulator. Configurable size, associativity and line size, with LRU replacement and three way miss classification. The classification is the part that makes it useful: knowing you had 16,402 misses tells you nothing, knowing 15,376 were conflict misses tells you where to look.
It works by carrying a fully associative shadow cache of the same capacity alongside the real one:
| test | meaning | |
|---|---|---|
| compulsory | never seen this line before | unavoidable, no cache would have held it |
| conflict | the shadow would have hit | evicted because of where it maps |
| capacity | the shadow would also have missed | the working set is simply too large |
Syscalls. write and exit via ecall. That is the entire boundary between a program and an operating system: one instruction, an agreed register holding a number, and something on the other side that acts on it.
$ make test
50/50 checks passedChecked against external truth where possible, not against the code's own opinion:
- Encodings compared to hand-computed constants from the RISC-V spec, so a shared bug in the assembler and decoder cannot hide.
add x3, x1, x2must be0x002081B3. - Assemble then decode round trips, catching any bit the two halves place differently.
- Cache checked against arithmetic. Walking N bytes sequentially through 64 byte lines must give exactly N/64 compulsory misses. If the simulator disagrees with division, the simulator is wrong.
- Address splitting verified by construction. 32 KB, 4-way, 64 byte lines gives 128 sets, so addresses 8192 bytes apart share a set; five of them in a 4-way cache must evict the first.
- Pipeline stalls counted by hand for sequences whose hazards are known.
- Signed versus unsigned comparison, sign extension on narrow loads,
x0staying zero, and the traversal demo asserted rather than merely printed.
- RV32I only. No M (multiply), A (atomics), F/D (floating point), or C (compressed).
- No ELF loading yet. Programs come from the built in assembler. Adding ELF is mostly parsing program headers and copying
PT_LOADsegments. - The pipeline is a cost model, not a structural simulation. It tracks the previous two instructions and accounts for cycles exactly, rather than simulating five stages of latches. That keeps it readable while still producing meaningful numbers, but it will not show you a pipeline diagram.
- Single level cache. No L2, no prefetcher, no TLB.
- No memory ordering, since there is one hart and
fenceis a no-op. - Miss penalty is a fixed constant, not a model of DRAM timing with row buffers and bank conflicts.
include/rv32i.h instruction encoding, field extraction, immediates
include/cpu.h registers, memory, the interpreter loop
include/cache.h cache with three way miss classification
include/pipeline.h five stage cost model and branch predictor
include/assembler.h two pass assembler
src/rv32i.cpp decode and disassemble
src/cpu.cpp execute, memory, syscalls
src/cache.cpp the cache, plus the fully associative shadow
src/pipeline.cpp hazard detection and the cycle account
src/assembler.cpp parsing and encoding
src/main.cpp CLI and the performance report
programs/hello.s prints via ecall, uses la and .string
programs/sum.s a loop, to watch the branch predictor learn
programs/loadstore.s load-use hazards
programs/traverse_row.s the demo
programs/traverse_col.s the same work, one loop swap apart