# Getting RISC Zero to work with 2<sup>40</sup> lists
Date: 2025-04-24
By: [Unnawut L.](https://twitter.com/unnawut)
## TLDR
This post is my attempt at getting RISC Zero to work with >32-bit lists, with a mix zkVM program optimizations along the way.
## Details
To recap, I was able to [run `process_block_header()` on RISC Zero](https://hackmd.io/@reamlabs/HJhGr8LR1e) by reducing BeaconState properties that are 2^40^-sized to be under 32-bit. The benchmark is below.
### Standard [`write()`](https://docs.rs/risc0-zkvm/2.0.1/risc0_zkvm/struct.ExecutorEnvBuilder.html#method.write) and [`read()`](https://docs.rs/risc0-zkvm/2.0.1/risc0_zkvm/guest/env/fn.read.html) from [earlier benchmark](https://hackmd.io/@reamlabs/HJhGr8LR1e)
This method deserializes the state and input SSZs into structs before passing into the guest zkVM.
| Operation | Test Case | Read Pre-State | Read Operation | Process | Merkleize | Commit | Total Cycles | Execution Time |
|-----------|-----------|----------------|----------------|---------|-----------|--------|--------------|----------------|
block_header | basic_block_header | 46949445 | 46610 | 2073605 | 902102375 | 1013 | 951176268 | 22.271106416s |
block_header | invalid_multiple_blocks_single_slot | 46949445 | 46610 | 2063903 | 902102375 | 1013 | 951166566 | 21.836859958s |
block_header | invalid_parent_root | 46949445 | 46610 | 2073605 | 902102375 | 1013 | 951176268 | 21.789772791s |
block_header | invalid_proposer_index | 46949445 | 46610 | 2073605 | 902102375 | 1013 | 951176268 | 21.598499417s |
block_header | invalid_proposer_slashed | 46949446 | 46610 | 2073605 | 902102375 | 1013 | 951176269 | 21.677046792s |
block_header | invalid_slot_block_header | 46949445 | 46610 | 2063898 | 902102375 | 1013 | 951166561 | 21.313484834s |
However I still need to find a way to work with 2^40^ lists to conform with the [beacon specs](https://ethereum.github.io/consensus-specs/specs/phase0/beacon-chain/#state-list-lengths). My attempts are logged below.
### Using [`write_slice()`](https://docs.rs/risc0-zkvm/2.0.1/risc0_zkvm/struct.ExecutorEnvBuilder.html#method.write_slice) and [`read_slice()`](https://docs.rs/risc0-zkvm/2.0.1/risc0_zkvm/guest/env/fn.read_slice.html)
As [suggested by RISC Zero team](https://discord.com/channels/953703904086994974/1361394295067640019/1361394295067640019):
> i think your issue here is most likely related to serde -- have you tried any other options for deserialization?
> On that topic, its important to note that `risc0_zkvm::env::read()` uses `risc0_zkvm::serde`. `risc0_zkvm::serde` was created as a zkVM optimized codec early on, but it turns out the popular Rust codecs are already quite good in the zkVM. So, it's no longer the recommended way to read large or complex structs.
>
> Our recommendation is to use `risc0_zkvm::env::read_frame()`[1] or `risc0_zkvm::env::stdin().read_to_end()` [2] to read in a byte array, and then deserialize with the codec of your choice. You _can_ use `serde_json`. But, if at all possible, I would recommend a binary codec like `bincode` or `postcard` instead. SP1 uses `bincode` by default [3], so if you want the same codec, you can use `bincode` there too. Make sure to use the same codec to serialize the input on the host, and use `ExecutorEnvBuilder::write_slice` or `ExecutorEnvBuilder::write_frame` instead of `ExecutorEnvBuilder::write` (which uses the `risc0_zkvm::serde` codec).
>
> [1] https://docs.rs/risc0-zkvm/latest/risc0_zkvm/guest/env/fn.read_frame.html
> [2] https://docs.rs/risc0-zkvm/latest/risc0_zkvm/guest/env/fn.stdin.html
> [3] https://docs.rs/sp1-lib/4.1.7/src/sp1_lib/io.rs.html#87
So I refactored the code to pass in the SSZ bytes (`Vec<u8>`-typed) into the zkVM guest to avoid issues with serializing/deserializing structs. SSZ deserialization then happens inside the guest.
I then benchmark it once more:
| Operation | Test Case | Read Pre-State SSZ | Deserialize Pre-State SSZ | Read Operation Input | Process | Merkleize | Commit | Total Cycles | Execution Time |
|-----------|-----------|--------------------|---------------------------|----------------------|---------|-----------|--------|--------------|----------------|
block_header | basic_block_header | 264367 | 18696661 | 93392 | 2109997 | 902102373 | 1012 | 923271940 | 19.907453708s |
block_header | invalid_multiple_blocks_single_slot | 264367 | 18696661 | 93392 | 2100295 | 902102373 | 1012 | 923262238 | 19.809696s |
block_header | invalid_parent_root | 264367 | 18696661 | 93392 | 2109997 | 902102373 | 1012 | 923271940 | 19.71908425s |
block_header | invalid_proposer_index | 264367 | 18696661 | 93392 | 2109997 | 902102373 | 1012 | 923271940 | 19.841570709s |
block_header | invalid_proposer_slashed | 264367 | 18696663 | 93392 | 2109997 | 902102373 | 1012 | 923271942 | 19.97571625s |
block_header | invalid_slot_block_header | 264367 | 18696661 | 93392 | 2100290 | 902102373 | 1012 | 923262233 | 19.677046667s |
By reading and writing slices of SSZ bytes and deserializes in the guest, we achieve `1 - (264367 + 18696661) / 46949445` = 59.6% reduction in cycles.
However, reading and writing slices still does not solve the 2^40^ lists issue. I'm still getting a deserialization error.
### Exploring `to_usize()` misbehavior
I narrowed the source of deserialization error further and arrived at [this code block](https://github.com/sigp/ssz_types/blob/4fef53f83403658c9db5b1d8ba4e02f3edd8b18e/src/variable_list.rs#L295-L300):
```rust=
let max_len = N::to_usize();
// ...
if num_items > max_len {
return Err(ssz::DecodeError::BytesInvalid(format!(
"VariableList of {} items exceeds maximum of {}",
num_items, max_len
)));
}
```
It's `let max_len = N::to_usize();` that's behaving unexpectedly, returning `max_len = 0` on RISC Zero. On the other hand, SP1 returns `max_len = 1099511627776` (2^40^).
Upon inspecting [`usize`](https://doc.rust-lang.org/std/primitive.usize.html), it turns out SP1 has 64-bit pointer while RISC Zero is 32-bit.
**SP1:**
```rust
println!("usize::BITS = {}", usize::BITS);
// usize::BITS = 64
println!("usize::MAX = {}", usize::MAX);
// usize::MAX = 18446744073709551615
```
**Risc0:**
```rust
println!("usize::BITS = {}", usize::BITS);
// usize::BITS = 32
println!("usize::MAX = {}", usize::MAX);
// usize::MAX = 4294967295
```
This is intriguing because [SP1 reportedly uses the 32-bit RV32IM architecture]():
> SP1 is a specialized implementation of the RISC-V RV32IM standard and aligns with the fundamental philosophy of RISC-V [...]
RISC Zero also [reportedly uses the same RV32IM architecture](https://dev.risczero.com/reference-docs/about-risc-v#relevance-in-risc-zero):
> RISC Zero's zkVM implements the RISC-V rv32im specification, which consists of the rv32i base with the multiplication extension.
### My mistake
It turned out that the issue lies in a discrepancy between our own SP1 and RISC Zero code. Our SP1 code [decodes the SSZ bytes in the host](https://github.com/ReamLabs/consensp1us/blob/5a35fad/script/src/bin/main.rs#L89) before passing it into the guest. On the other hand, our RISC Zero code had more development iterations on it and [decodes the SSZ inside the guest](https://github.com/ReamLabs/consenzero-bench/blob/3fb33ad/methods/guest/src/main.rs#L41).
And because `usize` was called by [ssz_types](https://docs.rs/ssz_types) during SSZ deserialization, the returned usize depends on which environment the ssz_types's decode was executed on, in this case on a 64-bit host for RISC Zero and the 32-bit guest for SP1.
The reason that our SP1 code did not fail is because the SSZ bytes have already been decoded to a `u64` element before being passed in, and therefore SP1 guest knows that it is operating on a `u64` value. On the other hand, our code on RISC Zero guest is instructed to work with `usize` on an out of bound value, hence the error.
### Both SP1 and RISC Zero supports `u64`
Now knowing that the error is probably from our codebase('s dependency), I made sure that both SP1 and RISC Zero should be able to work with `u64`. I turn to the newly created [zkvm-playground](https://github.com/ReamLabs/zkvm-playground) repo to [compare a `u64` operation](https://github.com/ReamLabs/zkvm-playground/blob/master/common/src/scenarios/u64_max_value.rs) apple-to-apple:
#### SP1
```rust
u64::MAX = 18446744073709551615
u64_max_value: 18446744073709551615
```
#### RISC Zero
```rust
u64::MAX = 18446744073709551615
u64_max_value: 18446744073709551615
```
We can now confirm that both zkVMs should be able to handle `u64`.
## Lesson learnt
It's important to keep in mind that use of `usize`, *including and perhaps especially in dependencies* could affect behavior of the interactions between the zkVM host and the guest.
## Next
- Patch [`ssz_types`](https://docs.rs/ssz_types/) to be able to hint a specific type rather than relying on usize to continue prototyping.