Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
58fcc53
Add libm Rust dependency
jasnell Oct 8, 2026
b3a2aeb
Add histogram crate with an HdrHistogram core
jasnell Oct 8, 2026
7d3596a
Add statistical analysis to the histogram crate
jasnell Oct 8, 2026
4d815b9
Add histogram export and import
jasnell Oct 8, 2026
5d4f642
Add quantile-respectful density estimation to the histogram crate
jasnell Oct 8, 2026
0f426c6
Let wd_rust_crate tests embed data files
jasnell Oct 8, 2026
9f586b8
Test the histogram crate for parity with Node.js
jasnell Oct 8, 2026
7d86184
Add a C++ interface to the histogram crate
jasnell Oct 8, 2026
40faa63
Add a process-wide bench mode
jasnell Oct 8, 2026
5a6db84
Add the bench event schema
jasnell Oct 8, 2026
cf0689c
Add the bench handler and BenchController
jasnell Oct 8, 2026
14c5f3f
Add run::bench()
jasnell Oct 8, 2026
2e9a74f
Add the workerd bench subcommand
jasnell Oct 8, 2026
12836c7
Skip tail workers under workerd bench
jasnell Oct 8, 2026
29f48ad
Add wd_bench() and a TextEncoder benchmark
jasnell Oct 8, 2026
9c444ce
Measure and subtract the bench runner's overhead
jasnell Oct 8, 2026
020c1a4
Document workerd bench and wd_bench()
jasnell Oct 8, 2026
b841385
Add workerd bench --trace
jasnell Oct 8, 2026
b8f4de0
Unit-test fillBenchReport() instead of timing
jasnell Oct 8, 2026
3670e20
Add just new-bench
jasnell Oct 8, 2026
89c844e
Mention workerd bench in AGENTS.md
jasnell Oct 8, 2026
fa7a6e7
Make the histogram crate's tests large
jasnell Oct 8, 2026
ae33aad
Leave just generate-types' snapshot writable
jasnell Oct 9, 2026
5f52464
Default workerd bench to the newest compat date
jasnell Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
samples/nodejs-compat-streams-split2/split2.js
# Generated by src/rust/histogram/testdata/generate-node-golden.mjs.
src/rust/histogram/testdata/node-golden.json
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Subdirectory `AGENTS.md` files provide component-specific context (key classes,
- `just build-asan` - Build with AddressSanitizer
- `just test-asan` - Run tests with AddressSanitizer
- `just new-test <target>` - Scaffold a new test (e.g., `just new-test //src/workerd/api/tests:my-test`)
- `just new-bench <target>` - Scaffold a new JavaScript benchmark (`wd_bench()`)
- `just wd-bench <target> [args]` - Run a `wd_bench()` target in an optimized build (e.g., `just wd-bench //src/workerd/api/tests:text-encoder-bench@`)
- `just new-wpt-test <name>` - Scaffold a new WPT test
- `just lint` or `just eslint` - Run ESLint on TypeScript sources
- `just coverage <path>` - Generate code coverage report (Linux only, defaults to `//...`)
Expand All @@ -55,7 +57,7 @@ Subdirectory `AGENTS.md` files provide component-specific context (key classes,
- **Rust unit tests**: `<module>-test.rs` beside the module under test, never inline; run by the crate's test target (`<name>_test` for a crate defined with a `wd_rust_*` macro; otherwise see the package's `BUILD.bazel`). See `src/rust/AGENTS.md` §UNIT TEST FILES.
- **Node.js compatibility tests**: `just node-test <test_name>`
- **Web Platform Tests**: `just wpt-test <test_name>`
- **Benchmarks**: `just bench <path>` (e.g., `just bench mimetype`)
- **Benchmarks**: `just bench <path>` (e.g., `just bench mimetype`) for C++ (`wd_cc_benchmark()`); `wd_bench()` for JavaScript via `workerd bench`, whose `@smoke` tests run in CI. See `docs/benchmarking.md`.

### Running a Single Test

Expand Down Expand Up @@ -196,6 +198,7 @@ Be aware that workerd uses tcmalloc for memory allocation in the typical case. W
| Durable Object storage | `src/workerd/io/actor-cache.{h,c++}` + `actor-sqlite.{h,c++}` | LRU cache over RPC / SQLite-backed |
| Streams implementation | `src/workerd/api/streams/` | Has 842-line README; dual internal/standard impl |
| Bazel build rules | `build/` | Custom `wd_*` macros; `wd_test.bzl` generates 3 test variants |
| JS benchmarks | `src/workerd/api/bench.{h,c++}`, `build/wd_bench.bzl` | `bench` handler; runner `BenchController`; report schema `io/bench.capnp`; driver `server/server/bench.rs` |
| TypeScript types | `types/` | Extracted from C++ RTTI + hand-written `defines/*.d.ts`; see `types/AGENTS.md` for detailed typings guidance |
| V8 patches | `patches/v8/` | 33 patches; see `docs/v8-updates.md` |

Expand Down
160 changes: 160 additions & 0 deletions build/wd_bench.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""wd_bench definition"""

load("@workerd//:build/lint_test.bzl", "lint_test")
load("@workerd//:build/wd_test.bzl", "wd_cli_test")

_WORKERD = "//src/workerd/server:workerd_cross"

def wd_bench(
src,
data = [],
name = None,
args = [],
lint = True,
compat_date = "",
generate_all_compat_flags_variant = True,
generate_all_autogates_variant = True,
tags = [],
**kwargs):
"""Defines benchmarks that run `workerd bench` with a particular config.

Args:
src: A config file defining the benchmarks, in the same format as wd_test()'s. (`name` is
derived from it if not specified.) The extensions `.wd-bench`, `.wd-test`, and `.capnp` are
permitted.
data: Additional files that the config file may embed. TypeScript files are compiled, as for
wd_test().
args: Additional arguments to pass to `workerd bench`.
lint: If True (default), lint the JavaScript and TypeScript in `data`.
compat_date: If specified, the compat date for the default variant instead of `latest`, the
newest that the build supports.
generate_all_compat_flags_variant: If True (default), generate @all-compat-flags variants.
generate_all_autogates_variant: If True (default), generate @all-autogates variants.
tags: Tags for all targets.

The variants are `name@` (`compat_date`), `name@all-compat-flags` (2999-12-31), and
`name@all-autogates` (`compat_date` with all autogates). For each variant, this generates:
- <variant>: a target for `bazel run`, which measures the benchmarks and prints the results.
Arguments after `--` are passed to `workerd bench`, e.g. a filter or `--format=json`. It is
tagged `workerd-benchmark`.
- <variant>@smoke: a test that runs the benchmarks once each with `--quick`, to check that they
work. This isn't tagged `workerd-benchmark`, so CI runs it.
It also generates `name@benchmark.json`, the default variant's report as a build output, which
is off by default.

As for wd_test(), a writable disk service named TEST_TMPDIR, e.g. for Durable Object storage,
gets a new empty directory for every run. Any other disk service uses the path in the config.
"""
data = data + [src]

ts_srcs = [s for s in data if s.endswith(".ts")]
if ts_srcs:
data = data + [s.removesuffix(".ts") + ".js" for s in ts_srcs]
Comment thread
jasnell marked this conversation as resolved.

if name == None:
Comment thread
jasnell marked this conversation as resolved.
name = src.removesuffix(".capnp").removesuffix(".wd-test").removesuffix(".wd-bench")

if lint:
lint_srcs = [s for s in data if (s.endswith(".ts") or s.endswith(".mts") or s.endswith(".js") or s.endswith(".mjs")) and not s.startswith("../") and not s.startswith("//")]
if lint_srcs:
lint_test(
name = name,
eslintrc_json = "@workerd//tools:base.eslint.config.mjs",
tsconfig_json = "@workerd//tools:base.tsconfig.json",
srcs = lint_srcs,
data = ["tsconfig.json"] if ts_srcs else [],
no_copy_to_bin = [
"@workerd//tools:base.eslint.config.mjs",
"@workerd//tools:base.tsconfig.json",
],
)

# Benchmarks usually measure current behavior, so unlike wd_test(), the default variant uses
# the newest compat date that the build supports rather than the oldest.
default_compat_args = ["--compat-date={}".format(compat_date or "latest")]
variants = [("@", default_compat_args)]
if generate_all_compat_flags_variant:
variants.append(("@all-compat-flags", ["--compat-date=2999-12-31"]))
if generate_all_autogates_variant:
variants.append(("@all-autogates", default_compat_args + ["--all-autogates"]))

workerd_args = ["$(location {})".format(_WORKERD), "bench", "$(location {})".format(src)] + args
for suffix, variant_args in variants:
_wd_bench_run(
name = name + suffix,
data = data + [_WORKERD],
args = workerd_args + variant_args,
tags = ["workerd-benchmark"] + tags,
**kwargs
)
wd_cli_test(
name = name + suffix.removesuffix("@") + "@smoke",
src = src,
data = data + [_WORKERD],
args = workerd_args + variant_args + ["--quick"],
tags = tags,
**kwargs
)

native.genrule(
name = name + "@benchmark.json",
# workerd is a source rather than a tool so that this uses the build that the other targets
# use, as wd_cc_benchmark()'s report does, rather than building it again for the exec
# configuration.
srcs = data + [_WORKERD],
outs = [name + ".benchmark.json"],
cmd = ("TEST_TMPDIR=$$(mktemp -d) && " +
"$(location {}) bench --format=json --output=\"$@\" $(location {}) {} " +
"-dTEST_TMPDIR=$$TEST_TMPDIR; status=$$?; rm -rf $$TEST_TMPDIR; exit $$status").format(
_WORKERD,
Comment thread
jasnell marked this conversation as resolved.
src,
" ".join(args + default_compat_args),
),
tags = ["off-by-default", "benchmark_report", "workerd-benchmark"] + tags,
)

# 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 from wd_cli_test();
# the run targets and the report create a new temporary directory and remove it afterwards.
_SH_TEMPLATE = """#!/bin/bash
set -euo pipefail
TEST_TMPDIR=$(mktemp -d)
trap 'rm -rf "$TEST_TMPDIR"' EXIT
"$@" -dTEST_TMPDIR="$TEST_TMPDIR"
"""

_WINDOWS_TEMPLATE = """@echo off
setlocal
set "WD_BENCH_TMPDIR=%TEMP%\\wd-bench-%RANDOM%%RANDOM%"
mkdir "%WD_BENCH_TMPDIR%" || exit /b 1
%* -dTEST_TMPDIR=%WD_BENCH_TMPDIR%
set "STATUS=%ERRORLEVEL%"
rmdir /s /q "%WD_BENCH_TMPDIR%"
exit /b %STATUS%
"""

def _wd_bench_run_impl(ctx):
is_windows = ctx.target_platform_has_constraint(ctx.attr._platforms_os_windows[platform_common.ConstraintValueInfo])
if is_windows:
executable = ctx.actions.declare_file("%s_wd_bench.bat" % ctx.label.name)
content = _WINDOWS_TEMPLATE
else:
executable = ctx.actions.declare_file("%s_wd_bench.sh" % ctx.label.name)
content = _SH_TEMPLATE
ctx.actions.write(output = executable, content = content, is_executable = True)

runfiles = ctx.runfiles(files = ctx.files.data)
for target in ctx.attr.data:
runfiles = runfiles.merge(target[DefaultInfo].default_runfiles)
return [DefaultInfo(executable = executable, runfiles = runfiles)]

# Bazel runs only executables that the rule creates, so this writes a script that runs its
# arguments, which wd_bench() sets to the workerd command line.
_wd_bench_run = rule(
implementation = _wd_bench_run_impl,
executable = True,
attrs = {
"data": attr.label_list(allow_files = True),
"_platforms_os_windows": attr.label(default = "@platforms//os:windows"),
},
)
5 changes: 4 additions & 1 deletion build/wd_rust_crate.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def wd_rust_crate(
test_tags = [],
test_deps = [],
test_proc_macro_deps = [],
test_compile_data = [],
test_size = "medium",
cxx_bridge_deps = [],
cxx_bridge_hdrs = None,
Expand Down Expand Up @@ -100,6 +101,8 @@ def wd_rust_crate(
test_tags: additional test tags.
test_deps: test-only dependencies.
test_proc_macro_deps: test-only proc_macro dependencies.
test_compile_data: files the unit tests read at compile time (include_str!, include_bytes!),
such as test vectors. Like the *-test.rs files, they are inputs of <name>_test only.
test_size: Bazel test size (default "medium"); affects test timeout. Use "large" for
crates with many subtests, since RUST_TEST_THREADS=1 forces serial execution.
cxx_bridge_deps: either a flat dependency list applied to every bridge source, or a dict of
Expand Down Expand Up @@ -189,7 +192,7 @@ def wd_rust_crate(

wd_rust_test(
name = name + "_test",
compile_data = test_srcs,
compile_data = test_srcs + test_compile_data,
crate = ":" + name,
env = test_env,
size = test_size,
Expand Down
9 changes: 9 additions & 0 deletions build/wd_test.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,12 @@ _wd_test = rule(
"_platforms_os_windows": attr.label(default = "@platforms//os:windows"),
},
)

def wd_cli_test(**kwargs):
"""Defines a test that runs workerd with the given `args`, as wd_test()'s variants do.

The first argument is usually `$(location //src/workerd/server:workerd_cross)`, which must also
be in `data`. `-dTEST_TMPDIR=<dir>` is appended to the arguments. For macros outside this
file, such as wd_bench().
"""
_wd_test(**kwargs)
7 changes: 7 additions & 0 deletions deps/rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions deps/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ hyper = { version = "1", default-features = false, features = ["client", "http1"
# kj-hyper supplies; tower-service is the trait that connector implements.
hyper-util = { version = "0", default-features = false, features = ["client-legacy", "http1", "tokio"] }
libc = "0"
# erfc() and lgamma() for histogram statistics; the std equivalents are unstable.
libm = "0"
lol_html_c_api = { git = "https://github.com/cloudflare/lol-html" }
hashlink = "0"
# kj-rs-io: `fs` for fcntl (KJ's fd flags), `signal` for ignoring SIGPIPE once per process like
Expand Down
129 changes: 129 additions & 0 deletions docs/benchmarking.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,132 @@ on a defined target to obtain benchmarking results.

See example in [bench-json.c++](../src/workerd/tests/bench-json.c++)

# JavaScript benchmarks

`workerd bench` measures JavaScript in a Worker. A Worker exports a `bench` handler that
registers cases; the runtime times them, so benchmark code never needs a precise clock (which
Workers don't have).

```js
export default {
bench(b, env, ctx) {
const encoder = new TextEncoder();
const input = 'a'.repeat(1024);
b.run('encode 1k', () => b.blackBox(encoder.encode(input)));
b.run('sha-256 1k', async () => {
b.blackBox(await crypto.subtle.digest('SHA-256', encoder.encode(input)));
}, { minTime: '2s' });
},
};
```

`b.run(name, fn, options)` registers a case. Cases run in order after `bench()` returns (or its
promise resolves). If `fn` returns a promise, the runner awaits it before the next call. `fn`
must either always or never return a promise; a case that does both fails.

For each case, the runner:

1. Calls `fn` repeatedly for `warmup` (200ms by default), so V8 can optimize it.
2. Doubles the number of calls per batch until a batch takes at least `minTime / samples`
(and at least 1ms). `batch` sets the number instead.
3. Times `samples` batches (50 by default), reading the monotonic and thread CPU clocks only
between batches. Each sample is a batch's time divided by its number of calls. The statistics
hold samples of up to an hour; a case with a longer one fails.

Options, all optional:

| Option | Default | Meaning |
| ---------- | ------- | ---------------------------------------------------------------------------- |
| `minTime` | `1s` | Sampling budget. A number of milliseconds, or a string like `"500ms"`, `"2s"` |
| `warmup` | `200ms` | Warmup budget |
| `samples` | 50 | Number of batches to time |
| `batch` | (none) | Calls per batch, instead of calibrating |
| `setup` | (none) | Called before each batch, untimed; its result is passed to every call of `fn` |
| `teardown` | (none) | Called after each batch with the setup result (if any), untimed |
| `skip` | false | Report the case as skipped |

`b.blackBox(value)` returns `value` through a native call that V8 can't see into. Pass results
through it so that V8 can't optimize away the work that computed them. `b.options` has the
command line's case `filter` and whether `--quick` was given.

## Running

`just new-bench //path/to:name-bench` scaffolds a benchmark. Otherwise, define benchmarks in a
config like a `.wd-test` file (conventionally named `.wd-bench`) and add them to a `BUILD.bazel`
file:

```python
load("//:build/wd_bench.bzl", "wd_bench")

wd_bench(
src = "text-encoder-bench.wd-bench",
data = ["text-encoder-bench.js"],
)
```

Unlike `wd_test()`, the default variant `name@` uses the newest compat date that the build
supports (`--compat-date=latest`) rather than the oldest, since benchmarks usually measure current
behavior; `compat_date` sets another. `name@all-compat-flags` uses 2999-12-31, and
`name@all-autogates` uses the default variant's date with all autogates enabled.

For each compat variant (`name@`, `name@all-compat-flags`, `name@all-autogates`), this defines a
target for `bazel run` and an `@smoke` test (`name@smoke`, `name@all-compat-flags@smoke`, ...)
that runs each case briefly, so CI checks that benchmarks keep working. CI doesn't run the
benchmarks themselves. As for `wd_test()`, the config can declare a writable disk service named
`TEST_TMPDIR`, for example for Durable Object storage; every run gets a new empty directory for it.
To run one in an optimized build:

```
just wd-bench //src/workerd/api/tests:text-encoder-bench@
just wd-bench //src/workerd/api/tests:text-encoder-bench@ '*/encode ascii*' --format=json
```

Arguments after the target go to `workerd bench`, which can also be run directly:

```
workerd bench config.wd-bench [filter] [--format=text|json] [--output=path] [--quick]
Comment thread
jasnell marked this conversation as resolved.
[--compat-date=date|latest] [--all-autogates] [--trace]
```

`--compat-date` requires the workers in the config to omit `compatibilityDate`.
`--compat-date=latest` is the newest date that the build supports: today's date, or the build's
maximum compatibility date if that's earlier. Without `--compat-date`, workers that omit
`compatibilityDate` get that date, so that benchmarks measure current behavior.

The filter has `workerd test`'s format, optionally followed by `/<case-glob>`: for example
`main:encoding/encode*`. `--quick` caps the budgets, to check that cases run rather than to
measure them.

## Reading the results

The text report has a table per handler with each case's median time per call, the 95%
confidence interval of the median, the mean, and the median thread CPU time (mostly noise on
Windows, whose thread CPU clock advances only on scheduler ticks). `--format=json`
writes the full report, including every sample, as defined by
[bench.capnp](../src/workerd/io/bench.capnp). The JSON format isn't stable yet.

- The runner's own cost per call (an empty function, and an empty async function for cases that
return promises) is measured once per run and subtracted from the results. It is shown under
each group's name.
- **at measurement floor**: the case took less than twice the runner's overhead, so the result is
mostly the runner's cost. V8 may have optimized away the work; pass results through
`b.blackBox()`.
- **high variance**: the coefficient of variation exceeds 10%. Try more `samples` or a longer
`minTime`, and check that the machine is quiet.

Results compare runs on the same machine and build. They don't predict production latency. The
report records the build mode, CPU, and frequency governor, and warns about a debug or sanitizer
build or a governor other than "performance". Use `--config=benchmark` (as `just wd-bench` does).

Tail workers don't run under `workerd bench`, since they would add their cost to every
subrequest. `--trace` runs them, to measure that cost.

## Classes

Only plain-object exports, like the default export above, have `bench` handlers. The methods of
`WorkerEntrypoint` and `DurableObject` classes are RPC methods, so a method named `bench` on one
is an ordinary RPC method that `workerd bench` doesn't run. To measure a Durable Object or an RPC
entrypoint, call it through a binding from a plain object's `bench` handler.

See [text-encoder-bench.js](../src/workerd/api/tests/text-encoder-bench.js).

Loading
Loading