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.