Skip to content

Phase A: Client (1 worker)

Pure-Go client at the final tree against its predecessor and against Apple’s Go binding over libfdb_c 7.3.77. One closed-loop worker, 11 workloads, counterbalanced. Part of the hcloud-rebench-20261014 campaign, which lists the other phases.

Arms

ArmClientSource
final pure-Gopkg/fdbgo267a41434a81723b07e7150869d702636a1b91a9
before pure-Gopkg/fdbgo before the allocation work (after read-path parity)3824f14654edae8431b0085de8ebf83f5a460a08
CGoApple’s Go binding over libfdb_c 7.3.77, built from the final tree267a41434…, native library 7.3.77

Executables: //tools/clientbench/cmd/clientbench:clientbench_go (per tree) and :clientbench_cgo, harness d94b4c61377d173b7c6884fde426a227dab0f0bb (tools/clientbench, docs/benchmarks/campaign-harness.md).

Environment

  • Hetzner ccx43, fsn1: 16 dedicated vCPUs (AMD EPYC Milan, 8 cores × 2 SMT threads, one NUMA node), 64 GiB. Guest CPU groups by sibling pairs: housekeeping/runner 0–1, fdbserver 2–7, client 8–15. Guest affinity is not physical-host isolation.
  • One fdbserver 7.3.77 process, single redundancy, ssd-2 engine, API 730. The client ran in a network namespace with no default route, reaching the server over a private veth pair on the same host. No artificial delay.
  • Go 1.26.9, GOMAXPROCS=8, GOGC=100, GOMEMLIMIT=off, empty GODEBUG.
  • Every run’s quiescence gate, CPU-steal/OOM/server-health telemetry and pinned input hashes passed. All 11 workload campaigns completed; none was rerun.

Design

  • Per workload: 6 balanced blocks, each running all three arms once in a preregistered order (seed 641001; every arm order appears once). One fresh process per run: 200 warmup then 3,000 measured transactions, so 18,000 measured transactions per arm and workload, 594,000 in total.
  • Workloads: grv (read version only), get100/get1000/get10000 (one point read of a 100 B/1 kB/10 kB value), read1000 (point read in a read-only transaction), range10/range100 (one range read of 10/100 rows), set100 (one 100 B write and commit), ryw (write then read the same key in a transaction that is never committed: no server round trip), batchset50/batchset50x1000 (50 writes of 100 B/1 kB and one commit).
  • Estimands: per-run statistics; the paired effect is the geometric mean of the six within-block ratios, with a nominal 95% percentile bootstrap over blocks (20,000 resamples, seed 391706). Absolute values are medians of the six run-level statistics. No outliers were removed.
  • CPU scopes. Shutdown process CPU (E−A) is the process’s user+system CPU from after binary identity hashing to after measurement, finalizer drain, database close and native network stop, divided by measured transactions. It includes startup, fixture, warmup and validation, so it charges each client for work the measured window can hide (deferred GC, native threads). Go bytes/mallocs per transaction count the Go heap only; the native libfdb_c heap is unobserved, so they are not comparable with the CGo arm.

Results

Figure A.1: Client at 1 worker, pure-Go / CGoBelow 1.0× favors pure-Go. Single host.

Pure-Go has lower mean latency on 10 of 11 workloads, lower p99 on 8, and lower CPU on 7. The rest are inconclusive; none favors CGo.

0.15×0.15×0.25×0.25×0.5×0.5×0.7×0.7×1.0×1.0×1.4×1.4×← Pure-Go faster / lower CPUCGo faster / lower CPU →Mean latency: final pure-Go / CGogrvgrv: 0.515× [0.484, 0.545]0.515× [0.484, 0.545]get100get100: 0.651× [0.594, 0.717]0.651× [0.594, 0.717]get1000get1000: 0.637× [0.598, 0.698]0.637× [0.598, 0.698]get10000get10000: 0.637× [0.585, 0.700]0.637× [0.585, 0.700]read1000read1000: 0.613× [0.583, 0.647]0.613× [0.583, 0.647]range10range10: 0.721× [0.618, 0.842]0.721× [0.618, 0.842]range100range100: 0.846× [0.713, 1.024]0.846× [0.713, 1.024]set100set100: 0.897× [0.873, 0.920]0.897× [0.873, 0.920]rywryw: 0.189× [0.161, 0.219]0.189× [0.161, 0.219]batchset50batchset50: 0.931× [0.904, 0.950]0.931× [0.904, 0.950]batchset50x1000batchset50x1000: 0.918× [0.898, 0.937]0.918× [0.898, 0.937]p99 latency: final pure-Go / CGogrvgrv: 0.592× [0.566, 0.625]0.592× [0.566, 0.625]get100get100: 0.675× [0.613, 0.743]0.675× [0.613, 0.743]get1000get1000: 0.679× [0.627, 0.743]0.679× [0.627, 0.743]get10000get10000: 0.725× [0.668, 0.776]0.725× [0.668, 0.776]read1000read1000: 0.648× [0.596, 0.701]0.648× [0.596, 0.701]range10range10: 0.666× [0.628, 0.701]0.666× [0.628, 0.701]range100range100: 0.861× [0.755, 1.008]0.861× [0.755, 1.008]set100set100: 0.939× [0.845, 1.020]0.939× [0.845, 1.020]rywryw: 0.245× [0.214, 0.284]0.245× [0.214, 0.284]batchset50batchset50: 0.989× [0.913, 1.126]0.989× [0.913, 1.126]batchset50x1000batchset50x1000: 0.942× [0.909, 0.979]0.942× [0.909, 0.979]Shutdown process CPU (E−A): final pure-Go / CGogrvgrv: 0.659× [0.621, 0.692]0.659× [0.621, 0.692]get100get100: 0.948× [0.817, 1.118]0.948× [0.817, 1.118]get1000get1000: 0.858× [0.810, 0.925]0.858× [0.810, 0.925]get10000get10000: 0.959× [0.884, 1.046]0.959× [0.884, 1.046]read1000read1000: 0.775× [0.735, 0.815]0.775× [0.735, 0.815]range10range10: 0.969× [0.834, 1.122]0.969× [0.834, 1.122]range100range100: 1.101× [0.968, 1.269]1.101× [0.968, 1.269]set100set100: 0.869× [0.841, 0.899]0.869× [0.841, 0.899]rywryw: 0.228× [0.201, 0.256]0.228× [0.201, 0.256]batchset50batchset50: 0.863× [0.854, 0.873]0.863× [0.854, 0.873]batchset50x1000batchset50x1000: 0.887× [0.864, 0.922]0.887× [0.864, 0.922]
95% CI below 1.0× 95% CI includes 1.0× (inconclusive) 95% CI above 1.0×
Figure A.2: Allocations, final / beforeBelow 1.0× means the final tree allocates less.

The allocation work reduced mallocs per transaction on all 11 workloads, down to 0.797×. Mean latency did not change measurably.

0.7×0.7×0.8×0.8×0.9×0.9×1.0×1.0×1.1×1.1×← Final allocates lessBefore allocates less →Go bytes/tx: final / beforegrvgrv: 0.993× [0.992, 0.994]0.993× [0.992, 0.994]get100get100: 0.929× [0.928, 0.930]0.929× [0.928, 0.930]get1000get1000: 0.944× [0.943, 0.944]0.944× [0.943, 0.944]get10000get10000: 0.982× [0.982, 0.983]0.982× [0.982, 0.983]read1000read1000: 0.997× [0.996, 0.998]0.997× [0.996, 0.998]range10range10: 0.844× [0.844, 0.845]0.844× [0.844, 0.845]range100range100: 0.824× [0.824, 0.825]0.824× [0.824, 0.825]set100set100: 0.956× [0.954, 0.957]0.956× [0.954, 0.957]rywryw: 0.995× [0.995, 0.995]0.995× [0.995, 0.995]batchset50batchset50: 0.993× [0.992, 0.994]0.993× [0.992, 0.994]batchset50x1000batchset50x1000: 0.998× [0.998, 0.998]0.998× [0.998, 0.998]Go mallocs/tx: final / beforegrvgrv: 0.950× [0.950, 0.950]0.950× [0.950, 0.950]get100get100: 0.874× [0.873, 0.874]0.874× [0.873, 0.874]get1000get1000: 0.874× [0.873, 0.874]0.874× [0.873, 0.874]get10000get10000: 0.874× [0.874, 0.875]0.874× [0.874, 0.875]read1000read1000: 0.947× [0.947, 0.948]0.947× [0.947, 0.948]range10range10: 0.797× [0.796, 0.797]0.797× [0.796, 0.797]range100range100: 0.798× [0.798, 0.798]0.798× [0.798, 0.798]set100set100: 0.919× [0.918, 0.919]0.919× [0.918, 0.919]rywryw: 0.950× [0.950, 0.950]0.950× [0.950, 0.950]batchset50batchset50: 0.973× [0.973, 0.973]0.973× [0.973, 0.973]batchset50x1000batchset50x1000: 0.973× [0.973, 0.973]0.973× [0.973, 0.973]
95% CI below 1.0× 95% CI includes 1.0× (inconclusive) 95% CI above 1.0×

Final pure-Go vs CGo

Ratio final/CGo (below 1 means pure-Go is faster or cheaper), nominal 95% interval.

WorkloadMean latencyp99 latencyShutdown process CPU (E−A) per tx
grv0.515 [0.484, 0.545]0.592 [0.566, 0.625]0.659 [0.621, 0.692]
get1000.651 [0.594, 0.717]0.675 [0.613, 0.743]0.948 [0.817, 1.118]
get10000.637 [0.598, 0.698]0.679 [0.627, 0.743]0.858 [0.810, 0.925]
get100000.637 [0.585, 0.700]0.725 [0.668, 0.776]0.959 [0.884, 1.046]
read10000.613 [0.583, 0.647]0.648 [0.596, 0.701]0.775 [0.735, 0.815]
range100.721 [0.618, 0.842]0.666 [0.628, 0.701]0.969 [0.834, 1.122]
range1000.846 [0.713, 1.024]0.861 [0.755, 1.008]1.101 [0.968, 1.269]
set1000.897 [0.873, 0.920]0.939 [0.845, 1.020]0.869 [0.841, 0.899]
ryw0.189 [0.161, 0.219]0.245 [0.214, 0.284]0.228 [0.201, 0.256]
batchset500.931 [0.904, 0.950]0.989 [0.913, 1.126]0.863 [0.854, 0.873]
batchset50x10000.918 [0.898, 0.937]0.942 [0.909, 0.979]0.887 [0.864, 0.922]

Final vs before pure-Go: the allocation work

Ratio final/before.

WorkloadMean latencyp99 latencyShutdown process CPU (E−A) per txGo bytes/txGo mallocs/tx
grv1.025 [0.970, 1.079]1.025 [0.978, 1.079]1.027 [0.981, 1.063]0.993 [0.992, 0.994]0.950 [0.950, 0.950]
get1001.021 [0.973, 1.093]0.960 [0.896, 1.028]1.002 [0.975, 1.038]0.929 [0.928, 0.930]0.874 [0.873, 0.874]
get10001.017 [0.989, 1.047]1.015 [0.922, 1.096]1.000 [0.976, 1.026]0.944 [0.943, 0.944]0.874 [0.873, 0.874]
get100000.984 [0.918, 1.055]0.977 [0.931, 1.023]1.004 [0.986, 1.022]0.982 [0.982, 0.983]0.874 [0.874, 0.875]
read10001.042 [0.998, 1.087]1.032 [0.932, 1.143]1.007 [0.956, 1.050]0.997 [0.996, 0.998]0.947 [0.947, 0.948]
range100.987 [0.915, 1.081]0.953 [0.898, 1.018]0.978 [0.939, 1.022]0.844 [0.844, 0.845]0.797 [0.796, 0.797]
range1001.039 [0.977, 1.158]0.951 [0.916, 0.998]0.952 [0.937, 0.971]0.824 [0.824, 0.825]0.798 [0.798, 0.798]
set1001.019 [0.995, 1.037]0.994 [0.881, 1.131]1.009 [0.987, 1.028]0.956 [0.954, 0.957]0.919 [0.918, 0.919]
ryw1.021 [0.967, 1.082]1.016 [0.926, 1.097]1.011 [0.947, 1.076]0.995 [0.995, 0.995]0.950 [0.950, 0.950]
batchset501.000 [0.980, 1.019]1.012 [0.955, 1.083]0.990 [0.969, 1.016]0.993 [0.992, 0.994]0.973 [0.973, 0.973]
batchset50x10001.011 [0.991, 1.029]0.992 [0.961, 1.025]1.002 [0.981, 1.024]0.998 [0.998, 0.998]0.973 [0.973, 0.973]

Absolute values

Medians of six run-level statistics per arm.

WorkloadArmTransactionsMean µsp50 µsp99 µsShutdown CPU (E−A) µs/txGo bytes/txGo mallocs/tx
grvfinal pure-Go18000217.5206.3354.0224.9385638.1
grvbefore pure-Go18000210.1200.2333.5218.9388040.1
grvCGo18000422.6410.3590.9338.942410.0
get100final pure-Go18000375.5368.2548.2424.6638269.2
get100before pure-Go18000370.2361.5569.6424.7687379.2
get100CGo18000594.2581.7856.5481.566416.0
get1000final pure-Go18000401.8391.7633.0455.5825469.2
get1000before pure-Go18000394.9388.2619.7447.8874779.2
get1000CGo18000652.1650.7937.6544.2158316.0
get10000final pure-Go18000433.6425.6820.4520.62670169.5
get10000before pure-Go18000428.4416.6832.5515.62717779.4
get10000CGo18000682.4676.61142.6551.71079616.0
read1000final pure-Go18000366.6356.8565.1357.6780554.1
read1000before pure-Go18000354.7345.1531.3346.9783257.1
read1000CGo18000596.0583.4819.7460.3154313.0
range10final pure-Go18000435.6437.8643.3474.61050374.3
range10before pure-Go18000456.2449.9682.0490.51244493.2
range10CGo18000635.5629.5993.9502.1316944.0
range100final pure-Go18000557.7536.01103.4629.55035674.8
range100before pure-Go18000560.2535.61160.8653.46110193.8
range100CGo18000729.3790.61362.7577.725675224.0
set100final pure-Go180001920.21883.12844.0566.810294101.3
set100before pure-Go180001876.41862.52863.0556.210775110.2
set100CGo180002140.32099.93068.7661.044010.0
rywfinal pure-Go180009.75.951.623.5456438.0
rywbefore pure-Go180009.75.952.123.6458740.0
rywCGo1800049.432.4213.7100.664814.0
batchset50final pure-Go180002368.32365.74008.6881.665691321.3
batchset50before pure-Go180002349.12354.74181.9892.866160330.3
batchset50CGo180002541.12575.74358.01018.1161659.0
batchset50x1000final pure-Go180002726.12662.74880.51278.7209165322.2
batchset50x1000before pure-Go180002702.72626.54797.41275.9209549331.2
batchset50x1000CGo180002974.02902.05141.61452.9161659.0

Interpretation

What the data shows:

  • Pure-Go was faster than CGo on every workload’s mean latency, with intervals below 1 everywhere except range100 (0.846, interval reaching 1.024). The gap is largest where the client’s own work dominates:
    • ryw (0.189): no server round trip at all. It measures transaction creation, a buffered write, a read served from the read-your-writes cache, and cancellation. Pure-Go took 9.7 µs mean, CGo 49.4 µs.
    • grv (0.515): one round trip to the GRV proxy, 217 µs vs 423 µs.
    • Point reads (0.613–0.651): one GRV plus one storage read.
  • The absolute saving is about the same everywhere a round trip occurs, so the ratio shrinks as transactions get longer. CGo’s mean minus pure-Go’s was 0.17–0.25 ms on every workload except ryw (grv 0.21 ms, set100 0.22 ms, batchset50x1000 0.25 ms). Commits take 1.9–3.0 ms in total, so the same saving gives ratios of 0.897–0.931 there.
  • Process CPU through shutdown (E−A) favored pure-Go on grv (0.659), read1000 (0.775), get1000 (0.858), the commits (0.863–0.887) and ryw (0.228). For get100, get10000, range10 and range100 the intervals include 1; range100’s point estimate is above 1 (1.101, interval 0.968–1.269).
  • The allocation work cut Go allocations (final vs before): mallocs per transaction fell 3–20% on every workload, and bytes up to 18% (range100 0.824, range10 0.844). Latency did not change measurably: every mean-latency interval includes 1. CPU through shutdown was lower only for range100 (0.952, interval 0.937–0.971).

What the design suggests but did not isolate (hypotheses, not measured causes):

  • The CGo arm’s extra latency is consistent with its per-call costs: each operation crosses the cgo boundary, and each result is delivered by libfdb_c’s network thread through a future callback to a waiting goroutine. The pure-Go client issues and completes requests on goroutines over Go’s network poller with no thread handoff. ryw is the cleanest view of this, since no network is involved; no profile attributed the time.
  • Pure-Go’s narrower margin and higher CPU on range100 are consistent with materializing 100 key-value slices on the Go heap per read (about 50 kB and 75 mallocs per transaction), which the C++ client keeps in its arena. Go GC work is charged to E−A. This was not profiled.

Limits

  • One host, no physical network. Server and client shared a host and talked over a veth pair, not a NIC, switch and real network path. A remote deployment adds network time that both clients pay alike, so it would most likely shrink these ratios toward 1; they are not a prediction for that case. The multi-node VPC design (tools/benchcampaign/multinode/) measures it.
  • One worker, one process. Batching of read versions and commits, load-balancing across replicas, and cgo thread contention appear only under concurrency, which phase A does not test.
  • One server process, single redundancy. No proxy/log/storage split, no replication, no cross-shard reads.
  • Scopes. Latency is closed-loop per transaction, including retries (none occurred). E−A CPU includes startup and warmup; the Go heap figures exclude native memory. Intervals are nominal and per workload, with no correction for multiple comparisons and no aggregate across workloads.

Reproduction

The harness, plan sealing and adapter are in tools/clientbench and tools/benchcampaign/singlenode; docs/benchmarks/campaign-harness.md describes the benchmark sources. The raw per-run artifacts (plans, schedules, per-transaction samples, telemetry) and the analysis outputs are retained outside the repository with the campaign’s provenance receipts.