-
Notifications
You must be signed in to change notification settings - Fork 90
513 lines (477 loc) · 25.7 KB
/
Copy pathperformance.yml
File metadata and controls
513 lines (477 loc) · 25.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
name: Performance
on:
push:
branches: [master, dev, 'release/**']
pull_request:
branches: [master, dev, 'release/**']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
permissions:
contents: read
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_TieredPGO: 0
jobs:
allocations:
# This job was named "Allocation budgets (B2, B6)" and asserted only B2, because
# nothing under src/ constructed an ActivitySource or a Meter — there was no
# telemetry to be zero-cost about, and naming B6 here made an unmeasured budget
# look gated on every pull request.
#
# WP-90 (2026-08-01) built the telemetry, so B6 has a subject. The allocation half
# is now asserted by TelemetryCostTests, in the same filter below: with no listener
# the decorator is not created, and a decorator abandoned by its listener runs a
# 4-step ephemeral flow for 0 B. That test found the defect it was written for —
# StartActivity($"step {i} {id}") evaluates its interpolation before returning null,
# at 68 B per step.
#
# The name still says B2 alone, and deliberately: B6 is "0 ns and 0 B", and only the
# allocation half is asserted. The nanosecond half is not measured rather than
# measured badly, which is this repository's standing position on timing here.
name: Allocation budget (B2, and B6's allocation half)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
global-json-file: global.json
- name: Hard-zero allocation assertions
# Unit tests, not benchmarks, running on every pull request. Allocation
# counts are deterministic; timings are not — see docs/benchmarks §5, where
# WP-3's assumption that ratios were reliable is withdrawn with evidence.
# This is the gate that actually protects budget B2.
run: |
dotnet test tests/FlowX.Core.Tests --configuration Release \
--filter "FullyQualifiedName~AllocationBudget" \
--logger "console;verbosity=detailed"
dotnet test tests/FlowX.Runtime.Tests --configuration Release \
--filter "FullyQualifiedName~EngineAllocation|FullyQualifiedName~Concurrency|FullyQualifiedName~TelemetryCost" \
--logger "console;verbosity=detailed"
benchmarks:
name: Benchmark budgets (B1, B3, B12 isolated)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
global-json-file: global.json
- name: Run benchmarks
run: >
dotnet run --configuration Release --project tests/FlowX.Benchmarks
-- --filter '*'
- name: Gate against the committed baseline
# Blocking: allocation changes and p95 over a documented budget.
# Advisory: absolute and ratio drift — neither is reproducible on a shared
# runner, as docs/benchmarks/README.md section 5 demonstrates with two runs
# of the same commit. Add --strict once the baseline is recorded on
# dedicated hardware (WP-11).
run: python3 scripts/check-benchmark-budgets.py BenchmarkDotNet.Artifacts
- name: Publish the report
if: always()
run: |
if [ -d BenchmarkDotNet.Artifacts/results ]; then
{
echo "## Benchmark results"
echo
cat BenchmarkDotNet.Artifacts/results/*-report-github.md 2>/dev/null || true
} >> "$GITHUB_STEP_SUMMARY"
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: benchmark-results
path: BenchmarkDotNet.Artifacts/
build-overhead:
name: Budget B12 — build overhead
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
global-json-file: global.json
- name: Measure build overhead against identical non-FlowX code
# Builds the reference sample twice per round, producing the same final
# compilation both times and differing only in whether the generator ran.
# The script exits non-zero above the +8 % budget, which is the number
# ADR-0002's revisit clause depends on — see docs/benchmarks/B12.md.
#
# Fewer rounds than the local run: a hosted runner is noisier, and the
# verdict rests on a margin of roughly twenty times the budget rather than
# on precision.
run: |
# pipefail explicitly: the default shell is `bash -e`, which takes the exit
# status of the LAST command in a pipeline. Without this, tee always succeeds
# and a FAIL verdict would be reported as a passing job — a gate that cannot
# fail, which is worse than no gate.
set -o pipefail
./scripts/measure-build-overhead.sh 7 | tee -a "$GITHUB_STEP_SUMMARY"
generator-cost:
name: Generator cost vs the committed baseline
runs-on: ubuntu-latest
# BLOCKING, and since ADR-0014 it is the only gate on compile-time cost there is.
#
# IT ANSWERS TWO QUESTIONS NOW. Relative — "did this change make the generator more
# expensive than the committed figure" — and, since the P1 criterion was re-expressed in
# bytes per flow and per capability on 2026-08-10, absolute: `check-generator-cost.py`
# fails the run when a ceiling in generator-cost-baseline.json is breached. Before that
# the criterion was a wall-clock ratio measured by `scale-overhead` below, which this job
# could only reprint. A criterion this job can evaluate is one it must evaluate; the old
# one was reprinted for four working packages and stopped being watched.
#
# WHY THIS EXISTS. docs/benchmarks/B12-scale.md §5.2 bisects a commit that took
# FlowPlanGenerator from 5.60 to 27.28 ms per flow — 4.9× — and merged unnoticed
# along with three further working packages. The only gate on compile-time cost at
# the time was `scale-overhead` below: absolute, against a +8 % budget the project
# was already failing by a wide margin, and therefore advisory. An absolute gate you
# are already failing says exactly as much about a 4.9× regression as it does about
# a no-op commit, which is nothing.
#
# WHAT IT MEASURES. Bytes allocated by one run of FlowPlanGenerator over a synthetic
# project, compared against docs/benchmarks/generator-cost-baseline.json. Not wall
# clock: measured over twelve independent runs on a 4-core container at load averages
# from 5.8 to 21.1, the gated figure moved by 0.069 % while wall clock over the same
# runs moved by 123 %. That is why this job can be blocking on a hosted runner and
# `scale-overhead` cannot.
#
# It is the split scripts/check-benchmark-budgets.py already makes at run time —
# allocations are exact and gateable on shared hardware, timings are not — applied to
# compile time. The threshold is +2 %, 29× the worst deviation ever observed of the
# statistic it gates, and 1/50 of the +101 % the bisected commit produces.
#
# WHAT IT DOES NOT SAY. That the build is fast enough. It is not: P1's exit criterion
# is a wall-clock question, `scale-overhead` is the instrument for it, and it fails.
# The checker reprints that criterion on every run, including passing ones.
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
global-json-file: global.json
- name: Measure, then gate against the committed baseline
# Exit codes are the gate, and each of the four means something different:
#
# 0 PASS the generator costs what the baseline says.
# 1 FAIL it costs more, or the subject/toolchain moved and the
# committed number no longer describes anything. Blocking.
# 2 INCONCLUSIVE the run could not judge. NOT blocking — but not silent
# either; see below.
# 3 broken a flow failed to analyse, or a build failed. Blocking,
# because that is a defect and not a measurement.
#
# INCONCLUSIVE is handled differently here than in `scale-overhead`, because it
# means something different. There, exit 2 means the runner was too busy to
# separate signal from scheduling noise — the weather, and failing a pull request
# for the weather is what that job's comment refuses to do. Here the measurement
# does not care how busy the runner is, so exit 2 means the same generator gave
# different answers about the same sources: a broken instrument, not a busy one.
# The right response to a broken instrument is to try it again and then say
# loudly that the gate did not run — so this retries once, and on a second
# INCONCLUSIVE raises a warning annotation that a reviewer sees on the pull
# request while leaving the merge unblocked. A gate that cannot judge must not
# pass quietly, and must not fail for something no author did.
run: |
set -o pipefail
# `|| return` on the first line, not a bare sequence: a measurement that exits
# non-zero has not written a usable samples file, and running the checker over a
# stale one from the previous attempt would turn a broken run into a verdict.
measure_and_check() {
./scripts/measure-generator-cost.py --json generator-cost.json \
| tee -a "$GITHUB_STEP_SUMMARY" || return $?
./scripts/check-generator-cost.py generator-cost.json \
| tee -a "$GITHUB_STEP_SUMMARY"
}
set +e
measure_and_check
rc=$?
if [ "$rc" -eq 2 ]; then
echo "::notice::Generator cost measurement was inconclusive. Retrying once."
measure_and_check
rc=$?
fi
set -e
case "$rc" in
0) ;;
2) echo "::warning::The generator-cost gate did not run: the probe gave "\
"inconsistent byte counts for identical sources, twice. This is not a "\
"regression and it is not a pass. Investigate the probe before trusting "\
"the next green run." ;;
*) exit "$rc" ;;
esac
- name: Publish a candidate baseline
# Not applied — offered. The committed baseline is recorded on a container, and
# nothing yet establishes that a hosted runner produces byte-identical counts;
# docs/benchmarks/generator-cost-gate.md is explicit that this is the design's
# one unverified assumption. If a runner ever disagrees by more than the
# threshold, the fix is to re-record the baseline where the gate runs, not to
# widen the threshold until the disagreement fits. This artifact is that
# re-recording, ready to download and commit.
if: always()
run: |
cp docs/benchmarks/generator-cost-baseline.json generator-cost-baseline.candidate.json
if [ -f generator-cost.json ]; then
./scripts/check-generator-cost.py generator-cost.json \
--baseline generator-cost-baseline.candidate.json --record || true
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: generator-cost
path: |
generator-cost.json
generator-cost-baseline.candidate.json
generator-cost-self-test:
name: The generator-cost gate can fail
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: A regression under the threshold must fail the gate
# A gate nobody has seen fail is an assumption, not a gate — the same argument
# `gate-self-test` below makes for the allocation budgets. This one takes the
# committed baseline, fabricates a measurement 2.5 % above it, and asserts the
# checker rejects it. 2.5 % rather than 100 % on purpose: a self-test that only
# proves a doubling is caught proves nothing about where the threshold actually
# sits.
run: |
python3 - <<'PY'
import json
baseline = json.load(open('docs/benchmarks/generator-cost-baseline.json'))
sizes = []
for flows, entry in baseline['sizes'].items():
sizes.append({
'flows': int(flows),
'allocated_bytes': int(entry['allocatedBytes'] * 1.025),
'allocated_bytes_per_flow': 0,
'spread_percent': 0.01,
'elapsed_ms': 0,
'elapsed_spread_percent': 0,
'generated_trees': entry['generatedTrees'],
'generated_chars': entry['generatedChars'],
'diagnostics': entry['diagnostics'],
'sources_sha256': entry['sourcesSha256'],
})
recorded = baseline['recordedOn']
json.dump({
'meta': {
'environment': {
'roslyn_product_version': recorded['roslyn'],
'roslyn_assembly_version': recorded['roslynAssemblyVersion'],
'framework': recorded['framework'],
'server_gc': False,
},
},
'sizes': sizes,
}, open('/tmp/injected.json', 'w'))
PY
if python3 scripts/check-generator-cost.py /tmp/injected.json; then
echo "::error::The gate accepted an injected 2.5 % generator cost regression."
exit 1
fi
echo "Gate correctly rejected the injected regression."
scale-overhead:
name: P1 scale — 200-flow build overhead
runs-on: ubuntu-latest
# ADVISORY, AND NO LONGER P1'S EXIT CRITERION — see docs/benchmarks/B12-scale.md.
#
# It was the criterion, expressed as "a 200-flow solution builds within +8 %", and it
# was advisory because the project missed it by fifty-nine points: blocking would have
# redded every pull request for the whole of P1 over a defect none of them introduced.
#
# ADR-0014 replaced that ratio on 2026-08-10. A ratio's denominator is the user's own
# code, so the same generator measured +0.4 % on the one-flow reference sample and
# +67.1 % at 200 flows — a number that can be re-argued but not passed or failed. The
# criterion is now generator allocation per flow and per capability, `generator-cost`
# above measures it, and `check-generator-cost.py` fails the run on a breached ceiling.
#
# SO THIS JOB IS NOW INFORMATION RATHER THAN A GATE, and stays advisory for that reason
# rather than the old one. What it measures is still worth seeing — wall-clock overhead
# against a non-FlowX build is what a person actually waits for, and no allocation figure
# substitutes for it. What it must not do is fail a pull request against a criterion the
# repository has replaced.
#
# What is NOT advisory: the script's exit code. It exits 1 above +8 %, 2 when the
# machine was too noisy to tell the two apart, pipefail is set below, and the step
# is shown as failed either way. Only the job's effect on the pull request is
# suppressed.
#
# WHAT DISCHARGED IT. `generator-cost` above is blocking, and it now answers both
# questions rather than one. It was only ever *relative* — "did this change make the
# generator more expensive than the committed figure" — which is the gate the regression
# bisected in B12-scale.md §5.2 needed and did not have. Since ADR-0014 it also evaluates
# the absolute criterion, because in bytes per flow and per capability the criterion is
# exactly what its own samples already hold. "Is the build good enough" and "did this
# change make it worse" are now both asked, in one place, by something that fails.
#
# DO NOT MAKE THIS JOB BLOCKING. It would be blocking on a superseded criterion. If
# wall-clock overhead is ever to gate again, it needs a criterion of its own, stated in
# a unit this runner can resolve — twelve identical runs of one tree disagreed on wall
# clock by 139 % (docs/benchmarks/generator-cost-gate.md), which is why the criterion
# that replaced it is not in milliseconds.
#
# WHEN IT IS MADE BLOCKING, exit code 2 must stay non-blocking. INCONCLUSIVE is
# not evidence of a regression — it is the harness reporting that this runner
# could not separate the generator's cost from its own scheduling noise, and
# failing a pull request for that fails it for the weather. Blocking form:
#
# ./scripts/measure-scale-overhead.sh --rounds 5 --sizes 50,200 \
# | tee -a "$GITHUB_STEP_SUMMARY"
# rc=$?
# case $rc in
# 0) ;;
# 2) echo "::warning::scale overhead inconclusive on this runner" ;;
# *) exit $rc ;;
# esac
continue-on-error: true
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
global-json-file: global.json
- name: Measure build overhead on a synthetic 200-flow solution
# Generates a synthetic solution of 50 and then 200 flows and builds each one
# both ways, exactly as the B12 job does for the one-flow reference sample:
# same final compilation, differing only in whether the generator ran.
#
# Two sizes because the ratio is the less interesting half. A generator whose
# cost grows faster than the flow count fails at 2 000 flows whatever it does
# at 200, and only a second point can tell the two apart. The script prints
# the growth exponent together with a confidence interval for it, because a
# fit through two noisy points reads far more convincing than it is.
#
# Five rounds, not the three this job ran before. Each round is a sandwich of
# three builds per size — A B A, alternating which arm takes the outside slots
# — whose outer pair doubles as an A/A control. The statistics that decide the
# verdict need enough rounds to form an interval at all, and below five the
# script says so in its own output rather than quoting one anyway.
run: |
# pipefail explicitly: the default shell is `bash -e`, which takes the exit
# status of the LAST command in a pipeline. Without this, tee always succeeds
# and a FAIL verdict would be reported as a passing step — a gate that cannot
# fail, which is worse than no gate.
set -o pipefail
./scripts/measure-scale-overhead.sh --rounds 5 --sizes 50,200 \
| tee -a "$GITHUB_STEP_SUMMARY"
gate-self-test:
name: The gate can fail
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Injecting a regression must fail the gate
# A gate nobody has seen fail is an assumption, not a gate. This builds a
# synthetic report with a 64 B allocation where the baseline says zero and
# asserts that check-benchmark-budgets.py rejects it.
run: |
python3 - <<'PY'
import json, os
os.makedirs('/tmp/synthetic/results', exist_ok=True)
baseline = json.load(open('docs/benchmarks/baseline.json'))
benchmarks = []
for name, entry in baseline['benchmarks'].items():
type_name, method = name.rsplit('.', 1)
benchmarks.append({
'Type': type_name,
'Method': method,
'Statistics': {
'Mean': entry['absoluteNs'],
'Percentiles': {'P95': entry['absoluteNs'] * 1.02},
},
# The injected defect: a benchmark the baseline says is allocation-free.
# The injected defect targets a benchmark the baseline says is
# allocation-free, so it exercises the blocking check specifically.
'Memory': {'BytesAllocatedPerOperation':
64 if method == 'Query' else entry['allocatedBytes']},
})
by_type = {}
for b in benchmarks:
by_type.setdefault(b['Type'], []).append(b)
for type_name, group in by_type.items():
path = f"/tmp/synthetic/results/{type_name}-report-full.json"
json.dump({'Benchmarks': group}, open(path, 'w'))
PY
if python3 scripts/check-benchmark-budgets.py /tmp/synthetic; then
echo "::error::The gate accepted an injected 64 B allocation regression."
exit 1
fi
echo "Gate correctly rejected the injected regression."
- name: A run that measured nothing must be rejected as absent, not as a crash
# The other failure mode, found on 2026-08-01 by running the benchmarks with a
# duplicate .csproj on disk: BenchmarkDotNet exports a benchmark it could not
# run with a null Statistics rather than omitting it. The gate did reject such
# a run — but by raising TypeError inside a dict comprehension, so the log said
# the script was broken and not that nothing had been measured. This asserts
# both halves: non-zero exit, and the benchmark named as absent.
run: |
python3 - <<'PY'
import json, os
os.makedirs('/tmp/unmeasured/results', exist_ok=True)
baseline = json.load(open('docs/benchmarks/baseline.json'))
by_type = {}
for name, entry in baseline['benchmarks'].items():
type_name, method = name.rsplit('.', 1)
# Every benchmark in one type fails to run; the rest measure cleanly, so
# the gate has to keep reporting the good ones while blocking on these.
failed = type_name == 'EngineBenchmarks'
by_type.setdefault(type_name, []).append({
'Type': type_name,
'Method': method,
'Statistics': None if failed else {
'Mean': entry['absoluteNs'],
'Percentiles': {'P95': entry['absoluteNs'] * 1.02},
},
'Memory': None if failed else {
'BytesAllocatedPerOperation': entry['allocatedBytes'],
},
})
for type_name, group in by_type.items():
path = f"/tmp/unmeasured/results/{type_name}-report-full.json"
json.dump({'Benchmarks': group}, open(path, 'w'))
PY
set +e
output=$(python3 scripts/check-benchmark-budgets.py /tmp/unmeasured 2>&1)
status=$?
set -e
echo "$output"
if [ "$status" -eq 0 ]; then
echo "::error::The gate accepted a run in which no engine benchmark was measured."
exit 1
fi
if echo "$output" | grep -q 'Traceback'; then
echo "::error::The gate rejected the run by crashing rather than by reporting it."
exit 1
fi
if ! echo "$output" | grep -q 'EngineBenchmarks.SagaFailure: in the baseline but absent'; then
echo "::error::The gate did not name the unmeasured benchmark as absent."
exit 1
fi
echo "Gate correctly reported the unmeasured benchmarks as absent."
durability-self-test:
name: The durability gate (B7, B8) can fail
runs-on: ubuntu-latest
# No database, and that is the point of putting it here rather than beside the rig.
# tests/FlowX.Durability.Bench needs a PostgreSQL and minutes; its JUDGEMENT needs
# neither, and the judgement is where a softening would happen unseen. The checker
# downgrades a B7 latency breach to a warning when the offered rate was not sustained
# — correct, because the queue is then the finding — and widening that by one
# condition would switch the gate off silently.
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Nine verdicts, and only two of them green
run: python3 scripts/selftest-durability-verdict.py
- name: The recorded run is the one the self-test claims it is
# The self-test builds every case by mutating docs/benchmarks/B7-B8-durability.json,
# so a recorded run replaced by a fabricated one would take the whole suite with it.
# Two facts are asserted directly: the commit was durable, and the arm the p99 is
# read from actually ran.
run: |
python3 - <<'PY'
import json, sys
document = json.load(open('docs/benchmarks/B7-B8-durability.json'))
machine = document['machine']
if machine.get('synchronousCommit') != 'on':
sys.exit("::error::The recorded run did not commit durably, so it is not a B7 record.")
commit = next(a for a in document['budgets'] if a['budget'] == 'B7')
if commit['response']['operations'] < 1000:
sys.exit("::error::The recorded run measured too few commits to carry a p99.")
print(f"Recorded run: {commit['response']['operations']} commits, "
f"synchronous_commit {machine['synchronousCommit']}.")
PY