Skip to content

Stop publishing runs whose responses were mostly errors#1020

Open
MDA2AV wants to merge 2 commits into
mainfrom
drop-failed-runs
Open

Stop publishing runs whose responses were mostly errors#1020
MDA2AV wants to merge 2 commits into
mainfrom
drop-failed-runs

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Jul 24, 2026

Copy link
Copy Markdown
Owner

rps counts a 500 exactly like a 200, and nothing between the load generator and the board ever looked at the status counts. They're parsed by every tool adapter, written into results/, carried through rebuild_site_data.py, and listed in gen_new_leaderboard_data.py's BASE_FIELDS — then read by nobody. So a framework that collapsed under load was ranked on the rate at which it produced failures.

The 21 rows that weren't measurements

framework profile published errors
php-fpm baseline-h2-1024 294,864 rps 99.2% 5xx
symfony-spawn-franken baseline-h2-256 28,243 rps 72.9% 5xx
symfony-spawn-franken static-h2-1024 52,582 rps 56.5% 5xx
symfony-spawn-franken static-h2-256 63,518 rps 47.3% 5xx
symfony-spawn-franken baseline-h2-1024 97,817 rps 36.5% 5xx
phoenix-bandit upload-{32,256} ~700 rps 33.3% 4xx
rage api-16-1024 9,825 rps 15.2%
rage api-4-256 13,999 rps 8.8%
apache,caddy,pingora pipelined-{512,4096} 0 rps 100% 4xx
traefik pipelined-{512,4096} 0 rps 100% 5xx
fasthttp crud-4096 0 rps 0 responses
effinitive static-h2-1024 0 rps 0 responses
trillium-tuned static-h2-{256,1024} 0 rps 0 responses

php-fpm is the clearest case: 294k rps of 500s placed it mid-field on baseline-h2, ahead of frameworks answering every request correctly.

The four zero rows are a different lie, and the logs say why — fasthttp crashed (a V backtrace in site/static/logs/crud/4096/fasthttp.log, plus 14,506,257 reconnects as every connect was refused); effinitive and trillium-tuned bound their listeners and then returned nothing while burning 1486–6187% CPU and 4.3–5.1 GiB. Publishing rps: 0 states "we measured zero throughput", which ranks them last on a test that never ran. Absent is the honest value, and the composite already reads absent as 0.

Changes

  • rebuild_site_data.py grows failure_reason() and applies it in merge_results() to the merged list, not just the incoming batch — so a bad row already in site/data is dropped on the next rebuild even when its framework isn't re-run. This is load-bearing: site/data is generated, so hand-deleting these rows would have lasted exactly until the next --save restored them from the raw files still on the box. Threshold is 5% non-2xx/3xx, overridable via HTTPARENA_MAX_ERROR_PCT.
  • benchmark.sh warns at save time on the same rule. The row and container log are still written — they're the post-mortem evidence — but a clean-looking --save shouldn't imply a result that will never reach the board.
  • The 21 rows are purged, applied with the generator's own function so the purge can't drift from the rule.
  • Docs: a short "Failed runs are not scored" section in composite-score.md, so a contributor whose row goes missing can find out why.

Ranking impact

Small — these rows were mostly earning little: rage −24.4 (api-4 is 20.6 of it), phoenix-bandit −9.4, symfony-spawn-franken −5.6, php-fpm −1.0, against a ~2600 ceiling. The zero rows score 0 either way. The eight pipelined proxy rows change nothing: pipelined is reference-only, and #1017 removes those frameworks outright.

Deliberately left alone

The 1–5% band stays published — roadrunner, sark, vanilla-epoll, swerver and others sit there, and at 16k connections a handful of failures is plausible rather than diagnostic. 1969 of 2017 rows had exactly zero errors, so the tail is thin either way.

Verification

Rebuilt a synthetic results tree through merge_results(): 9.1% dropped, 4.8% kept, and a pre-existing failed row purged without being re-run. The shell warning was checked against the same thresholds including the env override. 1996 published rows remain, 0 failing the rule.

🤖 Generated with Claude Code

rps counts a 500 exactly like a 200, and nothing between the load generator
and the board ever looked at the status counts. They are parsed by every
tool adapter, written into results/, carried through rebuild_site_data.py
and listed in gen_new_leaderboard_data.py's BASE_FIELDS -- and then read by
nobody. A framework that collapsed under load was ranked on the rate at
which it produced failures.

Twenty-one published rows were not measurements:

  php-fpm                baseline-h2-1024   294,864 rps   99.2% 5xx
  symfony-spawn-franken  baseline-h2-256     28,243 rps   72.9% 5xx
  symfony-spawn-franken  static-h2-1024      52,582 rps   56.5% 5xx
  symfony-spawn-franken  static-h2-256       63,518 rps   47.3% 5xx
  symfony-spawn-franken  baseline-h2-1024    97,817 rps   36.5% 5xx
  phoenix-bandit         upload-{32,256}     ~700 rps     33.3% 4xx
  rage                   api-16-1024          9,825 rps   15.2%
  rage                   api-4-256           13,999 rps    8.8%
  apache/caddy/pingora   pipelined-{512,4096}     0 rps  100.0% 4xx
  traefik                pipelined-{512,4096}     0 rps  100.0% 5xx
  fasthttp               crud-4096                0 rps   0 responses
  effinitive             static-h2-1024           0 rps   0 responses
  trillium-tuned         static-h2-{256,1024}     0 rps   0 responses

php-fpm is the clearest case: 294k rps of 500s placed it mid-field on
baseline-h2, ahead of frameworks answering every request correctly.

The four zero rows are a different lie. Their logs show why: fasthttp
crashed (a V backtrace in site/static/logs/crud/4096/fasthttp.log, and
14,506,257 reconnects as every connect was refused), while effinitive and
trillium-tuned bound their listeners and then returned nothing at all while
burning 1486-6187% CPU and 4.3-5.1 GiB. Publishing rps=0 states "we
measured zero throughput", which ranks them last on a test that never ran.
Absent is the honest value, and the composite already reads absent as 0.

Two changes plus the purge:

rebuild_site_data.py grows failure_reason() and applies it in
merge_results() to the merged list, not just the incoming batch, so a bad
row already in site/data is dropped on the next rebuild even when its
framework isn't re-run. This matters because site/data is generated: hand
-deleting these rows would have lasted exactly until the next --save, which
would have restored them from the raw files still on the box. The threshold
is 5% non-2xx/3xx, overridable with HTTPARENA_MAX_ERROR_PCT.

benchmark.sh warns at save time when a run fails the same rule. The row and
the container log are still written -- they are the post-mortem evidence --
but a clean-looking --save should not imply a result that will never reach
the board.

Composite impact is small, since these rows were mostly earning little:
rage -24.4 (api-4 was 20.6 of it), phoenix-bandit -9.4, symfony-spawn
-franken -5.6, php-fpm -1.0, against a ~2600 ceiling. The zero rows score 0
either way. The eight pipelined proxy rows change nothing at all: pipelined
is reference-only, and #1017 removes those frameworks outright.

The 1-5% band is deliberately left published -- roadrunner, sark,
vanilla-epoll, swerver and others sit there, and at 16k connections a
handful of failures is plausible rather than diagnostic. Verified by
rebuilding a synthetic results tree through merge_results: 9.1% dropped,
4.8% kept, and a pre-existing failed row purged without being re-run.
scripts/__pycache__/rebuild_site_data.cpython-312.pyc rode in with the
previous commit: verifying the new failure_reason() meant importing
rebuild_site_data as a module, which writes bytecode next to the source, and
nothing in .gitignore covered it. Removes the file and ignores __pycache__/
and *.py[cod] so importing any script in scripts/ can't do this again.
MDA2AV added a commit that referenced this pull request Jul 24, 2026
Closes #751. Results lived in 52 shared arrays — site/data/<profile>-<conns>.json,
each holding every framework — so a saved run rewrote files that every other
framework also occupies. They now live one file per framework:

  site/data/baseline-512.json    [ {actix…}, {hyper…}, … ]        ->
  site/data/results/actix.json   { framework, results: { "baseline-512": {…} } }

108 files, 1941 rows, same rows verbatim.

The conflict is real but narrower than "any two saves": git merges
non-adjacent hunks fine, so two frameworks updating existing rows usually
merge. It bites when rows are *inserted* — two contributors each adding a
framework, which is exactly when a rebase is most annoying. Reproduced both
ways against the real data:

  old layout, actix + hyper update rows      merges cleanly
  old layout, two new frameworks added       CONFLICT in baseline-512 and -4096
  new layout, the same two new frameworks    merges cleanly, both files present

Rows are copied verbatim rather than reshaped, which makes the migration
provable: regenerating data.js before and after gives a byte-identical file.
That is the main guarantee here — the board, the composite and every archived
round see exactly what they saw before.

Consumers:

  rebuild_site_data.py   writes each framework's file; a run touches only the
                         frameworks it benchmarked (verified: benchmarking
                         actix + hyper modified 2 of 108 files)
  gen_leaderboard_data   loads site/data/results/*.json once and rebuilds the
                         per-profile view, sorted by framework name so the
                         emitted data.js does not churn
  compare.sh             one read of the framework's own file instead of a
                         scan of every profile file
  archive.sh             regroups into the existing round shape, so
                         rounds/*.json keeps its current format
  benchmark-pr.yml       stages only site/data/results/<slug>.json

The filename is the display_name lowercased with anything outside
[A-Za-z0-9._-] replaced by '-', because two gateway entries are named
"aspnet-minimal + nginx" and "+ caddy".

.gitignore's `results/` rule was unanchored and silently swallowed the new
directory — the first attempt committed the code and none of the data. It is
now `/results/`, which is the raw benchmark output it was always meant to
cover; the root directory is still ignored.

scripts/migrate_results_layout.py is kept rather than deleted: it re-derives
the layout from a flat checkout, which is what a branch predating this needs
in order to rebase.

Note for #1020, which edits rebuild_site_data.py and deletes rows from the
flat files: it will need a rebase, and running the migration script after
merging main is the mechanical way to do it.
MDA2AV added a commit that referenced this pull request Jul 24, 2026
Closes #751. Results lived in 52 shared arrays — site/data/<profile>-<conns>.json,
each holding every framework — so a saved run rewrote files that every other
framework also occupies. They now live one file per framework:

  site/data/baseline-512.json    [ {actix…}, {hyper…}, … ]        ->
  site/data/results/actix.json   { framework, results: { "baseline-512": {…} } }

108 files, 1941 rows, same rows verbatim.

The conflict is real but narrower than "any two saves": git merges
non-adjacent hunks fine, so two frameworks updating existing rows usually
merge. It bites when rows are *inserted* — two contributors each adding a
framework, which is exactly when a rebase is most annoying. Reproduced both
ways against the real data:

  old layout, actix + hyper update rows      merges cleanly
  old layout, two new frameworks added       CONFLICT in baseline-512 and -4096
  new layout, the same two new frameworks    merges cleanly, both files present

Rows are copied verbatim rather than reshaped, which makes the migration
provable: regenerating data.js before and after gives a byte-identical file.
That is the main guarantee here — the board, the composite and every archived
round see exactly what they saw before.

Consumers:

  rebuild_site_data.py   writes each framework's file; a run touches only the
                         frameworks it benchmarked (verified: benchmarking
                         actix + hyper modified 2 of 108 files)
  gen_leaderboard_data   loads site/data/results/*.json once and rebuilds the
                         per-profile view, sorted by framework name so the
                         emitted data.js does not churn
  compare.sh             one read of the framework's own file instead of a
                         scan of every profile file
  archive.sh             regroups into the existing round shape, so
                         rounds/*.json keeps its current format
  benchmark-pr.yml       stages only site/data/results/<slug>.json

The filename is the display_name lowercased with anything outside
[A-Za-z0-9._-] replaced by '-', because two gateway entries are named
"aspnet-minimal + nginx" and "+ caddy".

.gitignore's `results/` rule was unanchored and silently swallowed the new
directory — the first attempt committed the code and none of the data. It is
now `/results/`, which is the raw benchmark output it was always meant to
cover; the root directory is still ignored.

scripts/migrate_results_layout.py is kept rather than deleted: it re-derives
the layout from a flat checkout, which is what a branch predating this needs
in order to rebase.

Note for #1020, which edits rebuild_site_data.py and deletes rows from the
flat files: it will need a rebase, and running the migration script after
merging main is the mechanical way to do it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant