@@ -32,6 +32,8 @@ this host; they are not hardware-independent capacity claims.
3232- Allocation uses ` GC.GetAllocatedBytesForCurrentThread ` on single-threaded paths and
3333 ` GC.GetTotalAllocatedBytes ` on concurrent queue paths.
3434- Packet bytes and checksums are consumed so dead-code elimination cannot remove protocol work.
35+ - WAL timings stop at the policy's acknowledgement point; final disposal sync is outside the timed
36+ region. Range and recovery trials are warm-cache filesystem measurements.
3537- Transport load is open-loop. Source timestamps precede dissemination, so backlog increases
3638 measured latency instead of reducing offered load.
3739- CI runs smoke-sized benchmarks for rot detection; it does not gate performance on shared runners.
@@ -59,6 +61,41 @@ end-to-end case includes seal, CRC validation, decoder locking, bounded reorder
5961retention, sequencing, and in-place depth application. CRC correctness is checked against the
6062standard ` 123456789 ` vector. Corruption tests assert that state and sequence do not advance.
6163
64+ ## Durable publication and recovery
65+
66+ Artifact: ` bench/results/durability-v2.json ` . Five trials; 5,000 append acknowledgements per trial,
67+ 50,000-message recovery log, and 100 ten-message range requests. The JSON embeds runtime and host
68+ metadata.
69+
70+ <!-- generated: v2-durability -->
71+ | Append contract | Policy | Payload | Median | Min–max | Rate | Syncs/trial | Allocation |
72+ | ---| ---| ---| ---| ---| ---| ---| ---|
73+ | OS page cache | OsBuffered | 64 B | 976.7 ns | 958.0–2,088.2 ns | 1,023,815/s | 0 | 0 B/op |
74+ | periodic 1 ms | SyncPeriodic | 64 B | 2,318.3 ns | 2,149.5–3,507.9 ns | 431,343/s | 4 | 0 B/op |
75+ | group commit 64 | OsBuffered | 64 B | 27,301.7 ns | 17,837.9–42,412.7 ns | 36,628/s | 79 | 0 B/op |
76+ | fsync each | SyncEachRecord | 64 B | 972,646.2 ns | 898,242.4–1,196,500.7 ns | 1,028/s | 5,000 | 0 B/op |
77+ | seal + packet WAL | OsBuffered | 50 B | 813.7 ns | 755.1–1,676.6 ns | 1,228,894/s | 0 | 0 B/op |
78+
79+ | Messages | Checkpoint | Full replay | Checkpoint + tail | Speed-up |
80+ | ---| ---| ---| ---| ---|
81+ | 50,000 | 47,500 | 24.68 ms (21.00–40.83) | 2.92 ms (2.64–3.84) | 8.45× |
82+
83+ | 10-message range | Queries | Index entries | Median | Min–max | Allocation |
84+ | ---| ---| ---| ---| ---| ---|
85+ | sparse index | 100 | 196 | 73.6 µs | 70.2–81.7 µs | 1,736 B/request |
86+ | segment scan | 100 | 0 | 1,946.8 µs | 927.1–2,530.3 µs | 5,848 B/request |
87+ <!-- /generated -->
88+
89+ ` OS page cache ` includes framing, CRC-32C, and one unbuffered managed write into the kernel cache;
90+ it is not power-loss durability. ` seal + packet WAL ` also seals and validates the 50-byte feed
91+ packet. The 1 ms periodic case stresses group sync; the configurable server default is 200 ms.
92+ ` fsync each ` measures this virtual disk, not a portable storage latency.
93+
94+ Recovery trials alternate full-first and checkpoint-first order. The checkpoint is at sequence
95+ 47,500; complete segments before it are skipped. The sparse range index stores one entry per 256
96+ records and incrementally follows the live tail. Both range cases copy the same ten payloads; the
97+ table isolates lookup strategy.
98+
6299## Matching engine
63100
64101Artifact: ` bench/results/matching-v2.json ` . Each value is the median over 200,000 state-preserving
@@ -168,15 +205,8 @@ arbitration with zero sequence gaps.
168205
169206## Transport scaling (pre-v2 generation)
170207
171- The sections above measure the v2 protocol and its recovery path. They do not measure how the
172- dissemination architecture scales with the * audience* , which is a separate question and the one
173- this project was originally built to answer. That record is retained here.
174-
175- ** Read this section under the artifact boundary below.** These runs predate protocol v2 and were
176- recorded on a different host (4 vCPU; see the table below) from the v2 measurements (8 logical
177- processors). Nothing here is comparable with a v2 number, and none of it is combined into a single
178- claim with one. What it is comparable with is itself: every row below was measured on one host in
179- one session, which is what makes the unicast-versus-multicast comparison meaningful.
208+ These runs predate protocol v2 and use a separate 4-vCPU host. Compare rows within this section;
209+ do not compare them with v2 results.
180210
181211<!-- generated: environment -->
182212| | |
@@ -190,11 +220,10 @@ one session, which is what makes the unicast-versus-multicast comparison meaning
190220| Topology | server and load generator as separate processes on the same host |
191221<!-- /generated -->
192222
193- ### The O(N) wall
223+ ### Unicast fan-out
194224
195- TCP fan-out performs one write per subscriber per update, so a subscriber's latency is essentially
196- its position in that span. Two runs matched on messages per second, differing only in how many
197- subscribers the work is spread across:
225+ TCP fan-out performs one write per subscriber per update. These runs have similar delivered rates
226+ but different subscriber counts:
198227
199228<!-- generated: equal-work -->
200229| Subscribers | Feed rate | Fan-out | Mean latency |
@@ -203,8 +232,8 @@ subscribers the work is spread across:
203232| 1,000 | 10 upd/s | 10,100 msg/s | ** 18.63 ms** |
204233<!-- /generated -->
205234
206- Identical work per second; ten times the audience costs an order of magnitude more latency, and
207- neither run was CPU-starved. No amount of tuning removes an O(N) term .
235+ At approximately 10,000 messages/s, increasing the audience from 100 to 1,000 subscribers raised
236+ mean latency from 1.61 ms to 18.63 ms .
208237
209238The full sweep, feed rate held at 100 updates/s aggregate:
210239
@@ -222,10 +251,8 @@ The full sweep, feed rate held at 100 updates/s aggregate:
222251| 900 | 86,621 | ** 46.15** | 23.75 | 216.7 | 254.2 | 322.8 | 99.2% | 97% | 229.4% | 378.8% | yes |
223252<!-- /generated -->
224253
225- Every point sustained, so this sweep does not contain unicast's breaking point — but the wall is
226- visible in the server's own CPU, which rises from 53% to 229% of 400% while host CPU reaches 379%.
227- At 900 subscribers the latency distribution has begun to come apart: p99 of 216.7 ms against a mean
228- of 46.2 ms.
254+ Every point sustained; 900 subscribers is the top of the sweep, not a measured limit. Server CPU
255+ rose from 52.8% to 229.4%; p99 reached 216.7 ms at 900 subscribers.
229256
230257A second sweep holds the message rate constant on a lighter feed:
231258
@@ -239,7 +266,7 @@ A second sweep holds the message rate constant on a lighter feed:
239266| 5,000 | 50,334 | ** 96.83** | 87.95 | 321.1 | 387.1 | 422.2 | 100.7% | 100% | 157.4% | 304.2% | yes |
240267<!-- /generated -->
241268
242- ### Multicast removes the term
269+ ### Multicast fan-out
243270
244271The publisher encodes each update once and sends a single datagram; the network performs the
245272replication.
@@ -257,21 +284,14 @@ replication.
257284| 8,000 | 594,357 | ** 744.52** | 350.45 | 10365.0 | 24032.8 | 91.2% | 81.5 | 594 | 0 | 91.6% | 373.9% | ** NO** |
258285<!-- /generated -->
259286
260- ** The ` Server pkt/s ` column is the result.** It does not move — 98.6 to 100.3 packets per second
261- from 100 subscribers to 6,000. The publisher transmits at the update rate and holds no subscriber
262- table at all.
263-
264- 8,000 is where it breaks, and it breaks the way an unreliable transport should: 594 sequence gaps,
265- detected and reported by the affected subscribers rather than silently corrupting anybody's book.
266- That failure is left in the table rather than trimmed off the end of it.
287+ Server packet rate stayed between 98.6 and 100.3/s through 6,000 subscribers. The 8,000-subscriber
288+ run was not sustained and recorded 594 sequence gaps.
267289
268290<!-- generated: head-to-head -->
269291| Subscribers | Unicast mean | Multicast mean | Improvement |
270292| ---| ---| ---| ---|
271293| 100 | 1.61 ms | ** 0.31 ms** | ** 5.3×** |
272294| 500 | 8.54 ms | ** 0.85 ms** | ** 10.1×** |
273-
274- Multicast was also measured at 250, 1,000, 2,000, 4,000, 6,000, 8,000 subscribers, where unicast was not run; those points are in the multicast sweep in BENCHMARKS.md.
275295<!-- /generated -->
276296
277297Cost per delivered message, at each transport's highest sustained point:
@@ -281,19 +301,14 @@ Cost per delivered message, at each transport's highest sustained point:
281301| ---| ---| ---| ---| ---|
282302| Unicast gRPC | 900 | 86,621 | 229.4% | ** 26.48 µs** |
283303| Multicast | 6,000 | 594,067 | 73.2% | ** 1.23 µs** |
284-
285- Multicast delivers each message for ** 21× less server CPU** , to ** 6.7× the subscribers** at ** 6.9× the throughput** .
286304<!-- /generated -->
287305
288- Note the asymmetry in that table: multicast's 6,000 is a real ceiling because 8,000 was measured
289- and failed, whereas unicast's 900 is simply the top of the sweep — no unicast point failed, so its
290- limit was never found and lies somewhere above 900.
306+ The next multicast point failed; no unicast failure point was measured.
291307
292308### Batching
293309
294- Batching normally trades latency for throughput. On a fan-out feed it does the opposite, because
295- per-packet cost is paid once per * subscriber* : 1,000 subscribers, 1,000 updates/s aggregate,
296- varying only how many messages the publisher packs into a datagram.
310+ The following runs use 1,000 subscribers and 1,000 aggregate updates/s while varying packet batch
311+ size.
297312
298313<!-- generated: batching -->
299314| Max batch | Fan-out (msg/s) | Mean (ms) | p99 | Server pkt/s | Server CPU | Host CPU |
@@ -304,10 +319,10 @@ varying only how many messages the publisher packs into a datagram.
304319| 64 | 971,792 | ** 2.09** | 4.7 | 201.0 | 45.0% | 170.9% |
305320<!-- /generated -->
306321
307- Packet rate falls 4.4×, mean latency 1.6×, p99 3.1×, host CPU 2.2× — while delivered throughput
308- * rises * 12%.
322+ Batch 64 versus batch 1 reduced packet rate 4.4×, mean latency 1.6×, p99 3.1×, and host CPU 2.2×;
323+ delivered throughput increased 12%.
309324
310- ### Repeatability, and what a single sweep point is worth
325+ ### Repeatability
311326
312327<!-- generated: repeatability -->
313328| Point | Runs | Median | Min | Max | Spread |
@@ -316,8 +331,7 @@ Packet rate falls 4.4×, mean latency 1.6×, p99 3.1×, host CPU 2.2× — while
316331| 500 subscribers, 100 upd/s | 3 | 8.64 ms | 7.73 | 8.66 | 1.12× |
317332<!-- /generated -->
318333
319- A 1.4× range across three runs of an identical configuration at 4,000 subscribers. Every point in
320- the sweep tables above is a single run, so read them with that spread in mind.
334+ The 4,000-subscriber configuration varied 1.4× across three runs. Sweep rows are single runs.
321335
322336## Market realism
323337
@@ -344,11 +358,8 @@ Files carrying `v2` or `protocolv2` are the current protocol/recovery record. Un
344358the earlier transport sweeps, microstructure study, regenerated controls, and repeatability runs,
345359presented under [ Transport scaling] ( #transport-scaling-pre-v2-generation ) .
346360
347- The two generations were measured on different hosts and ** are not combined into one claim** .
348- ` docgen.py ` enforces this: it partitions results by generation and refuses to render if either
349- generation contains results from more than one kernel instance. What that check cannot enforce is
350- prose, so the rule for a reader is simple — a v2 number and a pre-v2 number never belong in the same
351- sentence, and no comparison in this document crosses that line.
361+ The generations use different hosts and are not compared. ` docgen.py ` rejects mixed kernel
362+ instances within either generation.
352363
353364## Reproduce
354365
@@ -358,6 +369,10 @@ dotnet build MarketDataSimulator.sln -c Release
358369dotnet run --project Bench -c Release --no-build -- \
359370 protocol --iterations 1000000 --trials 7 --out bench/results/protocol-v2.json
360371
372+ dotnet run --project Bench -c Release --no-build -- \
373+ durability --records 5000 --payload 64 --trials 5 --range-queries 100 \
374+ --out bench/results/durability-v2.json
375+
361376dotnet run --project Bench -c Release --no-build -- \
362377 queue --items 1000000 --capacity 8192 --trials 7 --out bench/results/queue-v2.json
363378
@@ -375,9 +390,7 @@ python3 bench/docgen.py --write
375390python3 bench/docgen.py --check
376391```
377392
378- The pre-v2 transport generation was produced by the sweeps below. Re-running them re-measures that
379- whole generation on the current host, which is the only correct way to refresh it — the guard will
380- reject a partial refresh that mixes hosts within one generation.
393+ Refresh the complete pre-v2 generation together; mixed-host partial refreshes are rejected.
381394
382395``` bash
383396python3 bench/environment.py
@@ -400,6 +413,6 @@ for i in 1 2 3; do
400413done
401414```
402415
403- A serious capacity study should reserve hosts, pin processes and interrupts, record frequency and
404- thermal state , separate publishers and consumers, inject controlled loss/reordering, capture
405- hardware counters, and repeat across x64 and Arm64.
416+ A capacity study requires reserved hosts, process and interrupt affinity, frequency and thermal
417+ telemetry , separate publishers and consumers, controlled loss/reordering, hardware counters, and
418+ x64/ Arm64 runs .
0 commit comments