Steve Simkins

How SP1 Precompiles Revolutionized zkVM Performance

Learn about Succinct, SP1, and how the innovation of Precompiles changed the zkVM space

image>

In the last few years the terms “zk” or “zero knowledge proofs” have been buzzing and for good reason. It’s a relatively new piece of tech in cryptography that allows someone to prove something without revealing the information itself, which has massive implications for not only the blockchain space but for privacy and trusted code. While zk’s are powerful, they also come at a cost. The majority of real-world use zero knowledge proofs are computationally expensive and inefficient, and in order to truly scale and make a difference, the cost needs to come down. This is where Succinct comes in, a company that specializes in zkVM technology and makes it accessible to developers. Not only has Succinct built SP1, an zkVM that allows you to write zero knowledge proofs in Rust, but they have also revolutionized efficiency with Precompiles.

What are Precompiles?

When it comes to writing zero knowledge proofs, there are generally many cryptographic operations and methods that become repetitive, especially when being used for blockchains. These include arithmetics like elliptic curves or hashes which are accessed in Rust through crates. While building SP1, Succinct realized that improvements could be made if the execution of these programs happened through the RISC-V instruction used to make proofs. This created a balance between the ease of writing proofs in Rust but still having some of the benefits of custom circuits. The result was a series of patched crates of popular libraries that dramatically changed the speed and efficiency of zero knowledge proof calculation.

Instead of just taking my word for it, I’ll show you how to spin up a quick SP1 project and we’ll build proofs both with and without the precompile patches!

Proving the Performance

The best way to really experience these speed differences is to try SP1 out for yourself, so let’s start with what you’ll need to follow along.

  • Install Rust - SP1 allows developers to write normal Rust to build zero knowledge proofs, so make sure to have it installed on your machine (preferably the nightly version).
  • Install SP1 - After you have Rust installed the SP1 installation is quite straight forward. Just follow the instructions here.
  • Starter Repo - Finally you will want to clone the start repo that has everything built out. Run the following command and move into the project directory: git clone https://github.com/stevedylandev/precompiles-demo && cd precompiles-demo

Once you’ve gotten everything setup, let’s do a quick walkthrough of the project structure.

.
├── Cargo.lock
├── Cargo.toml
├── LICENSE-MIT
├── README.md
├── elf
│   └── riscv32im-succinct-zkvm-elf
├── lib
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── program
│   ├── Cargo.toml
│   └── src
│       └── main.rs
├── rust-toolchain
└── script
    ├── Cargo.toml
    ├── build.rs
    └── src
        └── bin
  • program - In this folder we have the proof that we want to write in Rust. For our example we’ll take an input and run it through a keccak256 hash.
  • script - Here you will find a bin script that handles command arguments for either executing the program or generating the proof. It’s recommended to use execute as the primary dev method since proving can be computationally expensive or take longer.
  • lib - This directory has some helper structs that we’ll use in the program for deserializing data.
  • elf - The elf directory holds a special ELF (Executable and Linkable Format) file that is used to make our program and script programs talk to each other.

Let’s take a quick look at the program we’ll be generating a proof for:

// These two lines are necessary for the program to properly compile.
//
// Under the hood, we wrap your main function with some extra code so that it behaves properly
// inside the zkVM.
#![no_main]
sp1_zkvm::entrypoint!(main);

use alloy_sol_types::{private::FixedBytes, SolType};
use precompiles_demo::PublicValuesStruct;
use tiny_keccak::{Hasher, Keccak};
//use patched_tiny_keccak::{Hasher, Keccak};

pub fn main() {
    // Read an input to the program.
    //
    // Behind the scenes, this compiles down to a custom system call which handles reading inputs
    // from the prover.
    let input = sp1_zkvm::io::read::<String>();

    // Compute a keccak hash form the input
    let mut hasher = Keccak::v256();
    hasher.update(input.as_bytes());
    let mut hash_bytes = [0u8; 32];
    hasher.finalize(&mut hash_bytes);

    let hash_fixed = FixedBytes::<32>(hash_bytes);

    // Encode the public values of the program.
    let bytes = PublicValuesStruct::abi_encode(&PublicValuesStruct {
        input,
        hash: hash_fixed,
    });

    // Commit to the public values of the program. The final proof will have a commitment to all the
    // bytes that were committed to.
    sp1_zkvm::io::commit_slice(&bytes);
}

At the top of our file we have an entrypoint for the sp1_zkvm which is needed to run inside SP1. Outside of that we have some basic Rust code that takes an input, creates a hash, and returns the input and the resulting hash. SP1 provides some utilities to handle things like io to read inputs as well as commiting them.

Now let’s start testing. If you haven’t already run cd program to move into the program directory, and then run:

cargo prove build

This will build our program using SP1 and prepare it for the next step, execution.

Inside our script folder we have a /bin/main.rs file that will run the program through SP1:

use alloy_sol_types::SolType;
use clap::Parser;
use precompiles_demo::PublicValuesStruct;
use sp1_sdk::{ProverClient, SP1Stdin};

/// The ELF (executable and linkable format) file for the Succinct RISC-V zkVM.
pub const ELF: &[u8] = include_bytes!("../../../elf/riscv32im-succinct-zkvm-elf");

/// The arguments for the command.
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)]
struct Args {
    #[clap(long)]
    execute: bool,

    #[clap(long)]
    prove: bool,

    #[clap(long, default_value = "Hello World from SP1!")]
    n: String,
}

fn main() {
    // Setup the logger.
    sp1_sdk::utils::setup_logger();

    // Parse the command line arguments.
    let args = Args::parse();

    if args.execute == args.prove {
        eprintln!("Error: You must specify either --execute or --prove");
        std::process::exit(1);
    }

    // Setup the prover client.
    let client = ProverClient::new();

    // Setup the inputs.
    let mut stdin = SP1Stdin::new();
    stdin.write(&args.n);

    println!("n: {}", args.n);

    if args.execute {
        // Execute the program
        let (output, report) = client.execute(ELF, stdin).run().unwrap();
        println!("Program executed successfully.");

        // Read the output.
        let decoded = PublicValuesStruct::abi_decode(output.as_slice(), true).unwrap();
        let PublicValuesStruct { input, hash } = decoded;
        println!("Input: {}", input);
        println!("Hash: 0x{}", hex::encode(hash.0)); // Convert bytes to hex string

        // Record the number of cycles executed.
        println!("Number of cycles: {}", report.total_instruction_count());
    } else {
        // Setup the program for proving.
        let (pk, vk) = client.setup(ELF);

        // Generate the proof
        let proof = client
            .prove(&pk, stdin)
            .run()
            .expect("failed to generate proof");

        println!("Successfully generated proof!");

        // Verify the proof.
        client.verify(&proof, &vk).expect("failed to verify proof");
        println!("Successfully verified proof!");
    }
}

This will look for arguments during cargo run to determine if it should only run an execution or if it should generate a proof. Executions are great for testing the code and will take less time and computation power than proofs. Once we have the arguments we can create an instance of the ProverClient to run our program and get the committed values back. Now let’s try it out with the following command, making sure we have run cd ../script first to be in this directory instead of program:

cargo run --release -- --execute

This will start the bin command and will take a little time to run. Once complete you should see an output like this one:

2024-10-27T04:45:04.296078Z  INFO vk verification: true
n: Hello World from SP1!
2024-10-27T04:45:04.388224Z  INFO execute: close time.busy=6.23ms time.idle=14.2µs
Program executed successfully.
Input: Hello World from SP1!
Hash: 0x5de0efbc889e22250feb078959ad9aa6fb6c1301b94b3a8e6a10d49e47c074f2
Number of cycles: 30635

The once piece you’ll want to note particularly is the cycle count. This is how many cycles were run on the circuits to produce the proof, and act as a measurement of computational efficiency.

With our base test out of the way with 30635 cycles, let’s use our patched keccak library from SP1 to see the difference. We’ve already got the library listed in the Cargo.toml file inside the program directory:

[package]
version = "0.1.0"
name = "keccak-program"
edition = "2021"

[dependencies]
tiny-keccak = { version = "2.0.2", features = ["keccak"] }
alloy-sol-types = { workspace = true }
sp1-zkvm = "3.0.0-rc4"
precompiles-demo = { path = "../lib" }
patched-tiny-keccak = { git = "https://github.com/sp1-patches/tiny-keccak", branch = "patch-v2.0.2", package = "tiny-keccak", features = [
  "keccak",
] }
hex = "0.4.3"

To use it, we just need to update the top of the program/src/main.rs file to use patched_tiny_keccak instead of tiny_keccak.

#![no_main]
sp1_zkvm::entrypoint!(main);

use alloy_sol_types::{private::FixedBytes, SolType};
use precompiles_demo::PublicValuesStruct;
//use tiny_keccak::{Hasher, Keccak};
use patched_tiny_keccak::{Hasher, Keccak};

With the standard tiny-keccak library commented out and the patched one being put in in’s place, let’s navigate back to the script directory and run the same execute command. You should get some results like this:

n: Hello World from SP1!
Program executed successfully.
Input: Hello World from SP1!
Hash: 0x5de0efbc889e22250feb078959ad9aa6fb6c1301b94b3a8e6a10d49e47c074f2
Number of cycles: 14259

Just like that, our number of cycles has dropped by half! While this example is pretty elementary, in a much larger application with more computationally expensive programs the results seen are more efficient in magnitudes rather than just a 2x increase in performance.

Wrapping Up

While there have been many advances in zk technology, there is still much work to do for larger adoption. Writing zero knowledge proofs shouldn’t take enormous amounts of time in low-level languages like assembly, and that’s exactly why Succinct is making it easier with SP1. Even today there are dozens of teams using SP1 in production to help scale blockchains, rollups, bridges, and more. It’s not hard to imagine a future where zkVMs are used in sectors beyond Web3, as there are many needs in our modern infrastructure for verifiable code, and Succinct will be there paving the way.