Repository navigation
Conversation
908b943 to
6b7735c
Compare
This comment was marked as resolved.
This comment was marked as resolved.
712d651 to
36fb9cc
Compare
2d8cb27 to
c9f80fc
Compare
guybedford
left a comment
There was a problem hiding this comment.
Built and ran this locally: histogram_test, bench-test@, bench-report-test@, the server-test bench case, text-encoder-bench@smoke and exported-handler-proxy-test@ all pass, and a real run of workerd bench produces sensible output. The harness design (C++ timing loop, calibrated batches, overhead measured the same way as user cases and subtracted) looks good.
My main question is the scope of the histogram crate. As I read it, bench reaches Histogram::new/record/percentile/percentile_ci/mean_ci/mean/stddev/skewness/kurtosis, which is a few hundred lines of hdr.rs/histogram.rs/stats.rs. The rest of the crate (cbor.rs export/import, qrde.rs, Welch/Mann-Whitney, the percentile/linear/log iterators, EWMA, record_corrected/record_delta/add/subtract, and the Node golden parity suite in lib-test.rs + testdata/) looks like it's there for a node:perf_hooks createHistogram() implementation rather than for the harness. Is that the intended scope of this PR? If not, could we drop those parts here (roughly 3k lines plus the fixtures) and bring them in with the perf_hooks PR that has a consumer for them?
A few smaller things inline.
They are intended to also support the perf_hooks stuff and more nodejs_compat but there are pieces that are used here and the bench tool will be incrementally expanded to use more of those. Going to need to do this work anyway, planning to do it very soon so would rather keep it here. |
The histogram statistics need erfc() and lgamma(); the std equivalents (float_erf, float_gamma) are unstable. libm has no dependencies.
Adds src/rust/histogram, which provides HDR histograms that behave like Node.js's native perf_hooks histograms. It will back the statistics of the `workerd bench` harness and node:perf_hooks createHistogram(). `hdr` ports the parts of HdrHistogram_c (0.12.0, as vendored by Node.js; public domain under CC0) that Node.js uses: bucket layout, recording, merging, the internal-counter reset, the value-equivalence helpers, percentiles, mean, standard deviation, and the recorded, percentile, linear, and logarithmic iterators. It follows the C code closely, including the round-half-up percentile target, the iterators' boundary steps, and the order of floating-point operations, so that results match Node.js exactly. The shift operations, and with them a nonzero normalizing index offset, are not ported because Node.js never uses them. `Histogram` adds the state Node.js keeps alongside the HDR data: the exceeds count, the reset count, the recordDelta() timestamp, and the EWMA, with Node.js's snapshot(), diff(), add(), subtract(), and reset() semantics.
Ports the analysis functions of Node.js's src/histogram.cc: skewness and excess kurtosis, a Student's t confidence interval for the mean, an exact binomial confidence interval for percentiles, Welch's t-test, the Mann-Whitney U test with tie correction, Cohen's d, Cliff's delta, and the two-sample Kolmogorov-Smirnov statistic, with the numerical helpers they rely on (the regularized incomplete beta function and the normal, Student's t, and binomial distributions). The port keeps Node.js's operation order, so results match to the last bit except where libm's lgamma() and erfc() differ from the platform libm by an ulp. The two-sample functions walk the counts arrays by index, as Node.js does, without checking that the layouts match. QRDE and the asymptotic incomplete beta approximations it uses are not ported yet.
Implements Node.js's histogram export format, the CBOR map behind histogram.export() and perf_hooks.importHistogram(). Export writes format version 2, byte-for-byte what Node.js writes. Import accepts versions 1 and 2 with Node.js's validation: duplicate keys, indexes that are not strictly increasing, a counts length that does not match the layout, counts that overflow, and a total count that does not match the counts are rejected, and unknown keys are skipped in version 2 data only. Unlike Node.js, import rejects a nonzero normalizing index offset. Node.js never writes one, and the HDR core does not port the index normalization it would require. The tests check exports against bytes produced by Node.js 26.10, which writes version 1; the only difference is the version value.
Ports histogram.qrde() from Node.js: Harrell-Davis quantiles at the requested probability boundaries, with the "none", "hdr", and "all" dequantization modes, the tail-trimming support search for histograms with many buckets, and the Edgeworth approximation for concentrated beta weights. The incomplete beta function gains the asymptotic paths QRDE needs; the exact path that the other statistics use is unchanged. Node.js computes QRDE on its thread pool and returns a promise. Here it is synchronous, since the computation is linear in the occupied buckets per boundary, and there is accordingly no equivalent of the snapshot cache.
Adds a test_compile_data attribute for files that a crate's unit tests read at compile time with include_str! or include_bytes!, such as test vectors. Like the *-test.rs files, they are inputs of the test target only.
Adds node-golden.json, the results of every histogram query that the crate implements, from Node.js 26.10, over 11 datasets (empty, tiny, constant, uniform, wide, bimodal, heavy-tailed, and out-of-range) in 7 layouts (figures 1 to 5, lowest 1, 1000, and 1024), plus the two-sample tests over 6 pairs. generate-node-golden.mjs produces it; the datasets use integer arithmetic only and are stored in the output. The tests require identical results where the crate performs the same operations as Node.js: counts, min and max, mean, standard deviation, percentiles, the percentile, linear, and logarithmic iterations, cdf(), percentile confidence intervals, and export bytes. Results that go through lgamma() or erfc() are compared to 1e-12 relative, since libm can differ from glibc by an ulp. Every export is also imported and checked again. Floats in the file are strings: serde_json's default float parsing is not correctly rounded, which showed up as one-ulp mismatches, while str::parse::<f64>() is. The file is excluded from prettier, which would reflow it into about a hundred thousand lines.
The benchmark harness records its samples into histograms and reports their statistics. The bridge exposes creating a histogram, recording into it, and a summary with the statistics the harness reports: count, min, max, mean, standard deviation, median, p75, p99, and confidence intervals on the median and the mean, plus skewness and kurtosis.
Set by the upcoming `workerd bench` subcommand. Code that exists only for benchmarking checks it, so that it fails closed anywhere else.
BenchParams carries the options for one bench() handler; BenchReport holds the results: per case, wall and CPU time per call with summary statistics and the raw samples. workerd bench --format=json will print the report with capnp's JSON codec, so field names are its JSON keys.
A Worker's `bench(b, env, ctx)` handler registers cases with `b.run(name, fn, options)`; the runtime then runs the timing loop, so user code never sees a high-resolution clock. For each case, the runner calls `fn` repeatedly to warm up, doubles the batch size until a batch takes at least minTime / samples (and 1 ms), then times `samples` batches, reading the monotonic and thread CPU clocks only between batches. Promises returned by `fn` are awaited before the next call. Batches are separated by a turn of the event loop. `setup` and `teardown` run untimed around each batch, and `b.blackBox(value)` returns its argument through a native call that V8 can't see into. The event is WorkerInterface::bench(params, groupBuilder), delivered like test(): only WorkerEntrypoint implements it, and the server's wrappers forward it. Unlike test(), it awaits the handler directly instead of through finishScheduled(), since the report needs the handler's result. Failures of the handler or of a case are recorded in the report; statistics come from the histogram crate. BenchController construction requires bench mode, which only `workerd bench` sets, so the event fails closed anywhere else. The handler isn't a member of ExportedHandler, which is unwrapped from the export for every event and reads each handler property. Reading a new property there would break exports that reject unknown properties, like miniflare's dev registry proxy, a WorkerEntrypoint that returns a Proxy from its constructor. Instead, as for `queue`, a separate BenchExportedHandler is unwrapped from the export's `self` only when a benchmark runs, through Worker::Api::getBenchTypeHandler(), whose default implementation throws. exported-handler-proxy-test checks that delivering an event reads no property outside the existing handler names. Compatibility: this adds `bench` to the ExportedHandler typings and the BenchController and BenchCaseOptions types to the generated typings, following the `test` precedent. Nothing in production dispatches the event or reads the property. Unlike `test`, `bench` isn't added to the WorkerEntrypoint or DurableObject classes: their methods are RPC methods, and an existing method named `bench` with another signature would stop type-checking. Such methods keep working over RPC.
Runs the bench handlers of the entrypoints that match the service and entrypoint patterns, as Server::test() runs test handlers, adding a group to a BenchReport for each. Progress is logged like test()'s, with [ BENCH ], [ DONE ], [ FAIL ], and [ SKIP ] lines. The startup that test() did inline moves to startLocalRun(), which both use. Its state lives in one heap object, destroyed after the run as the locals were. Only plain-object exports have bench handlers. In bench mode, validation leaves `bench` out of the methods it reports for WorkerEntrypoint and Workflow classes, whose methods are RPC methods, so an unrelated RPC method named `bench` isn't run as a benchmark. Production validation doesn't change.
`workerd bench <config> [filter]` runs the config's bench handlers and writes the results to stdout, or to --output, as a table or with --format=json as the BenchReport. It takes the same config files and filter syntax as `workerd test`, plus an optional /<case-pattern> suffix, and the same --compat-date, --all-autogates, and --no-verbose flags. --quick caps budgets for checking that benchmarks run. The report's environment records the workerd and V8 versions, build mode, CPU model, and frequency governor, with warnings for non-release builds and governors other than "performance".
Benchmarked code often makes a subrequest on every call, as in `env.SELF.fetch()`, and tail workers would add their cost to each one. No request gets tracers in bench mode. The bench server test now clears bench mode when it's done, so that it doesn't change later tests in the same binary.
wd_bench(src = "x.wd-bench", data = [...]) takes a config in wd_test()'s format and, for each compat variant, defines a `bazel run` target that runs `workerd bench` (tagged workerd-benchmark, so CI's benchmark filter excludes it) and an @smoke test that runs each case briefly with --quick, so CI checks that benchmarks keep working. @benchmark.json writes the default variant's report, off by default like wd_cc_benchmark()'s @benchmark.csv. `just wd-bench <target> [args]` runs one in the benchmark configuration. wd_test.bzl gains wd_cli_test(), a public name for its test rule, which wd_bench() uses for the smoke tests. As for wd_test(), configs can declare a writable disk service named TEST_TMPDIR, e.g. for Durable Object storage. The smoke tests get Bazel's test temporary directory; the run targets and @benchmark.json create a new temporary directory and remove it afterwards, so no target runs against persistent storage unless its config names a path. text-encoder-bench ports bench-text-encoder.c++. Each case is a single call, where the C++ version measured a fetch whose handler looped 1000 times.
Before the first group runs, the runner times three internal cases the same way as user cases: an empty function, a function that returns a resolved promise, and `blackBox(undefined)`. Their medians are kept for the rest of the process. Each case's samples have the matching empty-case cost subtracted, chosen by whether its function returned a promise, never going below zero. A case whose median before subtraction is under twice the overhead is flagged atFloor, since it mostly measures the runner (or V8 optimized its work away). Its variance is meaningless then, so it isn't also flagged highVariance. Each group reports the overhead it subtracted, so raw values can be recovered, and the text report shows it under the group name.
Adds a section on JavaScript benchmarks to docs/benchmarking.md: the bench handler and BenchController, how cases are timed, wd_bench() and running benchmarks, reading the report, and why classes have no bench handlers.
Tail workers are skipped under `workerd bench`, since they add their cost to every request; --trace runs them, to measure that cost. Skipping them now happens when a Worker's channels are linked, rather than for each request: the tail workers are looked up, so config errors are still reported, and then dropped.
The bench server test asserted that an empty async case is flagged at the floor, which depends on timing and failed under load. bench-test checks the report's statistics with fixed samples instead: which overhead is subtracted, clamping at zero, and when cases are flagged at the floor or high-variance.
`just new-bench //path/to:name-bench` scaffolds a wd_bench(): a .wd-bench config, a module with an example bench() handler, and the BUILD rule, as `just new-test` does for wd_test().
Lists `just new-bench` and `just wd-bench`, wd_bench() under test types, and where the bench harness lives; adds `bench` to the server's subcommands.
The Node.js parity tests take about 10 seconds in an unoptimized build, since Rust tests run serially, so under load they exceeded a medium test's 15-second timeout. Optimized, they take under a second.
The recipe copies the snapshot from bazel-bin, whose outputs are read-only, and cp keeps their modes, so types/generated-snapshot/ experimental was left read-only. Git then couldn't replace the files in it, and a checkout or rebase that changed them failed with "unable to unlink old ...: Permission denied". The recipe already made the directory writable before removing it; it now does so after the copy too.
c9f80fc to
a031007
Compare
Benchmarks usually measure current behavior, but wd_bench()'s default variant copied wd_test()'s 2000-01-01, so `just wd-bench ...@` measured year-2000 semantics, and @all-compat-flags (2999-12-31) enables flags whose dates haven't arrived. `workerd bench --compat-date=latest` now overrides every worker's date with the newest one this build supports: today's date, or MAXIMUM_COMPATIBILITY_DATE if that's earlier, since the maximum is set a little into the future. Without --compat-date, workers that omit compatibilityDate get that date rather than a config error, through the new Server::setDefaultCompatibilityDate(); workers that set a date keep it. The report records the resolved override in Environment.compatDate and the default in the new Environment.defaultCompatDate. wd_bench()'s default and @all-autogates variants now pass --compat-date=latest unless compat_date is set. Since the date is only known at run time, it can't come from the macro itself.
a031007 to
a4a6f8d
Compare
Similar to
wd-testbut for benchmarks...Planning to use this as part of the TS streams and other API optimizations.
*.wd-bench file
*.js file
Also sets us up for some more nodejs_compat work later but that's secondary.