Merge pull request #35 from floor-licker/feat/official-sdk-benchmark

perf: benchmark official Polymarket SDK
This commit is contained in:
floor-licker
2026-06-22 13:03:48 -04:00
committed by GitHub
5 changed files with 1794 additions and 840 deletions
Generated
+1223 -834
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -20,7 +20,7 @@ futures = "0.3"
futures-util = "0.3"
# HTTP client
reqwest = { version = "0.12", features = ["json", "stream", "gzip"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "stream", "gzip", "rustls", "http2"] }
hickory-resolver = "0.26"
# Serialization
@@ -64,6 +64,9 @@ rand = "0.8"
# Optional WebSocket support for streaming
tokio-tungstenite = { version = "0.21", optional = true, features = ["native-tls"] }
# Optional benchmark-only dependency for comparing against Polymarket's active Rust SDK.
polymarket_client_sdk_v2 = { git = "https://github.com/Polymarket/rs-clob-client-v2", rev = "8ba5008733c3c03e92041eef8b1cb8495dbed718", features = ["clob"], optional = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
tokio-test = "0.4"
@@ -75,11 +78,16 @@ env_logger = "0.10"
default = ["stream"]
stream = ["tokio-tungstenite"]
side-by-side-benchmark = []
official-client-benchmark = ["dep:polymarket_client_sdk_v2"]
[[example]]
name = "side_by_side_benchmark"
required-features = ["side-by-side-benchmark"]
[[example]]
name = "official_client_side_by_side_benchmark"
required-features = ["official-client-benchmark"]
[[bench]]
name = "book_updates"
harness = false
+12 -5
View File
@@ -43,11 +43,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
**Real-World API Performance (with network I/O)**
End-to-end performance with Polymarket's API, including network latency, JSON parsing, and decompression:
Real-world Polymarket API latency broken down by request phase:
| Operation | polyfill-rs | polymarket-rs-client | Official Python Client |
|-----------|-------------|----------------------|------------------------|
| **Fetch Markets** | **321.6 ms ± 92.9 ms** | 409.3 ms ± 137.6 ms | 1.366 s ± 0.048 s |
![polyfill-rs benchmark results](assets/benchmark-results.svg)
| Operation | Metric | polyfill-rs | rs-clob-client-v2 | polymarket-rs-client | Official Python Client |
|-----------|--------|-------------|-------------------|----------------------|------------------------|
| **Fetch Markets** | mean ± sd | **321.6 ms ± 92.9 ms** | - | 409.3 ms ± 137.6 ms | 1.366 s ± 0.048 s |
| **Cold Start** | single run | 651.7 ms | **543.9 ms** | - | - |
| **Warm Connection** | single run | **202.9 ms** | 497.2 ms | - | - |
| **Steady Typed Total** | p50 / p95 / p99 | **209.9 / 276.1 / 509.5 ms** | 215.9 / 284.7 / 312.4 ms | - | - |
| **Network-Only Byte Fetch** | p50 / p95 / p99 | 382.5 / 590.3 / 626.7 ms | **300.1 / 449.0 / 520.1 ms** | - | - |
| **CPU Parse Only** | p50 / p95 / p99 | **0.5 / 1.2 / 1.4 ms** | 1.3 / 1.4 / 1.4 ms | - | - |
**Performance vs polymarket-rs-client:**
@@ -55,7 +62,7 @@ End-to-end performance with Polymarket's API, including network latency, JSON pa
- **32.5% more consistent**
- **4.2x faster** than Official Python Client
**Benchmark Methodology:** All benchmarks run side-by-side on the same machine, same network, same time using 20 iterations, 100ms delay between requests, /simplified-markets endpoint. Best performance achieved with connection keep-alive enabled. See `examples/side_by_side_benchmark.rs` in commit `a63a170`: https://github.com/floor-licker/polyfill-rs/blob/a63a170/examples/side_by_side_benchmark.rs for the complete benchmark implementation.
**Benchmark Methodology:** The `rs-clob-client-v2` comparison separates cold start, warm connection, steady-state typed requests, network-only byte fetches, and CPU-only parsing. Steady-state rows use 20 paired iterations with alternating order and 100ms delay; parse rows use 200 iterations from a cached 480KB payload. The network-only row compares byte fetches through each HTTP stack without typed deserialization. Run it with `cargo run --release --example official_client_side_by_side_benchmark --features official-client-benchmark`. See `examples/side_by_side_benchmark.rs` in commit `a63a170`: https://github.com/floor-licker/polyfill-rs/blob/a63a170/examples/side_by_side_benchmark.rs for the original legacy benchmark implementation.
**Computational Performance (pure CPU, no I/O)**
+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="380" viewBox="0 0 980 380" role="img" aria-labelledby="title desc">
<title id="title">polyfill-rs benchmark bars</title>
<desc id="desc">Minimal horizontal bars visualizing p50 benchmark latency. Lower values are faster.</desc>
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.group { fill: #6b7280; font-size: 12px; font-weight: 650; }
.label { fill: #374151; font-size: 12px; font-weight: 600; }
.value { fill: #111827; font-size: 12px; font-weight: 650; }
.rule { stroke: #e5e7eb; stroke-width: 1; }
.polyfill { fill: #0f766e; }
.compare { fill: #9ca3af; }
</style>
<rect width="980" height="380" fill="#ffffff"/>
<text class="group" x="40" y="38">Steady typed total, p50</text>
<text class="label" x="40" y="68">polyfill-rs</text>
<rect class="polyfill" x="210" y="54" width="584" height="18" rx="3"/>
<text class="value" x="812" y="68">209.9 ms</text>
<text class="label" x="40" y="96">rs-clob-client-v2</text>
<rect class="compare" x="210" y="82" width="600" height="18" rx="3"/>
<text class="value" x="828" y="96">215.9 ms</text>
<line class="rule" x1="40" y1="128" x2="940" y2="128"/>
<text class="group" x="40" y="158">Network-only byte fetch, p50</text>
<text class="label" x="40" y="188">polyfill-rs</text>
<rect class="polyfill" x="210" y="174" width="600" height="18" rx="3"/>
<text class="value" x="828" y="188">382.5 ms</text>
<text class="label" x="40" y="216">rs-clob-client-v2</text>
<rect class="compare" x="210" y="202" width="471" height="18" rx="3"/>
<text class="value" x="699" y="216">300.1 ms</text>
<line class="rule" x1="40" y1="248" x2="940" y2="248"/>
<text class="group" x="40" y="278">CPU parse only, p50</text>
<text class="label" x="40" y="308">polyfill-rs</text>
<rect class="polyfill" x="210" y="294" width="231" height="18" rx="3"/>
<text class="value" x="459" y="308">0.5 ms</text>
<text class="label" x="40" y="336">rs-clob-client-v2</text>
<rect class="compare" x="210" y="322" width="600" height="18" rx="3"/>
<text class="value" x="828" y="336">1.3 ms</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,507 @@
//! Side-by-side benchmark comparing polyfill-rs against Polymarket's
//! `rs-clob-client-v2`.
//!
//! Run with:
//! `cargo run --release --example official_client_side_by_side_benchmark --features official-client-benchmark`
//!
//! Optional env vars:
//! - `POLYMARKET_BENCH_HOST` defaults to `https://clob.polymarket.com`
//! - `POLYMARKET_BENCH_ITERATIONS` defaults to `20`
//! - `POLYMARKET_BENCH_WARMUPS` defaults to `3`
//! - `POLYMARKET_BENCH_PARSE_ITERATIONS` defaults to `200`
//! - `POLYMARKET_BENCH_DELAY_MS` defaults to `100`
//! - `POLYMARKET_BENCH_KEEPALIVE` defaults to `true`
use std::hint::black_box;
use std::time::{Duration, Instant};
use polyfill_rs::ClobClient;
use polymarket_client_sdk_v2::clob::types::response::{
Page as OfficialPage, SimplifiedMarketResponse as OfficialSimplifiedMarketResponse,
};
use polymarket_client_sdk_v2::clob::{Client as OfficialClient, Config as OfficialConfig};
const INITIAL_CURSOR: &str = "MA==";
const OFFICIAL_SDK_REV: &str = "8ba5008733c3c03e92041eef8b1cb8495dbed718";
#[derive(Debug, Clone, Copy)]
struct Sample {
elapsed: Duration,
count: usize,
}
#[derive(Debug, Clone, Copy)]
struct Stats {
mean_ms: f64,
std_dev_ms: f64,
min_ms: f64,
max_ms: f64,
p50_ms: f64,
p95_ms: f64,
p99_ms: f64,
}
fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
}
fn env_bool(name: &str, default: bool) -> bool {
std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
}
fn percentile(sorted: &[f64], percentile: f64) -> f64 {
let rank = (percentile * sorted.len() as f64).ceil() as usize;
let index = rank.saturating_sub(1).min(sorted.len() - 1);
sorted[index]
}
fn calc_stats(times: &[Duration]) -> Option<Stats> {
if times.is_empty() {
return None;
}
let mut values: Vec<f64> = times
.iter()
.map(|duration| duration.as_micros() as f64 / 1000.0)
.collect();
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mean_ms = values.iter().sum::<f64>() / values.len() as f64;
let variance = values
.iter()
.map(|value| (value - mean_ms).powi(2))
.sum::<f64>()
/ values.len() as f64;
Some(Stats {
mean_ms,
std_dev_ms: variance.sqrt(),
min_ms: values[0],
max_ms: values[values.len() - 1],
p50_ms: percentile(&values, 0.50),
p95_ms: percentile(&values, 0.95),
p99_ms: percentile(&values, 0.99),
})
}
fn print_stats(name: &str, stats: Option<Stats>, successes: usize, attempts: usize) {
println!("{name}:");
match stats {
Some(stats) => {
println!(
" p50/p95/p99: {:.1} / {:.1} / {:.1} ms",
stats.p50_ms, stats.p95_ms, stats.p99_ms
);
println!(
" mean: {:.1} ms +/- {:.1} ms",
stats.mean_ms, stats.std_dev_ms
);
println!(
" range: {:.1} - {:.1} ms",
stats.min_ms, stats.max_ms
);
println!(" success: {successes}/{attempts}");
},
None => println!(" success: 0/{attempts}"),
}
}
fn print_single(name: &str, sample: &Result<Sample, String>) {
match sample {
Ok(sample) => println!(
"{name}: {:.1} ms ({} items)",
sample.elapsed.as_micros() as f64 / 1000.0,
sample.count
),
Err(error) => println!("{name}: ERROR - {error}"),
}
}
async fn time_polyfill_typed(client: &ClobClient) -> Result<Sample, String> {
let start = Instant::now();
let page = client
.get_simplified_markets(Some(INITIAL_CURSOR))
.await
.map_err(|error| error.to_string())?;
Ok(Sample {
elapsed: start.elapsed(),
count: page.data.len(),
})
}
async fn time_official_typed(client: &OfficialClient) -> Result<Sample, String> {
let start = Instant::now();
let page = client
.simplified_markets(Some(INITIAL_CURSOR.to_string()))
.await
.map_err(|error| error.to_string())?;
Ok(Sample {
elapsed: start.elapsed(),
count: page.data.len(),
})
}
async fn time_polyfill_cold(host: &str) -> Result<Sample, String> {
let start = Instant::now();
let client = ClobClient::new(host);
let page = client
.get_simplified_markets(Some(INITIAL_CURSOR))
.await
.map_err(|error| error.to_string())?;
Ok(Sample {
elapsed: start.elapsed(),
count: page.data.len(),
})
}
async fn time_official_cold(host: &str) -> Result<Sample, String> {
let start = Instant::now();
let client =
OfficialClient::new(host, OfficialConfig::default()).map_err(|error| error.to_string())?;
let page = client
.simplified_markets(Some(INITIAL_CURSOR.to_string()))
.await
.map_err(|error| error.to_string())?;
Ok(Sample {
elapsed: start.elapsed(),
count: page.data.len(),
})
}
async fn time_polyfill_raw(client: &ClobClient, url: &str) -> Result<Sample, String> {
let start = Instant::now();
let bytes = client
.http_client
.get(url)
.send()
.await
.map_err(|error| error.to_string())?
.bytes()
.await
.map_err(|error| error.to_string())?;
Ok(Sample {
elapsed: start.elapsed(),
count: bytes.len(),
})
}
async fn time_reqwest_raw(client: &reqwest::Client, url: &str) -> Result<Sample, String> {
let start = Instant::now();
let bytes = client
.get(url)
.send()
.await
.map_err(|error| error.to_string())?
.bytes()
.await
.map_err(|error| error.to_string())?;
Ok(Sample {
elapsed: start.elapsed(),
count: bytes.len(),
})
}
async fn run_typed_pairs(
polyfill_client: &ClobClient,
official_client: &OfficialClient,
iterations: usize,
delay: Duration,
) -> (Vec<Duration>, Vec<Duration>) {
let mut polyfill_times = Vec::with_capacity(iterations);
let mut official_times = Vec::with_capacity(iterations);
for i in 1..=iterations {
let polyfill_first = i % 2 == 1;
let (polyfill_result, official_result) = if polyfill_first {
let polyfill_result = time_polyfill_typed(polyfill_client).await;
tokio::time::sleep(delay).await;
let official_result = time_official_typed(official_client).await;
(polyfill_result, official_result)
} else {
let official_result = time_official_typed(official_client).await;
tokio::time::sleep(delay).await;
let polyfill_result = time_polyfill_typed(polyfill_client).await;
(polyfill_result, official_result)
};
if let Ok(sample) = polyfill_result {
polyfill_times.push(sample.elapsed);
}
if let Ok(sample) = official_result {
official_times.push(sample.elapsed);
}
tokio::time::sleep(delay).await;
}
(polyfill_times, official_times)
}
async fn run_raw_pairs(
polyfill_client: &ClobClient,
official_style_client: &reqwest::Client,
url: &str,
iterations: usize,
delay: Duration,
) -> (Vec<Duration>, Vec<Duration>) {
let mut polyfill_times = Vec::with_capacity(iterations);
let mut official_style_times = Vec::with_capacity(iterations);
for i in 1..=iterations {
let polyfill_first = i % 2 == 1;
let (polyfill_result, official_style_result) = if polyfill_first {
let polyfill_result = time_polyfill_raw(polyfill_client, url).await;
tokio::time::sleep(delay).await;
let official_style_result = time_reqwest_raw(official_style_client, url).await;
(polyfill_result, official_style_result)
} else {
let official_style_result = time_reqwest_raw(official_style_client, url).await;
tokio::time::sleep(delay).await;
let polyfill_result = time_polyfill_raw(polyfill_client, url).await;
(polyfill_result, official_style_result)
};
if let Ok(sample) = polyfill_result {
polyfill_times.push(sample.elapsed);
}
if let Ok(sample) = official_style_result {
official_style_times.push(sample.elapsed);
}
tokio::time::sleep(delay).await;
}
(polyfill_times, official_style_times)
}
async fn run_typed_warmups(
polyfill_client: &ClobClient,
official_client: &OfficialClient,
warmups: usize,
delay: Duration,
) {
for _ in 0..warmups {
let _ = time_polyfill_typed(polyfill_client).await;
tokio::time::sleep(delay).await;
let _ = time_official_typed(official_client).await;
tokio::time::sleep(delay).await;
}
}
fn official_style_http_client() -> Result<reqwest::Client, reqwest::Error> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("rs_clob_client"),
);
headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("*/*"),
);
headers.insert(
reqwest::header::CONNECTION,
reqwest::header::HeaderValue::from_static("keep-alive"),
);
headers.insert(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
);
reqwest::Client::builder().default_headers(headers).build()
}
fn parse_polyfill_once(bytes: &[u8]) -> Result<Duration, serde_json::Error> {
let start = Instant::now();
let page: polyfill_rs::types::SimplifiedMarketsResponse = serde_json::from_slice(bytes)?;
black_box(page);
Ok(start.elapsed())
}
fn parse_official_direct_once(bytes: &[u8]) -> Result<Duration, serde_json::Error> {
let start = Instant::now();
let page: OfficialPage<OfficialSimplifiedMarketResponse> = serde_json::from_slice(bytes)?;
black_box(page);
Ok(start.elapsed())
}
fn parse_official_helper_once(bytes: &[u8]) -> Result<Duration, serde_json::Error> {
let start = Instant::now();
let value: serde_json::Value = serde_json::from_slice(bytes)?;
let page: Option<OfficialPage<OfficialSimplifiedMarketResponse>> =
serde_json::from_value(value)?;
black_box(page);
Ok(start.elapsed())
}
fn run_parse_samples<F>(
bytes: &[u8],
iterations: usize,
mut parse_once: F,
) -> Result<Vec<Duration>, serde_json::Error>
where
F: FnMut(&[u8]) -> Result<Duration, serde_json::Error>,
{
let mut times = Vec::with_capacity(iterations);
for _ in 0..iterations {
times.push(parse_once(bytes)?);
}
Ok(times)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let host = std::env::var("POLYMARKET_BENCH_HOST")
.unwrap_or_else(|_| "https://clob.polymarket.com".to_string());
let url = format!("{host}/simplified-markets?next_cursor={INITIAL_CURSOR}");
let iterations = env_usize("POLYMARKET_BENCH_ITERATIONS", 20);
let warmups = env_usize("POLYMARKET_BENCH_WARMUPS", 3);
let parse_iterations = env_usize("POLYMARKET_BENCH_PARSE_ITERATIONS", 200);
let delay = Duration::from_millis(env_u64("POLYMARKET_BENCH_DELAY_MS", 100));
let keepalive = env_bool("POLYMARKET_BENCH_KEEPALIVE", true);
println!("=======================================================");
println!(" polyfill-rs vs rs-clob-client-v2 benchmark");
println!("=======================================================");
println!("Endpoint: {url}");
println!("Iterations: {iterations}");
println!("Warmups: {warmups}");
println!("Parse iterations: {parse_iterations}");
println!("Delay: {} ms", delay.as_millis());
println!("polyfill-rs background keepalive: {keepalive}");
println!("rs-clob-client-v2 rev: {OFFICIAL_SDK_REV}");
println!();
println!("Cold start: client construction + first typed request");
println!("-------------------------------------------------------");
let polyfill_cold = time_polyfill_cold(&host).await;
tokio::time::sleep(delay).await;
let official_cold = time_official_cold(&host).await;
print_single("polyfill-rs", &polyfill_cold);
print_single("rs-clob-client-v2", &official_cold);
println!();
let polyfill_client = ClobClient::new(&host);
let official_client = OfficialClient::new(&host, OfficialConfig::default())?;
let official_style_http = official_style_http_client()?;
if keepalive {
polyfill_client
.start_keepalive(Duration::from_secs(30))
.await;
}
run_typed_warmups(&polyfill_client, &official_client, warmups, delay).await;
println!("Warm connection: first typed request after warmup");
println!("-------------------------------------------------------");
let polyfill_warm = time_polyfill_typed(&polyfill_client).await;
tokio::time::sleep(delay).await;
let official_warm = time_official_typed(&official_client).await;
print_single("polyfill-rs", &polyfill_warm);
print_single("rs-clob-client-v2", &official_warm);
println!();
println!("Steady state: typed client total time");
println!("-------------------------------------------------------");
let (polyfill_typed_times, official_typed_times) =
run_typed_pairs(&polyfill_client, &official_client, iterations, delay).await;
print_stats(
"polyfill-rs",
calc_stats(&polyfill_typed_times),
polyfill_typed_times.len(),
iterations,
);
println!();
print_stats(
"rs-clob-client-v2",
calc_stats(&official_typed_times),
official_typed_times.len(),
iterations,
);
println!();
println!("Steady state: network-only byte fetch");
println!("-------------------------------------------------------");
let _ = time_polyfill_raw(&polyfill_client, &url).await;
tokio::time::sleep(delay).await;
let _ = time_reqwest_raw(&official_style_http, &url).await;
tokio::time::sleep(delay).await;
let (polyfill_raw_times, official_style_raw_times) = run_raw_pairs(
&polyfill_client,
&official_style_http,
&url,
iterations,
delay,
)
.await;
print_stats(
"polyfill-rs HTTP",
calc_stats(&polyfill_raw_times),
polyfill_raw_times.len(),
iterations,
);
println!();
print_stats(
"rs-clob-client-v2-style HTTP",
calc_stats(&official_style_raw_times),
official_style_raw_times.len(),
iterations,
);
println!();
println!("CPU-only parse from cached payload");
println!("-------------------------------------------------------");
let payload = polyfill_client
.http_client
.get(&url)
.send()
.await?
.bytes()
.await?
.to_vec();
println!("payload: {} bytes", payload.len());
let polyfill_parse_times = run_parse_samples(&payload, parse_iterations, parse_polyfill_once)?;
let official_direct_parse_times =
run_parse_samples(&payload, parse_iterations, parse_official_direct_once)?;
let official_helper_parse_times =
run_parse_samples(&payload, parse_iterations, parse_official_helper_once)?;
print_stats(
"polyfill-rs typed parse",
calc_stats(&polyfill_parse_times),
polyfill_parse_times.len(),
parse_iterations,
);
println!();
print_stats(
"rs-clob-client-v2 direct typed parse",
calc_stats(&official_direct_parse_times),
official_direct_parse_times.len(),
parse_iterations,
);
println!();
print_stats(
"rs-clob-client-v2 request-helper parse",
calc_stats(&official_helper_parse_times),
official_helper_parse_times.len(),
parse_iterations,
);
if keepalive {
polyfill_client.stop_keepalive().await;
}
Ok(())
}