Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rvsim

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)

The demo

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.

What I got wrong, and how the simulator caught it

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.

Build and run

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 --trace

Options:

--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)

What is implemented

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.

Tests

$ make test
50/50 checks passed

Checked 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, x2 must be 0x002081B3.
  • 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, x0 staying zero, and the traversal demo asserted rather than merely printed.

Limitations

  • 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_LOAD segments.
  • 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 fence is a no-op.
  • Miss penalty is a fixed constant, not a model of DRAM timing with row buffers and bank conflicts.

Layout

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

About

A RISC-V emulator that reports what a program cost, with a five stage pipeline model and a cache that classifies every miss

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages