RISCVM

RISCVM is a virtual machine for 64-bit RISC-V. RISCVM directly executes machine code. It comes with a handy custom assembler which understands the standard assembly format (including pseudo instructions) emitted by the GCC and Clang toolchains.

The source code is available on github.

Experiment with RISC-V as bytecode

I got the idea for this project after doing the riscvcpu project. That project made me realize how incredibly simple the RISC-V ISA semantics are. From an emulation perspective, that relative simplicity made me think it would be a potential good fit as the target for the compile time execution engine in the metagen compiler. What’s cool is that this would mostly unify codegen for compile time code and the final executable (assuming the underlying system is running a RISC-V CPU).

Having the same target for both compile time executed code and final executable further drives home the point that compile time execution is no different than its runtime counterpart. I like that idea, even if all it accomplishes is a simpler compiler backend (no need to support an added extra compile time target). I wonder if it would be possible to avoid the interpreter for compile time code execution altogether and just execute native code. It makes operating on the AST and querying compiler internals much more difficult. Maybe some clever compiler API accessible through IPC could work, but I struggle to envision it.

In any case, the jury is still out on whether RISC-V is a good target for compile time execution or not. I will investigate this further once metagen has progressed further. There is however some evidence to suggest using RISC-V for this purpose (software interpretation) is a bad idea. For instance, while RISC-V instruction encoding is conceptually quite simple, doing a decode requires a bunch of bitwise operations:

u8 opcode = inst & 0b1111111;
// When the opcode is an arithmetic operation:
u8 rd = (inst >> 7) & 0b11111; // return register
u8 funct3 = (inst >> 12) & 0b111;
u8 rs1 = (inst >> 15) & 0b11111; // rs1
u8 rs2 = (inst >> 20) & 0b11111; // rs2
u8 funct7 = (inst >> 25) & 0b1111111;

A hardware implementation could easily decode this efficiently in a fraction of a cycle. In software, each bitwise op required for the decode and the stores to intermediate memory becomes separate instructions (11 bitwise ops + 6 stores). That’s quite a lot, and that’s not counting the switch on the opcode, funct3 and maybe the funct7 as well.

Bytecode is an instruction set created for the exact purpose of being executed by software. With software execution as the stated purpose, different trade-offs, like that for decode, come into play. Decoding my custom bytecode instruction set for the metagen compiler is just a switch statement coupled with reading from the instruction stream if the instruction requires an immediate value. I should note this is not a fair comparison as my custom bytecode is stack based, and as such has no registers to decode. This is more to illustrate the point that the decisions that make RISC-V a brilliant ISA for hardware implementation does not necessarily equate to it being a great bytecode alternative.

fib(32) experiment

I benchmarked (in a very loose sense of the word! not rigorous in the slightest!) the wall time of computing the 32nd fibonacci number using the classic recursive algorithm comparing RISCVM and the custom bytecode interpreter for the metagen compiler. This is not an apples-to-apples comparison because the two implementations of the recursive fibonacci algorithm will be in two different languages. RISCVM takes as input an assembly file, produced by compiling a C implementation of the algorithm through godbolt and choosing GCC with RISC-V 64-bits and as the target with no optimizations. The metagen compiler takes in a high-level source file implementation and has to go through slightly more involved parsing, typechecking, and compiling down to bytecode before executing. That said, execution time will dominate the total time hiding most of the differences. Both source files are listed at the end of the page.

Results:

Interpreter Execution time (s)
metagen bytecode interpreter 0.254
RISCVM 0.602

Despite the metagen bytecode interpreter having a very primitive compiler and being stack based, it outperforms RISCVM by a factor of about 2.4x.

fib(32) RISC-V implementation:

main:
        addi    sp, sp, -16
        sd      ra, 8(sp)
        sd      s0, 0(sp)
        addi    s0, sp, 16
        li      a0, 32 # fib of a0
        call    fib
        halt           # Special pseudo instruction used by RISCVM
fib:
        addi    sp, sp, -32
        sd      ra, 24(sp)
        sd      s0, 16(sp)
        addi    s0, sp, 32
        sw      a0, -24(s0)
        lw      a1, -24(s0)
        li      a0, 1
        blt     a0, a1, .LBB0_2
        j       .LBB0_1
.LBB0_1:
        lw      a0, -24(s0)
        sw      a0, -20(s0)
        j       .LBB0_3
.LBB0_2:
        lw      a0, -24(s0)
        addi   a0, a0, -1
        call    fib
        sd      a0, -32(s0)
        lw      a0, -24(s0)
        addi   a0, a0, -2
        call    fib
        mv      a1, a0
        ld      a0, -32(s0)
        add    a0, a0, a1
        sw      a0, -20(s0)
        j       .LBB0_3
.LBB0_3:
        lw      a0, -20(s0)
        ld      ra, 24(sp)
        ld      s0, 16(sp)
        addi    sp, sp, 32
        ret

fib(32) metagen implementation:

func fib(n: s32): s32
begin
    if n = 0 then return 0
    if n = 1 then return 1
    return fib(n - 1) + fib(n - 2)
end

func main(): s32
begin
    print fib(32)
    return 0
end
Last updated: Jun 21, 2025