Skip to content

the nightly benchmark stand: one box, every [benchmark] in the tree, plotted over commits - #3976

Merged
borisbat merged 4 commits into
masterfrom
aleksisch/benchmarks-stand
Sep 16, 2026
Merged

borisbat merged 4 commits into
masterfrom
aleksisch/benchmarks-stand

Conversation

@aleksisch

@aleksisch aleksisch commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Why. Nothing measures how daslang's performance moves over commits. The tree holds 69 [benchmark] files and no rail that runs them on one machine every night.

What changes.

  • A new tool runs every benchmark under benchmarks/ in the interpreter, JIT and AOT lanes, one file per process under a wall-clock and memory cap.
  • A second verb turns the records into a static viewer and a markdown summary, and returns the night's verdict as its exit code.
  • A regression needs a move past both 10% and 3x the baseline's own noise.
  • A box-side driver builds the ref and publishes at /bench/; a nightly workflow drives it over ssh and posts the summary.
  • The AOT lane measures native code: the benchmark bodies join DAS_AOT_SUITES, so their stubs land in test_aot, and the night builds it.
  • A folder gate enforces the checklist's mechanical half.

Observable behavior.

  • no performance history -> a chart per benchmark arm over commits in three tiers, plus a per-group index
  • a broken benchmark goes unnoticed -> the nightly reds and names the file, the lane and the reason
  • a night whose build fails leaves nothing -> the summary leads with the build log tail

Where to look. bench_runner.das polices the child, bench_history.das decides every verdict, nightly.sh is the privileged surface.

Validation, claims, ledger

Validation

  • The three tiers were run against this tip on core/math/exp.das: interp 3.32 / 4.19 / 3.33 ns/op, jit 1.75 / 1.96 / 1.98, aot 2.99 / 1.62 / 3.00. That reproduces the shape plans/benchmark_followups.md already records for scalar exp through the vecmath lane - AOT level with or behind the interpreter on exp and exp_std, ahead of the JIT only on exp_est.
  • test_aot links with the benchmark stubs in it (275 MB, 1080-odd TUs) and answers the lane's probe.
  • 82 dastest arms pass (bin/daslang dastest/dastest.das -- --test utils/internal/bench-stand), reached per PR through run_utils_tests. The memory-cap arm needs perl, no longer /proc, so it runs on the darwin lane too. On a RelWithDebInfo host the process still exits 1 after the report: the C++ allocation tracker's exit-time leak dump does that to every suite in this repo, this one included.
  • The two-command local flow was run on this branch tip: run --filter core/math/ --repeat 1 then report, both exit 0, 20 series, and the summary renders. That path found two defects now fixed - a meta with no build section was read as a failed build, and the run verb's exit code did not follow the record's status.
  • The box pipeline was exercised locally in a scratch layout rooted at BENCH_STAND_HOME, against a worktree with a built binary: two clean nights, then a night whose build fails. The failed night published a summary leading with the CMake error, and the failed record was written by the tool. Linux x86_64, BENCH_STAND_BUILD=skip.
  • Each of the seven REVIEW.das cells was negative-controlled: break the invariant once, confirm the finding, restore. Gate green on the branch.
  • preflight fast tier: format, lint (both rails), hash-refs, review-md-tests, md-ascii, ast-verify, ci-das, ci-matrix all pass.
  • Two preflight gates are red for reasons outside this change. review-md reports nine findings from utils/REVIEW.das, all about utils/ast-fuzz/ and utils/dasFormatter/ - untracked local directories on the author's box, one of them excluded through .git/info/exclude. Control: the same gate on a clean worktree of this branch prints REVIEW.das utils: OK. compile-sweep reports 122 of 761 program roots failing, 50 of them missing prerequisite 'sqlite', on a build configured -DDAS_SQLITE_DISABLED=ON -DDAS_LLVM_DISABLED=ON; none of the 122 is in this diff.
  • The first CI run on this branch turned two macOS defects up, both now fixed and pushed. nightly.sh computed its build parallelism with a bare $(nproc) at load, which is GNU-only, so on the darwin lane every verb of the script died with exit 127 before reaching its work - the count now falls back to sysctl hw.ncpu and then to 4, verified by hiding nproc from PATH. And process_rss_mb truncated to whole megabytes, so a process under 1 MB read as 0; it rounds up now, which also makes a cap fire a hair early rather than late. The memory-kill arm itself passed on darwin, so the ps fallback works where the limit matters.
  • The external reviewer round was skipped: no codex on PATH.
  • The workflow itself has never executed. A schedule lane cannot fire on a branch, and workflow_dispatch needs the file on the default branch, so its first run is after merge - and it reds until the box is provisioned (below).

Claims - stated, not tested

  • The JIT lane's availability probe reports the lane unavailable rather than failing the night. Verified with a stand-in binary that exits non-zero, and locally where -jit exits 1 under the allocation tracker; a break would show as a night with no JIT numbers and no reason recorded beside the lane.
  • The ssh gate refuses everything but its four verbs and rejects arguments outside path characters. Verified by driving nightly.sh gate with hostile SSH_ORIGINAL_COMMAND values; a break would let a key holder run a shell on the web box.
  • The -DDAS_LLVM_DISABLED=OFF build on the box fetches the prebuilt LLVM the CI linux lanes fetch. Not verified on Debian 13. A break records the JIT lane as unavailable with the reason, and the interpreter lane still produces a night.
  • Timings quoted in README.md (a cold build of 20-40 minutes, a suite of 1-2 hours) are estimates from this author's box, not from the VPS.

Not attributed to this change

  • build (windows, 32, Release, none) fails one arm of tests/module_cache/test_deferred_modules.das, about a module whose C++ dependencies are deferred. This diff adds no C++, touches no module-cache code and no file under tests/. I did not obtain a master-side control run, so this is an attribution by content, not by a red-on-master comparison.

Not done

  • The box is not provisioned. Owed before the first night: generate an ed25519 key, sudo bench-stand-deploy.sh provision <key.pub>, sudo bench-stand-deploy.sh caddy, store the private half as the DASWEB_BENCH_KEY environment secret on github-pages, then dispatch the workflow once. README.md section 3 has the commands.
  • Two benchmarks are broken on master and will red the first nights. benchmarks/decs/bench_from_decs_count.das does not compile (int += int64). benchmarks/core/array/test01.das allocates ~19 GB and is OOM-killed; it is skipped in suite.json with that reason and shows as skipped every night until fixed. Neither is fixed here.
  • site/app.js has no test rail. The repo has two that would fit - node --test on the pure helpers, as utils/internal/dasweb-verify/browser/ does, and Playwright against the static page, as site/tests/playground/ does - and neither is wired up, so the viewer's DOM branches are unproven.
  • bench-stand-deploy.sh has no test: its testable core needs a splice-into-a-fixture entry point first, the shape utils/internal/dasweb-buildd/roll_toolchain.sh uses.
  • Four gate cells were proposed and not written: run-record fields against their readers, a temp-path literal check in the test files, and two more the round noted. The checklist carries the prose instead.

@aleksisch
aleksisch force-pushed the aleksisch/benchmarks-stand branch 9 times, most recently from 1881c61 to 786e7f9 Compare September 15, 2026 15:56
`total` counted archetype sizes in an int while Archetype.size is an int64, so the file did not
compile and the benchmark had never run. The count is a sum of sizes, so the accumulator takes the
width of what it sums rather than the sum being narrowed to it. The entity id the fixture block
never reads takes the underscore that says so.
…ns daslang stops reading argv[0] itself

daslib/clargs wraps get_command_line_arguments behind three accessors - the post-`--` slice,
argv[1..], and get_user_args picking between them by host - and every program in the tree is meant
to reach for those rather than the builtin. The one thing it did not answer was the binary a
program is running under, which is what a program spawning the same daslang it runs under needs.
get_host_binary() is that accessor: argv[0] as an absolute path. The CLI reference names it beside
the other three, and the compile-time benchmark group two commits along is its first caller.
…build step and no dependencies

utils/internal/bench-stand/site is the page served at /bench/: index.html, app.js, style.css and
nothing else - it fetches data.json and status.json from its own directory and renders everything
client-side, so publishing a night is a file copy and the box runs no service behind it.
caddy.snippet is the route that serves the directory, and the only place that route is written
down: it goes into the daslang.io block of the box's Caddyfile, no-cache, so a visitor never reads
yesterday's night out of a cache. suite.json rides along: the suite the nights measure - root,
excludes, the two lanes and their per-file exclusions, repeats, the per-file timeout, the
regression gate's three numbers, and the skips with the reason each one is listed on the page
every night.

It opens with the latest night: the build's log tail when the build is what failed, otherwise the
failures with the message to act on, the lanes that did not run and the skips with their reasons,
then the regressions and improvements against their baseline and the noise the gate was measured
against. Below that is one chart per benchmark arm, grouped by directory - the minimum ns/op per
night as the line, the night's spread as a band, a crosshair snapping to the nearest run with a
tooltip listing every lane there, and a click through to the commit; a night the file failed is
marked on the axis, and a gap in the line is a night the arm did not measure. The run history
closes the page, each row linking its own record.

Colors come from the lane through one `--lane` custom property, never from a series' position, so
a chart added today is comparable with one read yesterday, and a chart drawing more than one lane
carries its legend.
@aleksisch
aleksisch force-pushed the aleksisch/benchmarks-stand branch from 786e7f9 to f076b1e Compare September 16, 2026 07:36
@aleksisch
aleksisch marked this pull request as ready for review September 16, 2026 09:12
@aleksisch
aleksisch requested a review from borisbat September 16, 2026 09:13
… is benchctl's two new verbs

The stand runs every [benchmark] under benchmarks/ on one box, in an interp and a jit lane, and
publishes the numbers over commits. suite.json says what runs, with which limits and which
exclusions; a run record per night carries the commit, the machine, the build and one result per
file per lane; data.json is what the viewer reads. run_stand.sh is the single pass cron calls, and
caddy.snippet is the only place the public route is written down.

The tool itself is benchctl. benchctl already stored benchmark output, queried it by commit and
compared two sets with a Welch test, so a second tool beside it would have restated that: a second
parse of dastest's benchmark lines, a second copy of dastest's stats struct under another name, and
its own median. It could not lend any of that out, because benchstat reached its numbers only
through the SQL row and so pulled sqlite into anything that wanted a median.

benchstat now works on BenchmarkRunStats - the struct dastest itself emits - and bench_table maps a
stored row into that shape, so the statistics compile with no database behind them. median and
median_i64 live there beside the rest; BenchStatsLine is gone as the duplicate of BenchmarkRunStats
that it was; the runner's whole-output classifier, never a parser of the same kind, is
classify_run_output. bench_suite, bench_runner, bench_history and the run and report verbs sit under
utils/benchctl, which dispatches those two before its own parser sees a flag it does not know, and
utils/internal/bench-stand keeps only what the box has.

Every module orders its top level types, then private helpers, then its public functions, so the
tail of a file is its API.
@aleksisch
aleksisch force-pushed the aleksisch/benchmarks-stand branch from f076b1e to 43b9425 Compare September 16, 2026 09:14
@borisbat
borisbat merged commit 2fa02d0 into master Sep 16, 2026
33 checks passed
@borisbat
borisbat deleted the aleksisch/benchmarks-stand branch September 16, 2026 10:16
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.

2 participants