measurements/randomx-throughput-20260910/README.md
On this page

Proxy capacity and RandomX CPU benchmarks

Measured 2026-09-10. The Hetzner host was selected from the server entry in ~/sophia_infra.md; no API credentials from that file were used. Production was not restarted, its Mullvad device was not rotated, and no cloud machines were provisioned. All prior files and test artifacts were preserved.

Proxy throughput

RandomX generation is background epoch work. It does not run a puzzle for every request. All requests in one epoch share its key and puzzle. The current measured configuration permits 16 simultaneous fetch/capture/persistence operations, 128 inner connections, 10 MiB responses, a 30-second upstream timeout and up to 30 seconds for record publication. It buffers and archives the response before returning it. The 16-operation permit is released before the client finishes reading its response.

Sixteen is a conservative configurable admission limit, not a RandomX requirement or a measured optimum. Each admitted operation can retain a 10 MiB response plus audit serialization and encryption buffers, and slow upstream/storage operations keep their permits. Requests above the limit receive HTTP 503 rather than joining an unbounded queue. It bounds active fetch/archive work; completed response bodies and connection state also consume memory outside this permit. Raising the setting requires measuring memory and tail latency under load, not changing the puzzle.

A useful admission ceiling is 16 / (fetch + capture + seal + persist seconds). With 0.5, 1 and 2 seconds of occupied time, this gives 32, 16 and 8 requests/s, respectively. These are conditional ceilings, not promised production rates. For ordinary small responses, roughly 8–32 requests/s is a reasonable initial planning range under those latency assumptions. Slow destinations, client setup, transport round trips, CPU contention and storage can lower it. Production is warming, so sustained real Nitro/Mullvad capacity has not been measured.

The outer GET transport returns at most 32 KiB of encrypted stream bytes per exchange and waits for an acknowledgement before advancing. A single-stream upper bound is 32 KiB / outer-exchange time. Client uploads use 4 KiB chunks. Outer replies close their connection and the Python client opens a new HTTP connection for each exchange. Internet TCP/TLS setup therefore adds delay; an exchange time is not simply a network RTT. TLS framing and smaller socket reads further reduce useful body throughput.

Measured opaque transport

Actual host packet transport and Python client, debug build on local loopback; 1 MiB deterministic streams with added delay on each exchange. No TLS, Nitro, VPN, DNS, upstream fetch or archiving. These are isolated transport measurements.

Added time per exchange Streams Aggregate MiB/s
0 ms 1 15.31
50 ms 1 0.511
100 ms 1 0.282
200 ms 1 0.146
100 ms 8 2.195

All transfers verified the complete payload and used 32 outer exchanges per MiB. See transport-results.json and transport-ceiling.py.

Measured local proxy pipeline

This test exercised the real development-mode inner TLS, upstream TLS, response capture, record encryption, file fsync and directory fsync. The enclave was a Mac debug process, using a two-iteration light-mode puzzle and a local HTTPS fixture. It excludes real NSM, production RandomX load, Mullvad, DNS, WAN and Cloudflare. Client TLS/verification setup was excluded from timed request batches.

Upstream delay Clients Response body Successful requests/s
0 ms 1 1 KiB 145.3
0 ms 16 1 KiB 187.2
100 ms 16 1 KiB 112.4
1,000 ms 16 1 KiB 15.33
100 ms 16 100 KiB 59.95

All 272 requests in this run succeeded and produced encrypted records. These are short functional capacity samples, not sustained production results. The initial fixture, whose listening backlog was five, returned five upstream 502 errors among 64 concurrent-case requests. The repeat used a backlog of 128 and retained all success/error counts; that change supports a fixture-limit explanation but is not an instrumented proof of the initial failures' root cause. See proxy-fixture-initial-failure.json, proxy-fixture-results.json and the script.

Response bodies are base64 inside the CBOR audit plaintext, and the outer sealed JSON encodes ciphertext in hex. Large-body archive storage approaches 8/3 times body size plus metadata. The 100 KiB test measured 2.677 times body size. At 10 requests/s with 100 KiB responses, budget roughly 220 GiB/day per archive copy, before redirects and other overhead. More than CPU speed is needed for sustained capacity: disk sizing and retention must support that growth.

RandomX methodology

The fresh Hetzner binary was built from deployed source 92bd47508d7ad48442c98148f6b4e09c6169ab5e, including the native fixes. It uses RandomX 2.0.1 algorithm v2, full dataset mode, hardware AES, JIT and W^X protection, ordinary pages, and the actual dependent chain input/zeroization implementation. Rust 1.97.1 and Ubuntu GCC 13.3 built the Linux release binary. No MSR, huge-page, CPU-affinity, service or kernel setting was changed.

The KVM guest exposes AMD EPYC-Genoa Processor, 16 vCPUs and about 30.6 GiB RAM. Its exact physical EPYC SKU and physical SMT topology are not exposed. Results characterize this VM, not all Genoa CPUs. Benchmarks run at nice 10 and stop if live solver work starts or memory becomes scarce. The seven-worker generator shares one dataset; the nine-process fleet case uses nine separate datasets.

Single/shared-worker runs use 20,000 dependent hashes per worker, with repeat measurements at one and seven workers. Separate-puzzle processes use 10,000 hashes each. Dataset setup is recorded separately. Each run generates a new random dataset key; short-run variability, virtualization and scheduling matter. Mining-style independent-nonce runs use the same compiled vendor library and explicit v2/JIT/W^X/full-mode settings, with and without batch hashing.

Fresh Hetzner results

Layout Aggregate dependent H/s Slowest chain H/s Peak summed RSS
1 worker, two runs 501–512 501–512 2.29 GiB
2 workers, shared dataset 930 465 2.29 GiB
4 workers, shared dataset 1,575 394 2.29 GiB
7 workers, two runs, shared dataset 2,256–2,285 322–326 2.30 GiB
9 processes, separate datasets 4,503 482 20.59 GiB

All nine cases passed, including both independent-nonce comparisons; the guest reported zero CPU steal time during each case. Dataset initialization took about 36–38 seconds. All benchmark processes exited, and the existing solver, mirror and origin-tunnel services remained active.

The nine-process result sums each process's measured hashing rate; individual hashing intervals overlapped but were not perfectly aligned. Its 90,000 total hashes took at most 58.56 seconds including dataset initialization and polling, or 1,537 H/s including that one-time cost. Per-process hashing samples lasted only about 20 seconds. The 4,503 H/s hashing estimate is 27% above the arithmetic daily-puzzle requirement; sustained recovery, checkpointing and outage margin remain unmeasured. Each process owns a dataset, matching the deployed solver layout; shared-dataset threads are substantially slower on this VM. Profiling would be needed to distinguish page-permission synchronization, cache, scheduling and other causes of that difference.

At these solo rates, the deployed work count projects 6.93–7.07 days to recover one puzzle. This extrapolates short benchmarks, not a week-long recovery test. Using seven shared-dataset generation workers projects 37.24–37.73 hours to generate the segments, plus setup. The previous Graviton5 measurement projected 25.14 hours. Generation and solving use different parallelism; neither rate is a per-request proxy cost.

The same vendor binary measured 504.03 H/s on unbatched independent nonces and 519.13 H/s with batching, using one thread and ordinary pages. Both produced the same final result hash. These particular mining-style results are close to the relay's solo rate on this guest; that does not establish a conversion factor for XMRig submissions, other CPUs or different tuning.

Raw commands, timing, stdout/stderr, source/binary identity and environment are retained in hetzner/. summary.json records the derived projections.

Published CPU comparison

Single-core comparison

For this project's sequential delay, prioritize a single dependent chain on one active CPU thread. Whole-machine mining totals divided by thread count do not measure isolated-core performance.

CPU H/s with one active thread Workload/evidence
AMD EPYC Genoa, Hetzner KVM guest 501–512 Our fresh actual dependent-chain benchmark; physical SKU and host core sharing unknown
AWS Graviton5 506.6 Our earlier actual dependent-chain calibration; prior build
AMD Ryzen 9 9950X3D 1,093.00 Published single-thread RandomX v2 mining, one submission
Intel Core i7-12650H 828.46 Published single-thread RandomX v2 mining, one submission
Intel Core i5-12600K 709.60 Published single-thread RandomX v2 mining, one submission

Published single-thread figures come from the XMRig RandomX v2 single-thread index, checked 2026-09-10. Correction after inspecting XMRig 6.26.0: its single-thread benchmark feeds a cumulative checksum of earlier outputs into subsequent inputs; these are not fully independent mining inputs. However, its pipelined RandomX API prepares the next input before the current output is available, unlike the relay's strict dependent chain. The relay must be benchmarked on those CPUs before using their rates to calibrate recovery time. They are useful candidates to test, not established attacker dependent-chain rates. One active thread also does not establish performance per physical core under simultaneous load across the entire machine. See the source inspection and exact Ryzen submission.

Whole-system mining submissions

These are selected fast published mining submissions, not our own runs or a controlled comparison. All report RandomX v2 (rx/2) with XMRig 6.26.0. Their compiler, memory, huge-page, MSR and tuning choices differ from the relay. The per-thread column is aggregate hashrate divided by the number of active mining threads; it is not a dependent-chain benchmark or a measured isolated-core rate.

CPU Sockets Mining threads Total H/s H/s per mining thread
AMD Ryzen 9 9950X 16-Core Processor 1 32 28,441 888.8
AMD Ryzen 9 7950X 16-Core Processor 1 32 19,589 612.1
Apple M5 Max 1 18 9,889 549.4
AMD EPYC 9965 192-Core Processor 2 384 228,009 593.8
AMD EPYC 9754 128-Core Processor 2 256 141,758 553.7
Intel(R) Xeon(R) 6767P 2 256 95,088 371.4

The server rows with two sockets describe the whole dual-socket system, not one processor. Hardware identity and tuning are submitter-reported; XMRig's validation does not make the environment equivalent to the relay. Raw selected fields, submission dates and primary API URLs are in published-comparison.json.

Do not divide a puzzle's work by these total mining rates: one puzzle has seven serial segments, each composed of dependent hashes. Additional cores can solve additional independent puzzles or generate the seven segments in parallel, but cannot perform the next dependent hash before its predecessor is known.

At the deployed work count, total serial work is 306,376,868 hashes. For an actual measured dependent-chain rate h, estimated recovery days are 306376868 / h / 86400. Recovering one new puzzle every 24 hours requires at least 3,546 dependent hashes/s in aggregate across separate puzzle solvers, plus margin for setup, checkpointing, outages and contention. The seven-day value is calibrated to a reference machine, not a minimum established for the fastest available CPU.

The earlier Graviton5 measurement recorded 506.6 dependent hashes/s solo and 3,386.4 total with seven shared-dataset workers. Its slowest worker was 483.8 H/s. That is a previous-day build/calibration, not a fresh run of the patched binary or a benchmark under proxy load.

Reproduction

For an individual dependent-chain run, build the pinned source and run:

cargo build --release --locked -p relay-timelock
nice -n 10 target/release/relay-timelock calibrate --samples 20000 --workers 1 --mode full
nice -n 10 target/release/relay-timelock calibrate --samples 20000 --workers 7 --mode full

run-hetzner.py reproduces the complete sequence when placed next to a source/ checkout at the pinned commit with its release binary already built. It requires a fresh results/ path and sufficient idle memory, and retains all output. Do not run its nine-process case on a machine without at least 24.5 GiB available. The transport and proxy scripts run from the project root with the local Python client importable and debug binaries built. Their output directory constants must point to fresh directories for another run; existing artifacts are preserved.