cofetch
Chainable, high-performance async HTTP client for C++ event loops
Loading...
Searching...
No Matches
Benchmarks

cofetch vs cpr and cpp-httplib, plus the raw-epoll client cofetch grew out of (baseline/, kept as an internal no-regression reference — not a competitor).

How to run

One-time setup (Debian/Ubuntu):

sudo apt install nginx libcurl4-openssl-dev # nginx serves the load locally

Then, from the repo root:

bench/run_bench.sh | tee bench/results.csv # build + run everything
python3 bench/plot_bench.py bench/results.csv -o docs # regenerate README charts
  • run_bench.sh [total] [concurrency] [chain_count] — defaults 20000 100 2000. It configures a Release build with -DCOFETCH_BUILD_BENCH=ON (FetchContent pulls cpr and cpp-httplib), starts nginx on 127.0.0.1:18081 with bench/nginx.conf (keep-alive, access_log off), and prints one CSV row per run.
  • plot_bench.py needs matplotlib (sudo apt install python3-matplotlib) and writes docs/benchmark-{light,dark}.svg.

RTT scenario (Linux, needs root)

bench/run_netem.sh 10 | tail -n +2 >> bench/results.csv # 10 ms RTT
python3 bench/plot_bench.py bench/results.csv -o docs

run_netem.sh [rtt_ms] shapes loopback with tc netem (delay rtt/2 each direction), reruns the tier-1 comparison plus 100-thread sync pools, and tags rows throughput-rtt<ms>. Request counts scale with RTT so each run holds ~10 s of steady state. The plot script skips the RTT panel when those rows are absent.

Driver knobs (set as env vars for bench_cofetch):

  • COFETCH_BENCH_POLL=1 — busy-poll io_context::poll() instead of run()
  • COFETCH_BENCH_LOOPS=N — N threads, each with its own event loop and Client, splitting the workload
  • COFETCH_BENCH_CB=1 — chain scenario with callbacks instead of a coroutine

Scenarios

  • throughputtotal GETs with concurrency kept in flight. Sync clients (cpr, cpp-httplib) get one thread per unit of concurrency; cofetch keeps them in flight on one thread.
  • chain — sequential dependent requests, one in flight. This is per-request latency; concurrency cannot hide it.
  • throughput-rtt<ms> — tier 1 again, with real round-trip latency injected on loopback by tc netem. Loopback RTT≈0 actually flatters sync clients (every request returns instantly, so the race is pure CPU); with RTT, a sync thread parks for a full round trip per request while an async client keeps its pipeline full.

Results — 2026-07-10

Debian 13 (WSL2, 20 logical cores), gcc 14 -O3, libcurl 8.14, asio 1.38. Raw data: results.csv. WSL run-to-run variance is roughly ±10%; interleaved re-runs confirm the ordering is stable.

One thread each, 20,000 GETs

client in flight time req/s
cofetch (callbacks, busy-poll()) 100 1.10 s 18,240
cofetch (callbacks, run()) 100 1.15 s 17,412
cpr (sync session) 1 1.98 s 10,127
cpp-httplib (sync client) 1 1.55 s 12,889
epoll baseline (internal reference) 100 0.99 s 20,270

With network RTT (tc netem on loopback), one thread each unless noted

At 10 ms RTT (measured 10.2 ms):

client threads in flight req/s
cofetch 1 100 9,353
cpp-httplib (100-thread pool) 100 100 9,571
cpr (100-thread pool) 100 100 9,345
cpp-httplib (sync) 1 1 95
cpr (sync) 1 1 94

(100-in-flight runs do 20,000 GETs; sync runs do 1,000 — counts scale with the RTT ceiling so every run measures ~10 s of steady state.)

At 50 ms RTT: cofetch 1,819 req/s on one thread; sync clients 20 req/s each; 100-thread pools ~1,918.

Throughput here is pinned by in-flight/RTT (100/10 ms = 10,000 req/s ceiling), and that is the point: one cofetch thread runs ~98× ahead of a single-threaded sync client and ties a 100-thread pool, because it multiplexes 100 requests where sync burns an OS thread per in-flight request.

One thread per core (20 threads each), 20,000 GETs

client threads time req/s
cofetch (one event loop per core) 20 0.12 s 166,646
cpr (thread pool) 20 0.16 s 122,122
cpp-httplib (thread pool) 20 0.16 s 127,333

Sequential chain, 2,000 dependent requests, one thread

client time req/s
cofetch (callbacks, busy-poll()) 0.14 s 14,804
cofetch (callbacks, run()) 0.18 s 11,116
cofetch (coroutine, run()) 0.19 s 10,547
cpr 0.20 s 10,209
cpp-httplib 0.16 s 12,761
epoll baseline (internal reference) 0.13 s 15,566

The chain scenario is per-request latency. In blocking run() mode a reactor pays one kernel sleep/wake per request that a raw blocking recv() (cpp-httplib) does not — busy-poll mode removes it, which is exactly what that mode is for. The README chart uses busy-poll for the chain panel because it is cofetch's documented latency mode.

Notes for maintainers

  • The epoll baseline is the performance ceiling cofetch must not drift from. After the 2026-07-10 optimizations (static curl options set once per pooled handle instead of reset+reapply, curl's 0 ms timer kicks turned into a deduplicated asio::post, timer reschedules skipped when a pending expiry is early enough, reactor handlers on asio::recycling_allocator, completion invoked without the type-erased dispatch hop) the loopback gap is ~14% throughput and ~5% chain (busy-poll). Callback vs coroutine chains differ ~5%.
  • Construct the io_context as asio::io_context io(1) in single-threaded apps — the concurrency hint removes internal locking.
  • asio's io_uring backend (-DASIO_HAS_IO_URING -DASIO_DISABLE_EPOLL, link -luring) was measured +14% throughput before these optimizations; re-measure if pursuing.
  • Stock WSL2 kernels ship without sch_netem, with CONFIG_MODVERSIONS=y (so modules need matching symbol CRCs) and no __crc_* symbols in kallsyms. What worked (2026-07-10): clone microsoft/WSL2-Linux-Kernel at the linux-msft-wsl-$(uname -r | cut -d- -f1) tag, seed .config from zcat /proc/config.gz, scripts/config --module NET_SCH_NETEM, touch .scmversion (else vermagic grows a +), then a full make -j$(nproc)modules_prepare alone leaves no Module.symvers and the CRC check rejects the module (--force included). Keep DEBUG_INFO_BTF as in the running config (its fields change struct module, i.e. the module_layout CRC), strip the module's own BTF before loading (objcopy --remove-section=.BTF), insmod. Lasts until WSL shuts down.
  • Persistent socket registration was tried and rejected (2026-07-10): a client-owned epoll set with the epoll fd registered in asio measured −8% throughput and −3% chain vs the one-shot path. asio's epoll reactor already keeps descriptors persistently registered internally; nesting a second epoll only added syscalls per batch. Interleaved A/B, 5 rounds each. Don't retry without new evidence.