Rewrite It in Rust: RPC Provider
Part IV: Friendship ended with WEB3PY now ALLOY is my best friend
We have already established a long term goal: build a Rust core driven by a thin Python shell.
Communication with the blockchain is done via Remote Procedure Call (RPC) exposed by a node directly, or through some gateway service. An RPC is your link to the chain, so interacting with it is critical to the searcher experience. You will use it to build your local view of the chain, fetch and receive data, inspect state, and simulate / send transactions.
The layer that communicates with the RPC is generally called a provider. It encapsulates the translation of chain and block values to language primitives (e.g. converting values to integers, strings, and structs), plus the low-level concerns of communicating over the major transports (HTTP, Websocket, IPC socket).
Web3py
Python users use the web3py package for this. It’s easy to use, just feed a provider instance to the Web3 object and then you can interact with the chain:
>>> from web3 import Web3
>>> # Establish an HTTP connection to the local node
>>> w3 = Web3(Web3.HTTPProvider('http://localhost:8545'))
>>> w3.is_connected()
True
>>> w3.eth.block_number
25490415In addition to HTTP, web3py includes a provider to handle Websocket and IPC connections. And there are asynchronous versions of each provider.
Alloy
Rust users generally reach for Alloy, which includes a dedicated provider crate that supports the expected transports. Per their documentation, the correct way to build an Alloy Provider is to use its builder. The builder generally returns an asynchronous provider, so you need to use an appropriate async runtime like Tokio.
The setup is simple, and similar enough to web3py that you should be able to predict how this example behaves:
use alloy::providers::{Provider, ProviderBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let rpc_url = "http://localhost:8545".parse()?;
let provider = ProviderBuilder::new().connect_http(rpc_url);
let block_number = provider.get_block_number().await?;
println!("Latest block number: {}", block_number);
Ok(())
}Performance
We shouldn’t be surprised that Alloy is faster than web3py. But how much? Let’s benchmark them with a realistic scenario: fetch the last 1,000 full blocks from the RPC using HTTP and Websocket, logging the elapsed time.
I am running against my local Reth node. Reth provides a built-in cache for RPC requests, with a default block size of 5,000. After the warm-up fetch, the blocks are available in the memory cache, so disk I/O overhead should have minimal influence on the results.
fetch_blocks.py
import argparse
import asyncio
import sys
import time
from web3 import AsyncWeb3
RPC_HTTP = "http://localhost:8545"
RPC_WS = "ws://localhost:8546"
BLOCK_COUNT = 1_000
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--ws", action="store_true", help="Use WebSocket instead of HTTP"
)
args = parser.parse_args()
if args.ws:
async with AsyncWeb3(
AsyncWeb3.WebSocketProvider(RPC_WS, websocket_kwargs={"max_size": None})
) as w3:
latest = await w3.eth.block_number
start = max(0, latest - BLOCK_COUNT + 1)
t0 = time.perf_counter()
for number in range(start, latest + 1):
block = await w3.eth.get_block(number, full_transactions=True)
print(block)
elapsed = time.perf_counter() - t0
else:
w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider(RPC_HTTP))
latest = await w3.eth.block_number
start = max(0, latest - BLOCK_COUNT + 1)
t0 = time.perf_counter()
for number in range(start, latest + 1):
block = await w3.eth.get_block(number, full_transactions=True)
print(block)
elapsed = time.perf_counter() - t0
print(f"TIMING: {elapsed:.6f}", file=sys.stderr)
if __name__ == "__main__":
asyncio.run(main())fetch_blocks.rs
use alloy::providers::{Provider, ProviderBuilder};
use alloy::rpc::types::BlockNumberOrTag;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let ws = std::env::args().any(|arg| arg == "--ws");
let provider = if ws {
ProviderBuilder::new()
.connect_ws(alloy_transport_ws::WsConnect::new("ws://localhost:8546"))
.await?
} else {
ProviderBuilder::new().connect_http("http://localhost:8545".parse()?)
};
let latest = provider.get_block_number().await?;
let start = latest - (1_000 - 1);
let t0 = std::time::Instant::now();
for number in start..=latest {
let block = provider
.get_block_by_number(BlockNumberOrTag::Number(number))
.full()
.await?;
match block {
Some(b) => println!("{:#?}", b),
None => println!("No block found for number {}", number),
}
}
let elapsed = t0.elapsed().as_secs_f64();
eprintln!("TIMING: {:.6}", elapsed);
Ok(())
}Results
Web3py HTTP: median 50.5 s
Web3py WS: median 46.6 s
Alloy HTTP: median 16.7 s
Alloy WS: median 16.0 s
In both cases the HTTP provider is a bit slower than the Websocket, which tells us that the transport overhead is not the primary bottleneck.
Concurrent Requests
Alloy is ~3x faster vs web3py on this sequential, serialization-heavy workload.
Let’s compare the two libraries when using parallel workers and async requests that request multiple blocks per call.
fetch_blocks_parallel.py
#!/usr/bin/env python3
"""Fetch the last 1000 full blocks using a ProcessPoolExecutor (8 workers) and print them."""
import argparse
import sys
import time
from concurrent.futures import ProcessPoolExecutor
from web3 import Web3
RPC_HTTP = "http://localhost:8545"
RPC_WS = "ws://localhost:8546"
BLOCK_COUNT = 1_000
NUM_WORKERS = 8
def chunked(seq, n):
"""Split seq into n roughly-equal chunks."""
k, m = divmod(len(seq), n)
start = 0
for i in range(n):
size = k + (1 if i < m else 0)
yield seq[start : start + size]
start += size
def fetch_chunk(chunk, use_ws):
"""Worker: create its own Web3 instance and fetch a chunk of blocks sequentially."""
if use_ws:
w3 = Web3(Web3.LegacyWebSocketProvider(RPC_WS, websocket_kwargs={"max_size": None}))
else:
w3 = Web3(Web3.HTTPProvider(RPC_HTTP))
return [w3.eth.get_block(n, full_transactions=True) for n in chunk]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--ws", action="store_true", help="Use WebSocket instead of HTTP"
)
args = parser.parse_args()
probe = Web3(Web3.HTTPProvider(RPC_HTTP))
latest = probe.eth.block_number
start = max(0, latest - BLOCK_COUNT + 1)
numbers = list(range(start, latest + 1))
chunks = list(chunked(numbers, NUM_WORKERS))
t0 = time.perf_counter()
with ProcessPoolExecutor(max_workers=NUM_WORKERS) as pool:
batches = list(
pool.map(fetch_chunk, chunks, [args.ws] * len(chunks))
)
elapsed = time.perf_counter() - t0
for batch in batches:
for block in batch:
print(block)
print(f"TIMING: {elapsed:.6f}", file=sys.stderr)
if __name__ == "__main__":
main()fetch_blocks_parallel.rs
use alloy::providers::{Provider, ProviderBuilder};
use alloy::rpc::types::BlockNumberOrTag;
use std::time::Instant;
const NUM_WORKERS: usize = 8;
const BLOCK_COUNT: u64 = 1_000;
/// Split a Vec into `n` roughly-equal chunks.
fn chunked<T: Clone>(vec: Vec<T>, n: usize) -> Vec<Vec<T>> {
let len = vec.len();
let (k, m) = (len / n, len % n);
let mut out = Vec::with_capacity(n);
let mut start = 0;
for i in 0..n {
let size = k + if i < m { 1 } else { 0 };
out.push(vec[start..start + size].to_vec());
start += size;
}
out
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let ws = std::env::args().any(|arg| arg == "--ws");
let provider = if ws {
ProviderBuilder::new()
.connect_ws(alloy_transport_ws::WsConnect::new("ws://localhost:8546"))
.await?
} else {
ProviderBuilder::new().connect_http("http://localhost:8545".parse()?)
};
let latest = provider.get_block_number().await?;
let start = latest - (BLOCK_COUNT - 1);
let numbers: Vec<u64> = (start..=latest).collect();
let chunks = chunked(numbers, NUM_WORKERS);
let t0 = Instant::now();
let mut handles = Vec::with_capacity(NUM_WORKERS);
for chunk in chunks {
let provider = provider.clone();
handles.push(tokio::spawn(async move {
let mut blocks = Vec::with_capacity(chunk.len());
for number in chunk {
let block = provider
.get_block_by_number(BlockNumberOrTag::Number(number))
.full()
.await?;
blocks.push(block);
}
Ok::<_, alloy::transports::TransportError>(blocks)
}));
}
let mut batches = Vec::with_capacity(handles.len());
for handle in handles {
batches.push(handle.await??);
}
let elapsed = t0.elapsed().as_secs_f64();
for batch in batches {
for block in batch {
match block {
Some(b) => println!("{:#?}", b),
None => {}
}
}
}
eprintln!("TIMING: {:.6}", elapsed);
Ok(())
}Results
Web3py HTTP: median 16.0 s
Web3py WS: median 15.5 s
Alloy HTTP: median 1.4 s
Alloy WS: median 1.3 s
Web3py improved by ~5x, and Alloy improved ~12x. Excellent!
The Python code has also become more convoluted. Distributing to multiple workers requires creating a separate process pool so that workers are not bottlenecked by the GIL. The RPC requests themselves are not impacted by this since they use async-native libraries (aiohttp and websockets), but the transformation of the JSON response into Python for printing requires a trip through the single-threaded json library. Contrast this with the Rust version which simply clones a provider for each worker and then starts hitting the RPC.
Conclusion
Alloy is faster and easier to use for parallel work. Easy as that.
And as a bonus, the RPC calls all happen on the Rust side of the language boundary so that the results can be decoded and passed to pool data structs without involving the PyO3 FFI which necessarily holds onto the GIL and requires additional memory allocations to transfer data between the Python interpreter and the Rust runtime.
Next Steps
I have already converted much of the lingering web3py use in degenbot to Alloy, and am migrating the database update commands now. You can follow along in the dev branch, or just wait until I cut a new release
