Skip to content

Commit d49fb80

Browse files
authored
Harden durable feed publication and recovery (#2)
2 parents 5b62b61 + 81a7e1c commit d49fb80

34 files changed

Lines changed: 3967 additions & 1569 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ jobs:
3939
- name: Protocol benchmark (smoke)
4040
run: dotnet run --project Bench/Bench.csproj -c Release --no-build -- protocol --iterations 10000 --trials 3
4141

42+
- name: Durability benchmark (smoke)
43+
run: dotnet run --project Bench/Bench.csproj -c Release --no-build -- durability --records 100 --payload 64 --trials 3 --range-queries 10
44+
4245
- name: Feed smoke test
4346
run: ./scripts/smoke.sh
4447

BENCHMARKS.md

Lines changed: 66 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -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
5961
retention, sequencing, and in-place depth application. CRC correctness is checked against the
6062
standard `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

64101
Artifact: `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

209238
The 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

230257
A 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

244271
The publisher encodes each update once and sends a single datagram; the network performs the
245272
replication.
@@ -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

277297
Cost 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
344358
the earlier transport sweeps, microstructure study, regenerated controls, and repeatability runs,
345359
presented 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
358369
dotnet 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+
361376
dotnet 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
375390
python3 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
383396
python3 bench/environment.py
@@ -400,6 +413,6 @@ for i in 1 2 3; do
400413
done
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

Comments
 (0)