How to Model a CPU in Lean
To prove equivalence with a RISC-V CPU, we first need a RISC-V CPU modeled in Lean. Luckily for us, the teams at Galois and Cambridge University have done the hard work for us. Given a Sail specification of a RISC-V CPU, they translated the Sail specification into non-executable Lean code suitable for theorem proving. The details of how to get this code can be found here.
Our starting point will be to understand this transpilation. At its core, a CPU is just a state machine whose state is described by the register file, memory, program counter, control/status registers, and so on1. Instructions describe the ways the machine can transition between states. Of course, there are also the mechanics of how a state transition physically works. For example, on a silicon chip, the CPU emits an address on a bus, and the memory decoder reads this address, selects the matching memory cell, and drives its stored bits back onto the data bus where the CPU latches them into a register. We will not focus on the mechanics of how state transitions happen on a physical chip or in software emulation. Our concern is what happens before and after a state transition. From here on, we refer to the trusted Sail-to-Lean transpilation as Sail code.
See the RISC-V ISA for a detailed description of state.
State
The RISC-V state, abbreviated as SailState, is given by:
abbrev SailState := SequentialState RegisterType trivialChoiceSource
More formally, the state is defined as a dependent inductive type with one constructor. So to fully define the state, one must specify a term of the function type RegisterType and a term of ChoiceSource.
Here, SequentialState describes the full machine state from the Sail model.
It is parameterized2 by the function RegisterType : Register → Type and a ChoiceSource.
The register file and memory are fields of the structure with details described below.
structure SequentialState (RegisterType : Register → Type) (c : ChoiceSource) where
regs : Std.ExtDHashMap Register RegisterType
choiceState : c.α
mem : Std.ExtHashMap Nat (BitVec 8)
tags : Unit
cycleCount : Nat
sailOutput : Array String
regs (line 468) models the register file with Std.ExtDHashMap Register RegisterType — a hash map where the keys are of type Register and the value type of the register depends on the key.
That is, if the key is k : Register, the corresponding value has type RegisterType k.
This is what makes it a dependent hash map rather than an ordinary one, and also why the state is declared with parameters RegisterType and ChoiceSource.
Register is an inductive with ~180 variants — one for every named register in the RISC-V spec:
inductive Register : Type where
| hart_state
| satp
| PC
| nextPC
| x1 | x2 | x3 | ... | x31 -- general-purpose registers
-- ... CSRs, vector registers, debug state, ~180 variants total ...
Or, in Lean terms, the term of type Register → Type.
RegisterType is the exact function3 that tells the hash map what the type of the value is for each key:
abbrev RegisterType : Register → Type
| .hart_state => HartState
| .mhpmcounter => (Vector (BitVec 64) 32)
| .tlb => (Vector (Option TLB_Entry) (2 ^ 6))
| .PC => (BitVec 64)
| .x1 => (BitVec 64) -- all x1–x31 map to BitVec 64
| .x2 => (BitVec 64)
-- ...
So regs[.PC] has type RegisterType .PC = BitVec 64, while regs[.tlb] has type RegisterType .tlb = Vector (Option TLB_Entry) (2 ^ 6). Each register gets exactly the type it needs.
The general-purpose registers x1-x31 are of type BitVec 64.
The mem field models byte-addressable memory as a hash map from Nat addresses to BitVec 8 bytes.
The second parameter c has type ChoiceSource, a structure that provides a way to pick default values for primitive types.
structure ChoiceSource where
(α : Type)
(nextState : Primitive → α → α)
(choose : ∀ p : Primitive, α → p.reflect)
To define SailState, we use trivialChoiceSource, which is defined as:
def trivialChoiceSource : ChoiceSource where
α := Unit
nextState _ _ := ()
choose p _ :=
match p with
| .bool => false
| .bit => 0
| .int => 0
| .nat => 0
| .string => ""
| .fin _ => 0
| .bitvector _ => 0
More simply, this says that all default values are the expected ones. Next, we describe how one steps the CPU or performs a state transition.
The Sail Monad
Step transitions are modeled using Lean's built-in error-state monad (EStateM ε σ α), with the error and state types.
Under the hood EStateM ε σ α is just a function σ → Result ε σ α that takes a state and returns either .ok v newState or .error e newState, where v : α and e : ε.
See the official EStateM documentation for further details.
The Sail library defines a generic version called PreSailM, leaving the register types, choice source, and user-exception type as parameters:
abbrev PreSailM (RegisterType : Register → Type) (c : ChoiceSource) (ue : Type) :=
EStateM (Error ue) (SequentialState RegisterType c)
SailM is just PreSailM instantiated with the user-exception type ue as exception (so the error type ε becomes Error exception) and the state σ as SailState defined above.
abbrev SailM := PreSailM RegisterType trivialChoiceSource exception
Notice that SailM fixes the error type ε and state type σ, but says nothing about the result type α.
That is deliberate: EStateM ε σ α takes three type arguments, and here we have only supplied the first two.
SailM is therefore a function Type → Type.
The remaining α is filled in at each use site, giving a different "step computation that yields an α":
SailM ExecutionResult -- a full instruction step, yielding its execution result
SailM (BitVec 64) -- a step that reads a register, yielding a 64-bit word
SailM Unit -- a step run only for its effect on state
So SailM is the "RISC-V step computation" constructor, and SailM α is "a step computation that produces an α". This is the same currying you already use when you write List (a Type → Type) and only later List Nat.
Errors
These variants model all the ways the execution part of the fetch-decode-execute cycle can go wrong.
Error is from the Sail library:
inductive Error (ue : Type) where
| Exit
| Unreachable
| OutOfMemoryRange (n : Nat)
| Assertion (s : String)
| User (e : ue)
The User constructor wraps a user-exception type ue, which is instantiated with exception from the RISC-V model:
inductive exception where
| Error_not_implemented (_ : String)
| Error_internal_error (_ : String)
| Error_reserved_behavior (_ : String)Executing Instructions
With SailM defined above, to execute instructions we must provide two things:
- The type for $\alpha$. For RISC-V instructions, $\alpha$ will invariably be
ExecutionResult. This is the final output after execution. - Once $\alpha$ is defined, a full description of the state transition.
Execution Results: The Return Type After Execution
inductive ExecutionResult where
| Retire_Success (_ : Unit)
| ExecuteAs (_ : instruction)
| Enter_Wait (_ : WaitReason)
| Illegal_Instruction (_ : Unit)
| Virtual_Instruction (_ : Unit)
| Trap (_ : (Privilege × ctl_result × xlenbits))
| Memory_Exception (_ : (virtaddr × ExceptionType))
| Ext_CSR_Check_Failure (_ : Unit)
| Ext_ControlAddr_Check_Failure (_ : ext_control_addr_error)
| Ext_DataAddr_Check_Failure (_ : ext_data_addr_error)
| Ext_XRET_Priv_Failure (_ : Unit)
In the happy path, instructions return RETIRE_SUCCESS (which is Retire_Success ()).
To make this idea more concrete, we look at the actual execution of a native RISC-V instruction, ADDIW.
The opcode signature is ADDIW rd, rs1, imm.
In pseudocode, it computes:
# ADDIW rd, rs1, imm (RV64I)
tmp = rs1 + sext(imm) # 64-bit wrapping add of the sign-extended 12-bit imm
rd = sext( tmp[31:0] ) # keep the low 32 bits, sign-extend back to 64
That is, the add is done in 64 bits but only the low 32 bits are kept and then sign-extended back to 64. Below is the trusted Sail implementation of the pseudocode above.
def execute_ADDIW (imm : (BitVec 12)) (rs1 : regidx) (rd : regidx) : SailM ExecutionResult := do
let result ← do (pure ((← (rX_bits rs1)) + (sign_extend (m := 64) imm))) -- m1
(wX_bits rd (sign_extend (m := 64) (Sail.BitVec.extractLsb result 31 0))) -- m2
(pure RETIRE_SUCCESS) -- m3
Next we try and parse the above code. It might look simple, but under the hood a lot is going, and understanding it is key for understanding our proof tactics later on.
We will not always discuss do notation in such detail, but we choose to do so here because all CPU implementations are written in do notation. Thus, it's important to get used to mentally translating do notation into function composition.
Function Signature
The function signature says the inputs are a 12-bit immediate value imm and general-purpose source and destination registers rs1 and rd, which are of type4 regidx.
For our purposes, regidx behaves just like a BitVec 5, but is given its own name to record that this particular 5-bit vector is a general-purpose register index. Here the condition is false, so the if reduces through the else branch to 5, and regidx is just Regidx (BitVec 5).
This aligns with the RISC-V spec that there are $2^5 = 32$ general-purpose registers.
inductive regidx where
| Regidx (_ : (BitVec (if ( false : Bool) then 4 else 5)))
To parse the body, we need a little do notation.
Lean is a functional language, but CPU execution is inherently stateful, so do notation lets us write imperative-looking code that the Lean elaborator desugars back into ordinary functional code.
The mental model is that each line of the block is a SailM α computation — and recall these are just functions: feed one the current state and it either returns .ok with a new value and state, or .error with an error value and state.
If a line errors, the computation short-circuits and the whole block returns that error; on success, the returned value is passed along to the next line.
Applying this to the whole body, the three do lines desugar into one nested >>= chain:
Parsing The Body
We rewrite the outer do block as
do
result ← m1
m2
m3
Here m1, m2, and m3 are the monadic expressions marked in the code above.
m1 := do (pure ((← (rX_bits rs1)) + (sign_extend (m := 64) imm)))
m2 := wX_bits rd
(sign_extend (m := 64) (Sail.BitVec.extractLsb result 31 0))
m3 := pure RETIRE_SUCCESS
Following the guide on how to desugar do notation, the first desugaring step gives:
m1 >>= (fun result =>
do
m2
m3
)
A reminder that, a function m : Type → Type can be used as a monad only when Lean has a Monad m instance5 for it, which provides pure and bind.
The notation a >>= f is notation for bind a f, where a : m α and f : α → m β.
For EStateM, read bind as: run a; if it succeeds, pass its returned value to f to produce the next monadic computation.
The next desugaring step is as follows:
m1 >>= (fun result =>
m2 >>= (fun _ =>
do
m3
)
)
The next step is:
m1 >>= (fun result =>
m2 >>= (fun _ =>
m3
)
)
We expand only m2 and m3 first. As m1 is itself a do block, we do expand that next individually, and then give you the combined version.
m1 >>= (fun result =>
-- m2 expanded
(wX_bits rd (sign_extend (m := 64) (Sail.BitVec.extractLsb result 31 0))) >>= (fun _ =>
-- m3 expanded
pure RETIRE_SUCCESS
)
)
Now parsing m1
do (pure ((← (rX_bits rs1)) + (sign_extend (m := 64) imm)))
as m1 is a single statement it desugars to
(pure ((← (rX_bits rs1)) + (sign_extend (m := 64) imm)))
The next desugaring step hoists the nested ← out of the argument to pure:
(rX_bits rs1) >>= (fun rs1_value =>
pure (rs1_value + sign_extend (m := 64) imm)
)
Putting the desugared m1 back into the outer chain gives:
((rX_bits rs1) >>= (fun x =>
pure (x + sign_extend (m := 64) imm)
)) >>= (fun result =>
(wX_bits rd (sign_extend (m := 64) (Sail.BitVec.extractLsb result 31 0))) >>= (fun _ =>
pure RETIRE_SUCCESS
)
)
-
rX_bitsis a monadic computation: given the current state, it returns a 64-bit value (the contents of registerrs1)6 and threads the state unchanged. So the output is reallySailM (BitVec 64). -
sign_extendjust sign-extends the immediate value and returns a 64-bit value. -
The next line,
wX_bits ..., runs only if the first line succeeds all the way through. It is itself another bind: if we successfully write the sign-extended low 32 bits ofresult(that is,sext(result[31:0])) into therdkey of the register hash map, and that write succeeds -
the final
pure RETIRE_SUCCESSreturnsRETIRE_SUCCESSwithout changing the state further.
Thus, wX_bits ... is the only monadic computation that changes state.
We refer the reader to the Read and Write Chain appendix for the full code blocks.
pure z just expands to .ok z s when fed the current state s. It never errors.
In simple terms, this means pure and bind have been implemented for m.
We are assuming that the read of registers will always succeed.
As the register file is a hash map, Lean requires a proof that the key already exists in the hash map. Without this proof, we are unable to get the bits inside the hash map, and the computation errors as shown in the appendices.
Of course, in a real physical CPU, the register file is not a hash map and we do not have to say that general-purpose register x exists. So although not shown here, to prove theorems we will have a general assumption that the general-purpose registers can be read.
Jolt CPU
This state may change as we model other parts of Jolt and decide whether to include the remaining Rust fields.
The Jolt CPU as modeled in Rust is shown below.
Most fields have an equivalent copy in the Sail state, so the Jolt state can and should be built on top of SailState with some extra fields.
For the purposes of proving expansion equivalence, this is enough9.
What we really need to model is the virtual registers and the virtual register allocation.
Jolt adds virtual registers and extra instructions.
#[derive(Clone, Debug)]
pub struct Cpu {
clock: u64,
pub(crate) privilege_mode: PrivilegeMode,
wfi: bool,
pub x: [i64; REGISTER_COUNT as usize], // Missing in Sail
#[allow(dead_code)]
f: [f64; 32],
pub(crate) pc: u64,
csr: [u64; CSR_CAPACITY],
pub mmu: Mmu,
reservation: u64,
is_reservation_set: bool,
reservation_width: ReservationWidth,
_dump_flag: bool,
unsigned_data_mask: u64,
// pub trace: Vec<Cycle>,
pub trace_len: usize, // Missing in Sail
executed_instrs: u64, // "real" RV64IMAC cycles
active_markers: FnvHashMap<u32, ActiveMarker>,
pub vr_allocator: VirtualRegisterAllocator, // Missing in Sail
/// Call stack tracking (circular buffer)
call_stack: VecDeque<CallFrame>,
/// Advice tape for runtime advice system
pub advice_tape: AdviceTape, // Not modeled in Lean
#[cfg(feature = "field-inline")]
pub field_registers: FieldRegisterFile,
}
This gives us the following definition of Jolt state in Lean.
We do not model the virtual registers as a hash map; instead, we model them as a function from BitVec 7 -> BitVec 64.
structure SailJoltState where
sail : SailState
vregs : BitVec 7 → BitVec 64 := fun _ => 0
Note that we have duplication here.
The sail field already has the regidx for the general-purpose registers, and the first 32 slots of vregs are also the same.
To make proofs easier to manage, we directly use the Sail registers when writing to general-purpose registers, and block off writing to the first 32 registers.
-- The general-purpose registers are in the Sail hash map already,
-- so we should never write to vr 0-31.
def WritableVReg (vr : BitVec 7) : Prop :=
¬ vr.toNat < 32
-- We cannot write to the first 32 registers, as we use xreg for them.
def writeVReg (vr : BitVec 7) (val : BitVec 64) : JoltMonad Unit :=
if vr.toNat < 32 then
throw (Error.Assertion "writeVReg: architectural xreg address")
else
modify fun js => { js with vregs := fun r => if r = vr then val else js.vregs r }Virtual Registers
Not all virtual registers are the same. The figure below shows the classification of the different registers.
Virtual register allocation is modeled directly in the expansion. The logic used in Jolt is as follows:
- Before an instruction executes, all registers from 40 to 47 are marked free.
- When Jolt asks for a scratch virtual register, we return the lowest-indexed register marked free. For example, at the start, with all scratch registers marked free, the first free register is 40. Once this is returned, 40 is marked used until it is explicitly marked free again. The next free register is then 41, and so on.
We explicitly model this in JoltBytecode/JoltISA/VirtualRegisters.lean, with proof-side facts in JoltBytecode/InstructionEquivalence/ProofSupport/VirtualRegisters.lean, so that expansion proofs can check that the allocator neither runs out of scratch virtual registers nor reuses one that is still live.
It might seem confusing that some virtual registers represent control/status registers, while there is also a separate field for CSRs.
This is because the Jolt CPU emulator is a fork of a RISC-V emulator.
This means that the emulator is able to emulate non-expanded vanilla RISC-V programs.
However, when reading a Jolt CPU program, control/status register reads and writes are done via the virtual register file.
ECALL, EBREAK, MRET, CSRRW, CSRRS, and CSRRC are all expanded (as we show later).
Jolt Monad (Stepping the CPU)
We step the Jolt CPU using the same EStateM infrastructure.
The only difference between this and the RISC-V CPU is that we use SailJoltState instead of SailState.
abbrev JoltMonad (α : Type) := EStateM (Error exception) SailJoltState α
Next, we define the instructions natively supported by the Jolt CPU: a subset of RISC-V instructions, plus a few new virtual instructions.
The source of truth is the Rust instruction definition in tracer/src/instruction/mod.rs.
These are modeled in Lean as the inductive type JoltISA.Instr.
inductive Instr where
| NoOp
| ADDI (dst : Dst) (src : Src) (imm : BitVec 12)
| ANDI (dst : Dst) (src : Src) (imm : BitVec 12)
| ORI (dst : Dst) (src : Src) (imm : BitVec 12)
| XORI (dst : Dst) (src : Src) (imm : BitVec 12)
| LUI (dst : Dst) (imm : BitVec 64)
| JAL (dst : Dst) (imm : BitVec 21)
| BEQ (lhs rhs : Src) (imm : BitVec 13)
| ADD (dst : Dst) (lhs rhs : Src)
| SUB (dst : Dst) (lhs rhs : Src)
| MUL (dst : Dst) (lhs rhs : Src)
| VirtualMULI (dst : Dst) (src : Src) (imm : BitVec 64)
-- ... ~55 more opcodes, including the Virtual* family ...
| VirtualAssertMulUNoOverflow (lhs rhs : Src)
| VirtualAssertLTE (lhs rhs : Src)
deriving Repr
Note that unlike the RISC-V Cpu, these instructions can read/write to both risc-v registers and virtual registers.
/-- An instruction source operand: either a virtual register or an
architectural Sail register. -/
inductive Src where
| vreg : VReg → Src
| xreg : regidx → Src
deriving Repr
/-- An instruction destination operand: either a virtual register or an
architectural Sail register. -/
inductive Dst where
| vreg : VReg → Dst
| xreg : regidx → Dst
deriving Repr
The inductive type above only lists the different instructions supported by the Jolt ISA. It does not describe how the Jolt state changes when these instructions are executed. We do that next.
Executing Jolt Instructions
In JoltBytecode/JoltISA/Semantics.lean we define execInstr, a function that maps a JoltISA.Instr to a JoltMonad ExecutionResult.
That is, for each native or virtual Jolt instruction, it defines the state update function.
Remember, JoltMonad is just a function.
To execute the instruction, we have to "run" the function with a current state.
def execInstr : Instr → JoltMonad ExecutionResult
| .NoOp =>
pure RETIRE_SUCCESS
| .ADDI dst src imm => do
let x ← readSrc src
writeDst dst (x + sign_extend (m := 64) imm)
pure RETIRE_SUCCESS
| .ANDI dst src imm => do
let x ← readSrc src
writeDst dst (x &&& sign_extend (m := 64) imm)
pure RETIRE_SUCCESS
| .ORI dst src imm => do
let x ← readSrc src
writeDst dst (x ||| sign_extend (m := 64) imm)
pure RETIRE_SUCCESS
-- ... remaining native and virtual instruction cases ...
| .VirtualAssertLTE lhs rhs => do
let x ← readSrc lhs
let y ← readSrc rhs
if x.toNat ≤ y.toNat then
pure RETIRE_SUCCESS
else
throw (Error.Assertion "VirtualAssertLTE")
You might ask: where did this execution logic come from? For now, it is hand-translated from the Rust source code. For each instruction, the process is as follows:
Find the corresponding exec block and model the Rust code in Lean. For example, here are the Lean semantics and Rust semantics side by side for ADDI.
| .ADDI dst src imm => do
let x ← readSrc src
writeDst dst (x + sign_extend (m := 64) imm)
pure RETIRE_SUCCESSThis eyeballing of Rust and Lean code is prone to bugs. As a temporary solution, we always have an AI agent double-check the two code blocks. In the coming weeks, we will have an auto-translator from Rust to Lean for instruction execution semantics.
fn exec(&self, cpu: &mut Cpu, _: &mut <ADDI as RISCVInstruction>::RAMAccess) {
cpu.write_register(
self.operands.rd as usize,
cpu.sign_extend(
cpu.x[self.operands.rs1 as usize].wrapping_add(normalize_imm(self.operands.imm)),
),
);
}
As a result of the translation above, some natively supported instructions, such as ADDI, have both a Jolt execution and a trusted Sail execution.
We want them to agree on how they change Sail state.
This gives us an equivalence theorem for every Jolt instruction that is natively supported on a RISC-V CPU.
Continuing with the ADDI example, it looks like the following:
/-- Main native `ADDI` equivalence statement. -/
def addiInstrEqSailStatement
(imm : BitVec 12)
(rs1 rd : regidx)
(js : SailJoltState)
(_h : UnarySourceReadWithLinkedCSRs rs1 js) : Prop :=
System.systemProjectResult
((JoltISA.execInstr (.ADDI (.xreg rd) (.xreg rs1) imm)).run js) =
((execute_ITYPE imm rs1 rd iop.ADDI).run js.sail)
The statement above translates to the following mathematical statement.
Given a 12-bit immediate value imm, a readable source register rs1, a writable destination register rd, assumptions h, and an initial Jolt state js, running Jolt's ADDI and projecting down to the Sail state gives the same state as running the trusted Sail ADDI on the input state js.sail.
The exact assumption bundle used for ADDI is UnarySourceReadWithLinkedCSRs rs1 js, which bundles two sets of assumptions together.
The structure UnarySourceReadAssumptions just says we can read rs1.
As the register file is a hash map, this is a Lean implementation detail: we have to say that the key exists in the hash map.
The other structure is LinkedCSRRegisterAssumptions.
/-- Assumptions for an instruction that reads one architectural source register. -/
structure UnarySourceReadAssumptions (rs1 : regidx) (js : SailJoltState) where
rs1_val : BitVec 64
rs1_read : rX_bits rs1 js.sail = .ok rs1_val js.sail
/-- Persistent CSR virtual registers agree with Sail's architectural CSR state. -/
private structure LinkedCSRRegisterAssumptions (js : SailJoltState) where
mstatus_matches : Assumptions.MstatusVRegMatchesSail js
mtvec_matches : Assumptions.MtvecVRegMatchesSail js
mscratch_matches : Assumptions.MscratchVRegMatchesSail js
mepc_matches : Assumptions.MepcVRegMatchesSail js
mcause_matches : Assumptions.McauseVRegMatchesSail js
mtval_matches : Assumptions.MtvalVRegMatchesSail js
/-- One source-register read plus persistent CSR virtual-register agreement. -/
structure UnarySourceReadWithLinkedCSRs
(rs1 : regidx) (js : SailJoltState)
extends UnarySourceReadAssumptions rs1 js,
LinkedCSRRegisterAssumptions js
It simply says that the virtual registers are equal to the Sail registers for the corresponding control/status registers.
The only thing left to explain is systemProjectResult.
systemProjectResult removes the Jolt-specific state and keeps only the sail state, which can be compared with the RISC-V CPU.
Before we do that, we must map the virtual registers that represent control/status registers in Jolt to the Sail control/status registers.
Without this step, a program could arbitrarily write to these registers, and dropping vregs would make the Sail states appear to agree.
We need to tell Lean that those virtual registers represent the same values as the Sail registers.
/-- Overlay Jolt's persistent virtual CSR registers onto the generated Sail CSR
register keys.
This is only a proof projection: the Rust-faithful Jolt programs still write
Jolt virtual registers, and the Sail specification still reads/writes generated
Sail registers. -/
def systemProject (js : SailJoltState) : SailState :=
{ js.sail with
regs :=
((((((js.sail.regs
|>.insert Register.mtvec (js.vregs JoltISA.trapHandlerVReg))
|>.insert Register.mscratch (js.vregs JoltISA.mscratchVReg))
|>.insert Register.mepc (js.vregs JoltISA.mepcVReg))
|>.insert Register.mcause (js.vregs JoltISA.mcauseVReg))
|>.insert Register.mtval (js.vregs JoltISA.mtvalVReg))
|>.insert Register.mstatus (js.vregs JoltISA.mstatusVReg)) }
/-- Project a Jolt run result through `systemProject`, preserving the result
value and error shape while materializing virtual CSRs in the Sail state. -/
def systemProjectResult
(r : EStateM.Result (Error exception) SailJoltState α) :
EStateM.Result (Error exception) SailState α :=
match r with
| .ok a js' => .ok a (systemProject js')
| .error e js' => .error e (systemProject js')
And that's it: for every natively supported instruction, we have to prove that this statement is true.
Expansions
We've handled natively supported RISC-V instructions.
What about instructions like LW, where the RISC-V instruction is handled as a sequence of Jolt instructions rather than as one native execInstr step?
These instructions are modeled by expansion programs.
An expansion program is a value of the inductive type Program, made from composing Jolt instructions, as defined below.
/-- Structured Jolt bytecode programs.
`instr i next` means: execute `i`; if it retires successfully, continue with
`next`; otherwise return the non-retire result immediately. -/
inductive Program where
| done (result : ExecutionResult)
| instr (instr : Instr) (next : Program)
deriving Repr
So a program is either finished, with a final ExecutionResult, or it is one instruction followed by the rest of the program.
In plain English, a program is just the composition of instruction steps: run this instruction, then run the next instruction, and so on until the program ends.
For example, a tiny program with two instructions looks like this:
def tinyProgram (rd rs1 : regidx) : Program :=
.instr (.ADDI (.xreg rd) (.xreg rs1) 1) <|
.instr (.ANDI (.xreg rd) (.xreg rd) 255) <|
.done RETIRE_SUCCESS
The <| symbol is just Lean sugar that lets us write this composition without a pile of parentheses.
Without <|, the same program is:
def tinyProgram (rd rs1 : regidx) : Program :=
.instr (.ADDI (.xreg rd) (.xreg rs1) 1)
(.instr (.ANDI (.xreg rd) (.xreg rd) 255)
(.done RETIRE_SUCCESS))
This represents the expansion:
ADDI x[rd], x[rs1], 1
ANDI x[rd], x[rd], 255
For LW, the Lean model of the Jolt expansion lives in JoltBytecode/JoltISA/Expansions/Load.lean as JoltISA.lwProgram:
/-- Rust's RV64 `LW::inline_sequence`. -/
def lwProgram (imm : BitVec 12) (rs1 rd : regidx) : Program :=
let v0 := loadV0For rd
let v1 := loadV1For rd
let tmp := loadInlineTmpFor rd
let dst := loadDstFor rd
.instr (.VirtualAssertWordAlignment rs1 imm (ExceptionType.E_Load_Addr_Align ())) <|
.instr (.ADDI (.vreg v0) (.xreg rs1) imm) <|
.instr (.ANDI (.vreg v1) (.vreg v0) (-8 : BitVec 12)) <|
.instr (.LD .normal (.vreg v1) (.vreg v1) 0) <|
slliBlock (.vreg v0) (.vreg v0) (3 : BitVec 6) <|
srlBlock (.vreg v1) (.vreg v1) (.vreg v0) tmp <|
.instr (.VirtualSignExtendWord dst (.vreg v1)) <|
.done RETIRE_SUCCESS
We make use of some helper functions like loadV0For to model virtual register allocation.
/-- Rust source `rd = x0` rewrite destination for side-effecting load
expansions. -/
def loadDstFor (rd : regidx) : Dst :=
sideEffectingRdZeroDst rd
/-- Rust load `v0`, shifted when `rd = x0` consumes the first temporary. -/
def loadV0For (rd : regidx) : VReg :=
if isX0 rd then inlineTmp1 else loadV0
/-- Rust load `v1`, shifted when `rd = x0` consumes the first temporary. -/
def loadV1For (rd : regidx) : VReg :=
if isX0 rd then inlineTmp2 else loadV1
/-- Recursive load scratch, shifted when `rd = x0` consumes the first
temporary. -/
def loadInlineTmpFor (rd : regidx) : VReg :=
if isX0 rd then inlineTmp3 else loadInlineTmp
The reason they look somewhat complex is that we also need to model the side effect of the case where rd=x0.
In that case Jolt swaps rd for the first free virtual register, and this means the downstream virtual register allocation changes by an offset of 1.
These helpers help represent that in a clean manner.
So far, this only says how to build a program. We still need to say how to run one.
One might notice that we said a program is built by composing instructions.
However, the two lines slliBlock and srlBlock are not themselves instructions.
This is because Jolt does not support SLLI and SRL natively either, so they are expanded too.
/-- Rust `SLLI::inline_sequence`: multiply by the immediate power of two. -/
def slliBlock (dst : Dst) (src : Src) (shamt : BitVec 6) (tail : Program) : Program :=
.instr (.VirtualMULI dst src (slliMultiplier shamt)) tail
/-- Rust `SRL::inline_sequence`: compute the right-shift bitmask in a scratch
virtual register, then run `VirtualSRL` with that bitmask. -/
def srlBlock (dst : Dst) (value shift : Src) (scratch : VReg)
(tail : Program) : Program :=
.instr (.VirtualShiftRightBitmask (.vreg scratch) shift) <|
.instr (.VirtualSRL dst value (.vreg scratch)) tail
With the block notation, we just write the programs cleanly.
The Rust expansion is given below, and we can see that it lines up with the Lean lwProgram.
pub(in crate::expand) fn expand_lw(
instruction: &SourceInstructionRow,
) -> Result<ExpandedInstructionSequence, ExpansionError> {
let mut asm = ExpansionBuilder::new(*instruction);
let v0 = asm.allocate()?;
let v1 = asm.allocate()?;
// RAM is accessed at doubleword granularity here. The word alignment
// assertion is still required by the source `LW` semantics.
asm.expand_address(
SourceInstructionKind::VirtualAssertWordAlignment,
reg(rs1(instruction)?),
instruction.operands.imm,
);
asm.expand_i(
SourceInstructionKind::ADDI,
v0.operand(),
reg(rs1(instruction)?),
format_i_imm(instruction.operands.imm),
);
// v1 = containing doubleword address, v0 = byte offset within it.
asm.expand_i(
SourceInstructionKind::ANDI,
v1.operand(),
v0.operand(),
format_i_imm(-8),
);
asm.expand_i(SourceInstructionKind::LD, v1.operand(), v1.operand(), 0);
asm.expand_i(SourceInstructionKind::SLLI, v0.operand(), v0.operand(), 3);
asm.expand_r(
SourceInstructionKind::SRL,
v1.operand(),
v1.operand(),
v0.operand(),
);
asm.expand_i(
SourceInstructionKind::VirtualSignExtendWord(
jolt_riscv::instructions::VirtualSignExtendWord(()),
),
reg(rd(instruction)?),
v1.operand(),
0,
);
asm.release(v0);
asm.release(v1);
asm.finalize()
}Executing Expanded Programs
Execution of a program is much like the execution of a single Jolt instruction. Given a program, instead of a single Jolt instruction, we return a state transition function. If the program is already complete, then execution just returns the result. Otherwise, it takes the instruction and the rest of the program, steps the current Jolt instruction, and on successful execution, executes the rest.
def execProgram : Program → JoltMonad ExecutionResult
| .done result => pure result
| .instr instr rest => do
match ← execInstr instr with
| .Retire_Success () => execProgram rest
-- If any instruction does not complete successfully,
-- return Result.ok result s' and do not run the rest of the program.
| result => pure result
If the instruction traps, errors, or otherwise returns a non-retire result, execution stops and that result is returned.
We will also prove that this program leaves the Sail state exactly as it would be after running the trusted Sail LW.
def lwProgramEqSailStatement (imm : BitVec 12)
(rs1 rd : regidx)
(js : SailJoltState)
(_h : LoadProgramEqSailAssumptions imm rs1 js) : Prop :=
System.systemProjectResult
((JoltISA.execProgram (JoltISA.lwProgram imm rs1 rd)).run js) =
(execute_LOAD imm rs1 rd false 4).run js.sail
The statement above translates to the following mathematical statement.
Given a 12-bit immediate value imm, a readable source register rs1, a writable destination register rd, assumptions h, and an initial Jolt state js, running Jolt's expanded LW program and projecting down to the Sail state gives the same state as running the trusted Sail LW on the input state js.sail.
In upcoming posts, we will dive into the details of assumptions in theorem statements and how to go about proving these statements.
Appendices
Read and Write Chain
def rX_bits (app_0 : regidx) : SailM (BitVec 64) := do
let .Regidx i := app_0
(rX (Regno (BitVec.toNatInt i)))
def wX_bits (typ_0 : regidx) (data : (BitVec 64)) : SailM Unit := do
let .Regidx i : regidx := typ_0
(wX (Regno (BitVec.toNatInt i)) data)def rX (app_0 : regno) : SailM (BitVec 64) := do
let .Regno r := app_0
let v ← (( do
match r with
| 0 => (pure zero_reg)
| 1 => readReg x1
| 2 => readReg x2
-- ... x3 through x30 ...
| _ => readReg x31 ) : SailM regtype )
(pure (regval_from_reg v))def wX (typ_0 : regno) (in_v : (BitVec 64)) : SailM Unit := do
let .Regno r : regno := typ_0
let v := (regval_into_reg in_v)
match r with
| 0 => (pure ())
| 1 => writeReg x1 v
| 2 => writeReg x2 v
-- ... x3 through x30 ...
| _ => writeReg x31 v
if ((r != 0) : Bool)
then (xreg_write_callback (Regidx (to_bits (l := 5) r)) in_v)
else (pure ())def writeReg (r : Register) (v : RegisterType r) : PreSailM RegisterType c ue PUnit :=
modify fun s => { s with regs := s.regs.insert r v }def readReg (r : Register) : PreSailM RegisterType c ue (RegisterType r) := do
let .some s := (← get).regs.get? r
| throw .Unreachable
pure sCSR Assumptions
structure MstatusVRegMatchesSail (js : SailJoltState) : Prop where
value_eq :
js.sail.regs.get? Register.mstatus =
some (js.vregs JoltISA.mstatusVReg)
structure MtvecVRegMatchesSail (js : SailJoltState) : Prop where
value_eq :
js.sail.regs.get? Register.mtvec =
some (js.vregs JoltISA.trapHandlerVReg)
structure MscratchVRegMatchesSail (js : SailJoltState) : Prop where
value_eq :
js.sail.regs.get? Register.mscratch =
some (js.vregs JoltISA.mscratchVReg)
structure MepcVRegMatchesSail (js : SailJoltState) : Prop where
value_eq :
js.sail.regs.get? Register.mepc =
some (js.vregs JoltISA.mepcVReg)
structure McauseVRegMatchesSail (js : SailJoltState) : Prop where
value_eq :
js.sail.regs.get? Register.mcause =
some (js.vregs JoltISA.mcauseVReg)
structure MtvalVRegMatchesSail (js : SailJoltState) : Prop where
value_eq :
js.sail.regs.get? Register.mtval =
some (js.vregs JoltISA.mtvalVReg)