Performance testing, stress testing, and benchmarking for
AgentSpaces. The project follows
the approach the Embabel project takes for its performance work:
JMH microbenchmarks for the hot paths, kept
apart so measurement code never leaks into the shipped artifacts, plus plain
main harnesses for whole-fleet stress runs.
The build needs JDK 21 and Maven. It runs against the published AgentSpaces
libraries at the version agentspaces.version names in pom.xml. Until those
artifacts are on Maven Central, install the core into your local repository
first:
git clone https://github.com/badmonkeyai/AgentSpaces.git agentspaces
mvn -f agentspaces/pom.xml install -DskipTestsThen build this project:
mvn packagemvn package runs the smoke tests and writes target/benchmarks.jar, a
self-contained jar with the benchmarks, the stress harnesses, and every
dependency.
The benchmarks cover the layers whose per-operation cost multiplies through the whole fabric:
| Benchmark | What it measures |
|---|---|
CborCodecBenchmark |
CBOR (RFC 8949) encode and decode, small records and 16 KiB blobs |
CryptoBenchmark |
SHA-256, Ed25519 (RFC 8032) sign and verify, AES-256-GCM group-key seal and open, at 256 B, 4 KiB, and 64 KiB |
HlcBenchmark |
Hybrid logical clock: local stamp, remote merge, encode, parse |
TemplateBenchmark |
Template matching, the inner loop of read, take, and notify routing |
LocalSpaceBenchmark |
Space verbs at populations of 100 to 10,000 entries |
CrdtBenchmark |
Full-state, per-entry, and take-claim CRDT merges at 100 to 10,000 entries |
WireCodecBenchmark |
A complete signed wire frame: encode plus sign, decode plus verify |
Run everything:
java -jar target/benchmarks.jarRun one family, or pass any JMH option:
java -jar target/benchmarks.jar Cbor
java -jar target/benchmarks.jar -prof gc CrdtBenchmark
java -jar target/benchmarks.jar -l # list the benchmarksThe class-level defaults (3 warmup and 5 measurement iterations, 1 fork) suit
a laptop; raise forks and iterations on the command line for publishable
numbers. Run JMH from the jar rather than mvn exec:java: each fork is a
fresh JVM that inherits the jar's classpath, which a Maven-hosted run cannot
pass on.
The stress harnesses start a real fleet in one JVM: every peer runs its own
node, Ed25519 identity, TCP listener, gossip, and space replica, so results
include the full wire path. Every peer listens on 127.0.0.1 only. Each
harness reads its shape from system properties and prints a compact report
with latency percentiles. They run with no forks, so mvn exec:java suits
them (after a mvn compile); FleetThroughputStress is the default.
# Contended takes across the fleet: throughput and write-to-take latency
mvn exec:java \
-Dexec.mainClass=ai.badmonkey.agentspaces.perf.stress.FleetThroughputStress \
-Dpeers=3 -Dworkers=2 -Dtasks=200 -DsettleMs=100
# Rumor spread: time until a write is visible on every peer
mvn exec:java \
-Dexec.mainClass=ai.badmonkey.agentspaces.perf.stress.GossipPropagationStress \
-Dpeers=4 -Dentries=50
# Churn: a peer dies, writes continue, a newcomer joins and catches up
mvn exec:java \
-Dexec.mainClass=ai.badmonkey.agentspaces.perf.stress.ChurnRecoveryStress \
-Dpeers=3 -Dentries=100
# The content swarm: one seeder, every other peer fetching at once over TCP;
# aggregate MiB/s and the share of bytes the seeder served against the swarm
mvn exec:java \
-Dexec.mainClass=ai.badmonkey.agentspaces.perf.stress.ContentSwarmStress \
-Dpeers=8 -Dmebibytes=64 -DpieceKiB=1024 -Dbinding=tcp -DdeadlineSeconds=1800
# The persistent trees (srad/agentspaces-pdt.md PD-O-05, srad/agentspaces-pdt2.md
# P2-O-04): every mode, a 64 MiB blob across eight peers
MAVEN_OPTS=-Xmx4g mvn exec:java \
-Dexec.mainClass=ai.badmonkey.agentspaces.perf.stress.PdtStress \
-Dmodes=all -Dpeers=8 -Dmebibytes=64 -Dbinding=tcp -DdeadlineSeconds=1800
# The chunker and hasher (PD-Q-PRF-07, P2-B-05) over a 64 MiB array
mvn exec:java \
-Dexec.mainClass=ai.badmonkey.agentspaces.perf.bench.FastCdcBench -Dmebibytes=64 -Dpasses=3ContentSwarmStress (srad/large-networks-bulk-data.md LB-O-08) writes the
artifact to a temporary file, publishes it from peer 0 with a file-backed
piece store on every peer, and reports per fetcher how many bytes came from
the seeder and how many from the other fetchers; with one holder the seeder
share is 100%, and it falls as the fleet grows. -Dbinding=tcp (the default)
tls (the TLS binding with per-peer channel certificates), or dtls picks what
every peer listens on; quic lives in its own module and is not wired into
MiniFleet. Every ten seconds the run prints each fetcher's verified pieces, and a fetcher
that makes no progress for thirty seconds prints the tail of its trace.
Recorded on 2026-10-27 on one macOS laptop, 1 GiB in 1 MiB pieces, every peer in one JVM:
| Peers | Binding | Wall time | Aggregate | Seeder share |
|---|---|---|---|---|
| 2 | tcp | 2.3 s | 444 MiB/s | 100% |
| 8 | tcp | 17.1 s | 411 MiB/s | 26% |
| 2 | tls | 2.5 s | 418 MiB/s | 100% |
| 8 | tls | 18.4 s | 390 MiB/s | 27% |
Every fetcher served the others, and the seeder's share fell to about a quarter. LB-Q-PRF-01 asks eight peers on one host to finish within 2.5 times the single-stream copy; the swarm took 7.4 times, so the row fails as written. Seven fetchers receive seven times the bytes of one, and the eight-peer aggregate rate stays near the single stream's, so on one host the swarm's time grows with the fetcher count. The first eight-peer run found a deadlock in the TCP and TLS readers, fixed the same day (srad/large-networks-bulk-data.md 10.4).
PdtStress (srad/agentspaces-pdt.md PD-O-05) runs the measures of that
document's section 5.4 on a fleet of pack node stores on disk: peer 0 writes
and the others read. -Dmodes is all or a list of map (a 10,000-entry map
fetched whole, then one key changed and fetched by delta; PD-Q-PRF-01 and 06),
cold (the blob fetched by every reader at once, timed against
ContentSwarmStress moving the same bytes in 1 MiB pieces on the same binding;
PD-Q-PRF-08), blob-edit (ten one-byte edits in place; PD-Q-PRF-02),
blob-insert (ten inserts of 100 bytes; PD-Q-PRF-03), blob-append (ten
appends of 1 MiB; PD-Q-PRF-04), catch-up (a reader that held nothing
fetches the final version naming a base it lacks; PD-Q-PRF-06), and
verified-window (srad/agentspaces-pdt2.md P2-B-03, P2-Q-PRF-05: a member that
holds nothing of the blob reads 2 MiB from its middle through the writer's Bao
outboard, and the report counts every node it stored by kind). The report
prints each delta's bytes per reader and round trips, then one PASS or FAIL
line per row against its bound. The blob modes share one blob, and
catch-up and verified-window each need a peer that the other modes leave
out. Every member advertises the PDT feature level -Dlevel (2 by default):
at level 2 the writer keeps an intra-chunk delta frame per edit, so
blob-edit moves the frame and the changed path in place of the edited chunk
(P2-Z-03, P2-Q-PRF-08); -Dlevel=1 moves the chunk whole, as before.
PdtStress hands the PDT 2 phase 5 modes to PushReconcileStress, which runs them on a fleet of
its own, since each member needs a ref space and a set of roots: push (srad/agentspaces-pdt2.md
P2-P-01 to P2-P-03, P2-Q-PRF-04: every member writes small heads first so the Plumtree settles, then
peer 0 pushes a 200 KB delta to every other member, and the report counts the copies of each bundle
the writer's bus sent, the fetches the followers made, and the fallbacks, and gives each follower's
delay and the swarm's GRAFT and repair counters during the push, ROBUSTNESS.md §10.5) and reconcile (P2-K-02,
P2-Q-PRF-06: two members reconcile two sets of 100,000 roots that differ by 1,000 at random places,
once by negentropy and once by RIBLT). Run them as -Dmodes=push,reconcile -Dpeers=21. The push
report checks P2-Q-PRF-04 as the owner restated it on 2026-11-09: (a) the writer's answers to askers
that had heard another member announce the bundle, against 0; and (b) the eager copies of each bundle
against the writer's eager edges, its view less its lazy members, counted just before and just after
the write. For (a), a witness reads every member's wire during the push (AskerWitness). It notes
which members heard the bundle announced by a member other than the writer, and it splits the GRAFTs
for the bundle that reach the writer into those askers and askers that heard the writer alone, which
the check allows (ROBUSTNESS.md §10.11). The report keeps every answer visible as a figure: the
bundle copies the writer sent beyond its eager pushes. A frame the witness cannot read counts against
the check, so it never passes on what the witness did not see.
PdtStress hands the PDT 2 phase 8 modes to PdtRefStress (P2-O-04), which gives every member a
ref facade and runs two content stores: store-cold (P2-Q-PRF-02: peer 0 writes a blob ref, pins it
on peers 1 and 2, and leaves once both receipts are in; every other peer then fetches the blob cold,
timed against ContentSwarmStress moving the same bytes from one seeder to as many fetchers, and the
report gives the stores' share of the bytes), rrb-splice (P2-Q-PRF-07: a vector ref of
-Delements integers, a million by default, followed by every other peer, then ten insertions at
random places, each delta and each reader's bytes), and text (P2-Q-PRF-09: two replicas edit one
text of a map ref at once, 1,000 edits in one swap each; the report gives the bytes each member
received per edit, the time of the first read that merges both heads, and what one edit after the
merge ships). No run moves a value above 64 MiB, the owner's ruling of 2026-11-09
(srad/agentspaces-pdt2.md §10.16): PdtStress and PdtRefStress refuse a larger blob, and the rows
that named 1 GiB or 10 GiB read as rates and ratios.
FastCdcBench (PD-Q-PRF-07) is a plain harness, since the figure is a byte
rate over one large input: the best of -Dpasses passes of the cuts alone,
the cuts plus a SHA-256 per chunk, and the whole PBlob build into a store
that keeps nothing, over a seeded array of -Dmebibytes or a file given as
-Dfile; over an array it also times BLAKE3 (P2-B-05): BouncyCastle's
Blake3Digest over the whole input, and BaoOutboard.of, the identity and the
bo nodes in one pass, which is what a publish hashes. Recorded on 2026-11-08 on one macOS laptop (10 cores, 32 GiB) over a
1,024 MiB array, best of three passes: cuts 2,567 MiB/s, cuts plus SHA-256
1,424 MiB/s, the whole blob build 1,268 MiB/s (14,369 chunks, mean 74,726
bytes); the row asks for 300 MiB/s.
Recorded on 2026-11-08 on the same laptop (PdtStress, -Dmodes=all -Dpeers=8 -Dmebibytes=1024 -Dbinding=tcp -DdeadlineSeconds=1800, MAVEN_OPTS=-Xmx6g,
run beside the reactor gate; the dtls run of 2026-11-09 follows the phase 4 table):
| Measure | Figure | Row and bound |
|---|---|---|
| Flat swarm, 1,024 MiB in 1,024 KiB pieces to 7 peers | 21,644 ms, 331 MiB/s aggregate, seeder share 26% | ContentSwarmStress on the same host |
| Map, 10,000 entries whole | 2,202,321 bytes per reader, 2 round trips, 241 ms | |
| Map, one key of 10,000 | 4,239 bytes to the costliest reader, 2 round trips, 101 ms | PD-Q-PRF-01: 8,192 bytes, 2 round trips, PASS |
| Cold 1 GiB blob (14,414 chunks, height 3, 1,074,425,624 tree bytes) to 6 readers | 12,820 ms, 480 MiB/s aggregate, writer share 92%, 3 round trips | PD-Q-PRF-08: ratio 0.59 against the flat swarm's 21,644 ms, bound 1.25, PASS |
| Ten one-byte edits in place | median 98,902 bytes, max 126,796 bytes, 2 round trips, 5.5 to 10.1 s | PD-Q-PRF-02: median 131,072, max 540,672, PASS |
| Ten 100-byte inserts | median 83,180 bytes, max 122,774 bytes, 2 round trips, 7.3 to 8.7 s | PD-Q-PRF-03: median 262,144, PASS |
| Ten 1 MiB appends | max 1,168,605 bytes, 2 round trips, 7.1 to 8.8 s | PD-Q-PRF-04: 1,327,104 bytes, 2 round trips, PASS |
| Catch-up without the base | 1,763,519,106 bytes received for 1,084,919,060 tree bytes in 4 round trips, 5,010 ms, complete | PD-Q-PRF-06: depth 3 + 1 round trips, PASS (the bytes over the tree size are the concurrent sources' overlap; the row bounds round trips) |
Recorded on 2026-11-09 on the same laptop for PDT 2 phase 4 (srad/agentspaces-pdt2.md, phase 4 record), with other builds sharing the host:
| Measure | Figure | Row and bound |
|---|---|---|
FastCdcBench, 1,024 MiB, best of 3 |
cuts 2,534 MiB/s; cuts plus SHA-256 1,383 MiB/s; blob build 1,170 MiB/s; BouncyCastle BLAKE3 280 MiB/s; outboard pass 543 MiB/s | PD-Q-PRF-07 PASS; P2-B-05 (1,024 MiB/s) FAIL for both BLAKE3 figures |
Ten one-byte edits of a 1 GiB blob, level 2 (-Dmodes=blob-edit -Dpeers=3 -Dmebibytes=1024) |
median 5,801 bytes, max 10,593 bytes per reader, 2 round trips; each frame 67 bytes, the rest the changed b nodes |
P2-Z-03 and P2-Q-PRF-08 (8,192 bytes): 9 of 10 edits under, max FAIL; the same edits moved median 98,902 bytes at level 1 |
A 2 MiB verified window from the middle of a 10 GiB blob (-Dmodes=verified-window -Dpeers=2 -Dmebibytes=10240) |
4,245,473 bytes moved: 2,132,842 of chunks, 11,390 of b nodes, 2,101,241 of bo nodes (the window straddles two 256 MiB outboard leaves); read in 126 ms; outboard built in 22.4 s |
P2-Q-PRF-05 (2 MiB + 64 KiB) FAIL |
Recorded on 2026-11-09 on the same laptop for PDT 2 phase 7 (srad/agentspaces-pdt2.md P2-X-07):
PdtStress on dtls with every mode, -Dmodes=all -Dpeers=8 -Dmebibytes=1024 -Dbinding=dtls -DdeadlineSeconds=1800, run from the shaded jar with -Xmx6g while other builds shared the host.
The whole run took 10 min 7 s. catch-up and verified-window each take a peer, so five readers
fetch the blob.
| Measure | Figure | Row and bound |
|---|---|---|
| Flat swarm, 1,024 MiB in 1,024 KiB pieces to 7 peers | 45,729 ms, 118 MiB/s aggregate, seeder share 24% | ContentSwarmStress on dtls |
| Map, 10,000 entries whole | 2,202,321 bytes per reader, 2 round trips, 263 ms | |
| Map, one key of 10,000 | 4,239 bytes to the costliest reader, 2 round trips, 82 ms | PD-Q-PRF-01: 8,192 bytes, 2 round trips, PASS |
| Cold 1 GiB blob (14,414 chunks, height 3, 1,074,425,624 tree bytes) to 5 readers | 239,803 ms, 21.4 MiB/s aggregate, writer share 100%, 3 round trips, three stalls of about 60 s | PD-Q-PRF-08: ratio 5.24 against the flat swarm's 45,729 ms, bound 1.25, FAIL |
| Ten one-byte edits in place, level 2 | median 5,801 bytes, max 10,593 bytes, 2 round trips, 4.5 to 6.8 s | PD-Q-PRF-02 PASS; P2-Z-03 and P2-Q-PRF-08 (median under 8,192, each under 16,384) PASS |
| Ten 100-byte inserts | median 4,572 bytes, max 8,636 bytes, 2 round trips, 6.1 to 8.0 s | PD-Q-PRF-03: median 262,144, PASS |
| Ten 1 MiB appends | max 1,084,793 bytes, 2 round trips, 5.6 to 9.3 s | PD-Q-PRF-04: 1,327,104 bytes, 2 round trips, PASS |
| Catch-up without the base | 1,711,508,003 bytes received for 1,084,919,060 tree bytes in 5 round trips, 16,240 ms, complete | PD-Q-PRF-06: depth 3 + 1 round trips, FAIL by one (tcp took 4 on 2026-11-08) |
| A 2 MiB verified window from the middle | 2,313,397 bytes moved: 2,237,483 of chunks, 6,378 of b nodes, 69,536 of bo nodes; read in 2,484 ms; outboard built in 721 ms |
P2-Q-PRF-05 (2,162,688 bytes) FAIL: 140,331 bytes of chunks past the window's ends |
The cold fetch stalled three times, about 60 s each, with every reader at the same byte count. A
second run of cold alone on six peers (-Dmodes=cold -Dpeers=6) printed each stalled reader's
trace, which the harness now does after 30 s without progress, as ContentSwarmStress does. It
stalled the same way: 194,774 ms against the flat swarm's 39,641 ms (ratio 4.91), and the traces
show the cause eleven times. Every reader asked the writer, its only source, timed out three times
in a row, and dropped the writer for 60 s: "source ... timed out 3 times; dropped for 60 s". That is the rule of ROBUSTNESS.md
§10.3 row B4, which the policy table keeps. In the first run the writer served every byte, so
nothing moved until the drop lapsed; in the second the readers served a third of the bytes and
still waited at every drop. Without the stalls the transfer took
about 60 s, close to the flat swarm's 46 s. The 64 MiB run on dtls (-Dpeers=8 -Dmebibytes=64)
had no stall: cold 3,750 ms against the flat swarm's 5,586 ms (ratio 0.67), catch-up in 4 round
trips, and the window 2,211,649 bytes, so the window's overhang shows at both sizes.
Recorded on 2026-11-09 and 10 on the same laptop for PDT 2 phase 8 (srad/agentspaces-pdt2.md §10.18, P2-O-04), every value at 64 MiB or less, run from scratch copies of the jars while other builds held the 1-minute load between 6.9 and 17.2:
| Mode | Binding, peers | Figure | Row and bound |
|---|---|---|---|
push, three runs |
tcp, 21 | the writer held 1, 1, and 0 eager edges and pushed 1, 1, and 0 copies; it sent 1, 0, and 4 bundle copies beyond them in answer to GRAFTs, 2.71, 1.67, and 4.98 x the delta; the last follower held the root after 1,703, 1,622, and 2,343 ms; the second run's followers made one fetch | P2-Q-PRF-04 (b) PASS in every run; (a) as the run measures it, 0 in one run of three (the simulation splits the answers, §10.18); P2-P-03 (no fetch) in two of three |
push |
dtls, 8 | 2 eager edges and 2 copies, no answers, 2.06 x the delta, the last follower after 105 ms | P2-Q-PRF-04 (a) and (b) PASS |
reconcile |
tcp, 2 | negentropy 14 rounds, 914,557 bytes, 435 ms; RIBLT 2 rounds, 97,644 bytes, 517 ms | P2-Q-PRF-06 PASS |
reconcile |
dtls, 2 | negentropy 14 rounds, 914,557 bytes; RIBLT 2 rounds, 97,656 bytes | P2-Q-PRF-06 PASS |
store-cold, 64 MiB to 5 readers after the writer left |
tcp, 8 | 803 ms against the flat swarm's 1,604 ms for the same bytes from one seeder to 5 fetchers; the stores served 100%; 2 round trips | P2-Q-PRF-02: ratio 0.50 (bound 1.25) PASS, share (bound 90%) PASS |
| the same | dtls, 8 | 2,111 ms against 4,542 ms | ratio 0.46 PASS, share 100% PASS |
verified-window, 2 MiB of 64 MiB |
tcp, 2 | 2,211,649 bytes moved: 2,140,147 of chunks, 5,246 of b nodes, 66,256 of bo nodes; 1.055 x the window |
P2-Q-PRF-05 as restated (the window + 128 KiB, 2,228,224) PASS |
| the same | dtls, 8 | the same bytes, read in 710 ms | PASS |
rrb-splice, ten insertions in 1,000,000 elements |
tcp, 8 | each insertion 3,559 to 4,880 bytes in 8 to 10 nodes, and every reader received exactly that; the vector of 6,481,568 bytes followed whole in 4.4 s | P2-Q-PRF-07 (8 KiB) PASS |
| the same | dtls, 8 | the same deltas, 800 to 925 ms each | PASS |
text, 1,000 concurrent edits by two replicas |
tcp, 8 | 2 heads; a member received 28,635 bytes for both, 28.6 bytes per edit; the slowest first merging read 77.5 ms; one edit after the merge shipped the 28,140-byte t node whole |
P2-Q-PRF-09 (100 ms, 50 bytes) PASS |
| the same | dtls, 8 | the same bytes; merge 54.6 ms | PASS |
push with the witness, fifteen runs in three sets of five (2026-11-10, ROBUSTNESS.md §10.11) |
tcp, 21 | all answers: 1 in one run, 0 in fourteen; answers to askers that had heard another member: the same 1; answers to askers that had heard the writer alone: 0; no frame unread; 4 to 14 eager edges, 4.10 to 14.36 x the delta; the last follower after 134 to 789 ms | P2-Q-PRF-04 (a) as restated PASS in 14 of 15, (b) PASS in 15 of 15 |
blob-edit, ten one-byte edits of 64 MiB |
tcp, 3 | level 2: median 3,323 bytes, max 5,487; level 1, the same edits: median 76,885, max 123,574; each edit at level 2 moved 0.021 to 0.072 of its level-1 bytes | P2-Z-03 and P2-Q-PRF-08 as restated (median 8 KiB, max 16 KiB, under a tenth of level 1) PASS |
The smoke suite asserts the PD-Q-PRF-08 ratio (at most 1.25) and the
PD-Q-PRF-07 floor (300 MiB/s on cuts plus SHA-256) on every run;
-Dperf.floors=false skips both on a slow host.
The same harnesses run from the jar, with the properties before -cp:
java -Dpeers=4 -Dentries=50 -cp target/benchmarks.jar \
ai.badmonkey.agentspaces.perf.stress.GossipPropagationStressmvn test runs two smoke suites: one JMH
benchmark in process to prove the generated benchmark list works, and each
stress harness at toy scale to prove fleets start and reports carry data
(PdtStress on tcp over an 8 MiB blob with map, cold, blob-edit, blob-insert (since PDT 2
phase 7), blob-append, and catch-up, and on dtls over a 4 MiB blob, a verified window of a
blob just under 64 MiB, FastCdcBench over 16 MiB, push and reconcile on seven peers, and since
PDT 2 phase 8 store-cold over 8 MiB on five peers, rrb-splice over 200,000 elements, text,
and the refusal of a value above 64 MiB). They verify the harnesses rather than the numbers.
Contributions are welcome under the Developer Certificate of Origin; see CONTRIBUTING.md and the Code of Conduct. Report vulnerabilities privately, as SECURITY.md describes.
Copyright 2026 Bad Monkey, Inc.
AgentSpaces Perf is an open source project from Bad Monkey, Inc., licensed
under the Apache License, Version 2.0; see LICENSE.