Humainary Substrates 3.15.3 · Showcase benchmark

A million circuits, 8.5 billion deliveries a second

Google Compute Engine c4d-standard-32 · 1 November 2026

One Java process held one million circuits, each with its own virtual thread, and carried 8.4 to 8.7 billion deliveries a second through them. It held that rate for 20 minutes without slowing.

8.4–8.7 G

Deliveries a second

20 min

Held without slowing

3.7–3.8 ns

Processor time per delivery

< 1%

Variation, round to round

≈ 2 KB

Heap per circuit

The four runs

25.5 trillion deliveries in 50 minutes of measurement.

FigureRun 1Run 2Run 3Run 4
Length5 minutes5 minutes20 minutes20 minutes
Deliveries per second8.69 billion8.51 billion8.53 billion8.42 billion
Deliveries2.62 trillion2.56 trillion10.24 trillion10.11 trillion
Processor time per delivery3.66 ns3.75 ns3.71 ns3.79 ns
Handoffs between circuits per second271,600266,100266,600263,200
Round-to-round variation (cv)0.6%0.6%0.7%0.6%
Processors busy99%100%99%100%
Garbage collections2245
Heap held per circuit2,079 bytes2,075 bytes2,049 bytes2,039 bytes
Time to build 1,000,000 circuits0.50 s0.49 s0.50 s0.71 s

The four runs used three VMs of the same type. Runs 1 and 2 ran one after the other on one VM, and run 3 on a second. Run 4 ran on a third, in a fresh JVM, straight after an observed 20-minute run that this report leaves out.

What Substrates is

A Java runtime for event-driven data flow.

Its central object is the circuit: a processing engine with one worker thread, which runs every event sent to it in order. Code emits values into a circuit through pipes, and the circuit hands each value to a receptor, the callback that processes it.

Each circuit’s worker is a Java virtual thread. A circuit with nothing to do parks its thread and costs no processor time. That makes it practical to hold very large numbers of circuits in one process, for example one per simulated entity in a model.

How much work can a single JVM push through a million of them?

What the test does

A relay race run by the circuits themselves.

Main thread
seeds and verifies
Circuit 1
holds the token
Circuit 2
31,247 more
Circuit 31,250
seed
hand off
32,000 deliveries to itself
× 32 chains, one per processor
the last circuit signals the main thread
One chain of 31,250 circuits. Every circuit in it bursts and hands off in turn; 32 chains run at once, so 32 tokens are moving at any moment and the rest of the million are parked.
  1. Build. The program creates 1,000,000 circuits and splits them into 32 chains of 31,250, one chain per processor.
  2. Seed. Each round, the main thread drops one token into the first circuit of each chain. That gives 32 tokens moving at once.
  3. Burst. A circuit holding the token delivers it to itself 32,000 times in a row. Each of these is a full delivery through the circuit’s queue to its receptor.
  4. Hand off. After its burst, the circuit passes the token to the next circuit in its chain. This crosses threads: the next circuit’s virtual thread is woken and takes over.
  5. Finish. The last circuit in each chain signals the main thread. When all 32 have signalled, the round is over.
  6. Verify. The main thread checks that every circuit made exactly the number of deliveries it should have. A wrong count stops the run with an error. A lost token would instead stall its chain, and the run would wait for it indefinitely.
Per round
Circuits visited1,000,000
Deliveries32,000,000,000
Handoffs between circuits999,968
Round length hereabout 3.7 s

The program runs two warmup rounds, which are reported but not counted. The first starts every circuit’s worker and lets the JIT compiler optimise the code. The second absorbs the extra allocation that follows the first round’s garbage collections. Measured rounds then repeat until the run’s length has passed: five minutes by default, twenty for runs 3 and 4.

The workload is driven entirely by Substrates through its public API. The main thread does no delivery work: it seeds, waits, verifies and reports. The receptor does nothing but count and pass the token on, so the figures measure the runtime’s own cost of moving events.

Each run is a fresh JVM. Run 2 followed run 1 on the same machine; they did not overlap. Run 4 followed an observed 20-minute run on its machine.

Environment

Three VMs of one type, each built for the test and deleted after.

Machine

CloudGoogle Compute Engine, Spot VM
Machine typec4d-standard-32
Zoneus-west4-a
ProcessorAMD EPYC 9B45
Processors32 vCPUs: 16 physical cores, 2 hardware threads each
Sockets, NUMA1 socket, 1 NUMA node
Memory122 GiB
HypervisorKVM
Price at run timeabout $0.27 an hour (Spot)

Software

Operating systemUbuntu 24.04.5 LTS, kernel 7.0.0-1014-gcp
JavaOpenJDK 27.0.1.0.3 (general availability build, checksum-pinned)
Garbage collectorG1
Heap8,325 MiB, fixed and pre-touched (512 MiB + 8 KiB per circuit)
JVM flags-server -Xms8325m -Xmx8325m -XX:+AlwaysPreTouch -XX:+UseG1GC
Substrates API3.15.3
ProviderHumainary alpha provider 3.15.3 (humainary-substrates-spi-alpha)
BenchmarkPerfkit 3.15.3, io.humainary.perfkit.showcase.Showcase
Sourcecommit 4584fc91; run 4’s checkout also had uncommitted edits to perfkit/jmh-showcase.sh and perfkit/README.md

Each VM was created for this test, built the source on the machine, ran its runs, and was deleted. No other workload ran on it. Package updates were disabled for the duration. Run 4’s VM first ran an observed 20-minute run, which this report leaves out. Run 4 began 21 minutes after that VM booted; runs 1 and 3 began a minute after theirs.

The benchmark jar built on each of the three VMs was byte-identical (SHA-256 85f172efb149b9f4381e1063d566ab81cb1413456ceea6ba3faaf0a70ff31ab3), and every run was launched with the JVM flags above. Run 4’s uncommitted edits added two settings to the runner’s --observe option, and run 4 used neither. They changed neither the code it measured nor how it was started.

Results · Steady for twenty minutes

Throughput held flat across every run.

The last ten rounds were no slower than the first ten in any of them. The few slow rounds were isolated single rounds, each within 5% of its run’s median.

Run 1 · 82 roundsRun 2 · 80 roundsDeliveries per second, billions · axis starts at 8.0
RunRoundsSlowest roundMedian roundFastest roundFirst 10 roundsLast 10 rounds
1828.39 G/s8.71 G/s8.74 G/s8.67 G/s8.72 G/s
2808.21 G/s8.53 G/s8.54 G/s8.50 G/s8.54 G/s
33208.31 G/s8.51 G/s8.64 G/s8.52 G/s8.55 G/s
43168.05 G/s8.43 G/s8.50 G/s8.39 G/s8.46 G/s

The 20-minute runs, in blocks of 60 rounds

About 3¾ minutes a block, in deliveries per second.

Rounds1 – 6061 – 120121 – 180181 – 240241 – 300
Run 38.51 G8.49 G8.59 G8.56 G8.50 G
Run 48.40 G8.41 G8.44 G8.44 G8.43 G

Processor time per delivery stayed at 3.70 ns in every block of run 3 and 3.80 ns in every block of run 4.

Results · Run to run

The four runs span 3%, from 8.42 to 8.69 billion a second.

That is larger than the variation inside any run, so a single run’s figure should be read as within a few percent of what another fresh JVM, or another VM of the same type, would give. Runs 1 and 2, on the same VM, differ by 2%.

Results · Memory

The workload itself allocates nothing.

In about nine rounds in ten, a circuit visit allocated 24 bytes on average: the one small task object the JDK creates each time it wakes a virtual thread.

Round without a collectionRound with a collectionBytes allocated per circuit visit
The floor is 24 bytes a visit. Rounds 14 and 31 allocated about 430 bytes a visit in all four runs with no collection counted; the cause is not established.

Each run had two to five garbage collections. A round during or just after a collection allocates more: about 300 to 440 bytes a visit. After a collection the JDK cannot reuse a parked virtual thread’s saved stack, so each circuit allocates a new one the next time it goes idle.

This is the JDK’s behaviour, and it is why the heap is sized at 8 KiB per circuit: room enough that these collections stay rare.

After the rounds, each circuit held 2,039 to 2,079 bytes of heap. A million circuits therefore fit in about 2 GB.

About half of that is particular to the first population a JVM builds, as the next section shows.

Results · Building and dropping the population, ten times

Nothing leaks.

A separate test built a million circuits, ran three busy rounds through them, closed every circuit and dropped every reference, then did it again, ten times in one JVM. After each close it forced a full collection and read the heap.

CycleBuild timeDeliveries/sHeap while runningHeap left after closing
10.48 s2.61 G2.08 GB31.0 MB
20.32 s3.19 G1.13 GB27.0 MB
3 – 100.17 – 0.34 s3.10 – 3.23 G1.04 GB27.0 MB, every cycle

Nothing leaks

From the second cycle to the tenth, the heap left after closing never grew: it held at 27,016,432 bytes for four cycles, then fell by 280 bytes. Once the circuits closed, no virtual thread, saved stack or provider object of theirs remained.

What stays is the JDK’s, and it does not grow

The 25 MB left above the starting heap is sized by the largest population the JVM has held: the table the JDK keeps of every thread (8.4 MB), the virtual-thread scheduler’s queues (4.2 MB) and a filler object of the garbage collector (4.2 MB).

A steady population costs about 1 KB a circuit

The first population holds twice that, and the difference is almost all saved stacks. With the JDK’s default settings, the first population kept 1.78 saved stacks per circuit, averaging 715 bytes. With thread tracking off (-Djdk.trackAllThreads=false), it kept two per circuit, averaging 659 bytes, and a population built later kept one of 305 bytes. Every showcase run is a fresh JVM, which is why it reports about 2 KB.

This test ran on an Apple M4 laptop (10 cores, 4 performance and 6 efficiency, 16 GB, macOS 27.2, Java 27+35) with 10 tokens and the showcase’s heap and collector settings. Its throughput is the laptop’s and is not comparable with the Compute Engine figures. The probe’s program, console log, full heap histograms and run notes are in the memory-probe/ folder beside this report.

Compared with earlier runs

Context, not a like-for-like comparison.

All on Compute Engine with the same processor, 1,000,000 circuits and a token per processor. The earlier runs used earlier code and, before 31 October’s change, a smaller heap.

Before 3.15.2, 4 KiB heap per circuit3.15.3, 8 KiBDeliveries per second, billions · bar to the lowest run, mark across the range

c4d-standard-1631 Oct · before 3.15.2 · 5 min

4.00 G

c4d-standard-32, 4 runs31 Oct · before 3.15.2 · 5 min

8.23 – 8.41 G

c4d-standard-32, 2 runs1 Nov · 3.15.3 · 5 min

8.51 – 8.69 G

c4d-standard-32, 2 runs1 Nov · 3.15.3 · 20 min

8.42 – 8.53 G

DateMachineCodeHeap per circuitMinutesDeliveries/scvCollections
31 Oct 2026c4d-standard-16before 3.15.2 (6593d51f)4 KiB54.00 G2.7%21
31 Oct 2026c4d-standard-32, 4 runsbefore 3.15.24 KiB58.23 – 8.41 G1.5 – 2.6%7 – 47
1 Nov 2026c4d-standard-32, 2 runs3.15.3 (4584fc91)8 KiB58.51 – 8.69 G0.6%2
1 Nov 2026c4d-standard-32, 2 runs3.15.3 (4584fc91)8 KiB208.42 – 8.53 G0.6 – 0.7%4 – 5

Two of the four earlier c4d-standard-32 runs were observed: they recorded collections and host samples. Every 3.15.3 figure in this report comes from a run that was not. The time to build a million circuits varied from 0.49 s to 0.80 s across these VMs, so a single build time is not a figure to compare.

How to read the figures

What each column measures, and what a healthy run shows.

ColumnMeaningA healthy run shows
Deliveries per secondValues passed through a circuit’s queue to its receptor, across all circuitsFlat across rounds
Handoffs per secondTokens passed from one circuit to another, each waking a parked virtual threadFlat across rounds
BusyShare of all processors’ time the process usedClose to 100%
ns cpu per deliveryProcessor time summed over all processors, divided by deliveriesLow and flat
Bytes per visitHeap allocated per circuit visit; a round visits every circuit once24 bytes between collections
CollectionsGarbage collections during the roundRare
cvStandard deviation of the measured rounds’ rates, divided by their meanUnder about 3%
Heap held per circuitHeap in use after the rounds, after a full collection, divided by the circuitsAbout 2 KB in a fresh JVM

Two figures that look alike measure different things

ns cpu per delivery is processor time

With 32 processors busy, the machine completes a delivery about every 0.12 ns of wall time, while each one costs 3.7 ns of some processor’s time.

Deliveries per circuit per second is about 8,600

Most circuits are parked at any moment: only 32 hold a token. The figure shows how much work a large idle population can absorb when it is woken, not how busy each circuit is.

What this does not show

Four limits on what these figures can claim.

One process

All circuits are in one JVM on one machine. Substrates does not yet stream between processes, and this test does not measure distribution.

A runtime cost, not an application

The receptor does no work of its own. A real application adds its own processing to each delivery.

Earlier releases declined over five minutes

Runs on code from before 3.15.2, with a heap of 4 KiB per circuit, fell 3 to 5%. The 3.15.3 runs, with 8 KiB, did not. These runs cannot say whether the code or the heap made the difference.

One machine type

The figures are for a 32-vCPU AMD EPYC Spot VM. Other processors and other clouds will differ.

Reproducing the run

Perfkit builds nothing it measures.

The showcase is part of Perfkit, Humainary’s public benchmark suite (humainary-io/perfkit-java). It runs against any Substrates provider named by its Maven coordinates. These results used the Humainary alpha provider.

With JDK 27 active, and the Substrates and Serventis APIs and the provider in the local Maven repository, build the benchmark jar naming its provider, then run the showcase. From a clone of perfkit-java:

SPI_GROUP=io.humainary.substrates \
SPI_ARTIFACT=humainary-substrates-spi-alpha \
SPI_VERSION=3.15.3 \
  ./jmh.sh build                 # the benchmark jar, naming its provider
./jmh.sh showcase                # a million circuits, five minutes, a token per processor
./jmh.sh showcase --repeat 2     # two runs, one after the other
./jmh.sh showcase --minutes 20   # a twenty-minute run

Every option, column and default is described in the Perfkit README. Results go to results/<timestamp>-showcase/, with each run’s console log, a summary line, and a record of the machine, the JVM and the exact source.

These runs’ records are in perfkit/results/ of the Humainary workspace: runs 1 and 2 in 20261101T072128Z-c4d-standard-32-showcase/, run 3 in 20261101T090646Z-c4d-standard-32-showcase/ and run 4 in 20261101T110616Z-c4d-standard-32-showcase/.

Appendix · The data

Every measured round of runs 1 and 2.

Deliveries per second are in billions. Run 2 had 80 rounds and run 1 had 82, because each run counts rounds until five minutes have passed. Coral marks a round with a garbage collection. The rounds of runs 3 and 4 are in their console logs.

RoundRun 1
seconds
Run 1
deliveries/s
Run 1
bytes/visit
Run 1
collections
Run 2
seconds
Run 2
deliveries/s
Run 2
bytes/visit
Run 2
collections
13.6908.679403.7718.49410
23.6918.672403.7528.53240
33.6758.712403.7488.54240
43.6728.712403.7498.53240
53.6788.702403.7478.54240
63.6778.702403.7468.54240
73.6758.712403.7478.54240
83.6738.712403.8978.21240
93.8148.392403.7508.53240
103.6798.702403.7648.50240
113.6758.712403.7498.54240
123.6898.672403.7528.53240
133.6838.692503.7598.51250
143.7218.6044003.8338.354400
153.6818.692403.7548.52240
163.6848.692403.7528.53240
173.7458.552403.7498.54240
183.6848.692403.7548.52240
193.6828.692403.7538.53240
203.6868.682403.7958.433441
213.6788.702403.7528.53380
223.6808.702403.7538.53240
233.7308.5833813.7578.52240
243.6818.694303.7518.53240
253.6798.702403.7518.53240
263.6758.712403.7558.52240
273.6748.712403.7638.50240
283.6768.702403.7588.52240
293.6878.682403.7558.52240
303.6858.682403.7568.52240
313.7138.6243203.7838.464300
323.6798.702403.7518.53240
333.6748.712403.7578.52240
343.6738.712403.7568.52240
353.6718.722403.7568.52240
363.6718.722403.7538.53240
373.6728.722403.8388.34240
383.6728.722403.7598.51240
393.6728.722403.7518.53240
403.6778.702403.7508.53240
413.6698.722403.7488.54240
423.6708.722403.7488.54240
433.7628.512403.7508.53240
443.6748.712403.7488.54240
453.6738.712403.7468.54240
463.6808.692403.7578.52240
473.6748.712403.7588.51240
483.6998.654803.7778.47480
493.6678.732403.7568.52240
503.6708.722403.7498.53240
513.6708.722403.7978.43240
523.6708.722403.7528.53240
533.6698.722403.7488.54240
543.6688.722403.7558.52240
553.6718.722403.7528.53240
563.6678.732403.7588.52240
573.6938.662403.7568.52240
583.6678.732403.7548.52240
593.7288.582403.7568.52240
603.6648.732403.7538.53240
613.6658.732403.7498.54240
623.6698.722403.7468.54240
633.6648.732403.7458.54240
643.6738.712403.7508.53240
653.6918.674803.8158.393641
663.6678.732403.7518.53800
673.6668.732403.7518.53240
683.6668.732403.7518.53240
693.6658.732403.7528.53240
703.6678.732403.7468.54240
713.6988.658013.7508.53240
723.6928.6729203.7488.54240
733.6728.722403.7478.54240
743.6688.722403.7468.54240
753.6668.732403.7468.54240
763.6658.732403.7488.54240
773.6638.742403.7458.54240
783.6758.712403.7488.54240
793.6678.732403.7508.53240
803.6658.732403.7518.53240
813.6658.73240————
823.6988.654220————

The ten rebuild cycles on the laptop

Heap figures are in bytes. Heap while running is measured above the starting heap of 1,552,664 bytes; heap after closing is the whole heap in use.

CycleBuildDeliveries/sHeap while running
bytes
Heap after closing
bytes
Close
10.48 s2.61 G2,079,675,35230,999,5200.50 s
20.32 s3.19 G1,129,792,93627,016,4320.25 s
30.17 s3.22 G1,043,115,33627,016,4320.23 s
40.34 s3.23 G1,044,832,31227,016,4320.23 s
50.32 s3.14 G1,043,628,96027,016,4320.23 s
60.29 s3.18 G1,044,101,97627,016,4160.23 s
70.19 s3.16 G1,043,112,40827,016,1520.23 s
80.17 s3.13 G1,043,260,98427,016,1520.23 s
90.34 s3.21 G1,043,522,60027,016,1520.23 s
100.17 s3.10 G1,043,051,36027,016,1520.23 s

The warmup rounds, not counted above

RunRoundSecondsDeliveries/sBytes per visitCollections
1Warmup 14.4197.24 G9511
1Warmup 23.7528.53 G3591
2Warmup 14.2847.47 G9621
2Warmup 23.8048.41 G3591