Skip to content

[bench]: Add hot-path benchmarks and an A/B benchmark driver - #218

Merged
pseudomuto merged 5 commits into
temporalio:mainfrom
rainman456:benchmarks
Oct 20, 2026
Merged

pseudomuto merged 5 commits into
temporalio:mainfrom
rainman456:benchmarks

Conversation

@rainman456

@rainman456 rainman456 commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

The repo has no benchmarks, so performance claims have nothing to measure
against. This adds benchmarks for every stage of the request path:

  • namespace extraction and translation in protoutil,
  • rule matching and the routing decision, including the decision metric, in
    router,
  • one full unary round trip through the gateway shape (hybrid codec, metrics
    interceptor, PeekInterceptor, Director, per-upstream proxy) against a
    no-proxy baseline over the same bufconn upstream.

The whole-path pair is the headline. A proxied unary call allocates 400 times
against 160 for a direct one, so any change to the forward path now has a
number to answer to.

Here is what a 10 round run of these benchmarks reports on a shared 2 vCPU
host. Allocs/op and B/op are deterministic; timing on shared hardware moves a
few percent between rounds.

benchmark ns/op B/op allocs/op
UnaryDirect ~95µ 10.76 Ki 160
UnaryThroughProxy ~250µ 30.55 Ki 400
Extractor (peek) ~4.6µ 2.23 Ki 19
Director.Resolve ~0.5µ 192 5
Translator (warm) ~0.5µ 32 2

mise run test:bench benchmarks the working tree, and with -b <ref> it
compares against that revision: rounds alternate between the working tree and
a detached worktree of the base so noise lands evenly on both sides, then the
benchstat comparison is printed. Count, benchtime, and packages are task
flags.

The test helpers dialBufconn, serve, prepared, newTestReporter, and
newRelayWith take testing.TB now so benchmarks can reuse them instead of
duplicating the bufconn harness. CI compiles the benchmarks but does not run
them; mise run test:bench runs them on demand.

@rainman456
rainman456 requested review from a team and pseudomuto as code owners October 11, 2026 00:23
@CLAassistant

CLAassistant commented Oct 11, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@pseudomuto pseudomuto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello again @rainman456, thanks for PR!

Having benchmarks at all is a big step up 💪 Most of my comments are nits and other minor concerns. The larger suggestion is about bench/run-benchmarks.sh. In sum, I think we should drop it (well, replace it really).

The script's idea is solid 👌 IIUC, we compare two revisions in a worktree, alternate rounds so noise spreads evenly, and let benchstat do the stats. I have a few concerns about the other parts of the script that I think should be addressed. I commented inline in the file, but to summarize:

  • Linux only: /proc/cpuinfo, and nproc aren't on stock macOS), so on a Mac it prints errors instead of attesting anything
  • Pinning GOMAXPROCS to nproc is already Go's default, so it doesn't change the run (and requires nproc to be available).
  • IMO, the profiling pass (CPU, heap, block and mutex for each package) is a separate job from comparing two revisions, and we have config-gated pprof routes now (#220) for real profiling (thanks to you).
  • It writes untracked artifacts into the repo root and runs an unpinned benchstat@latest.

I think we can simplify this into a short mise task, keeping it similar to the other dev commands and pinning the tooling.

If we drop something like this into mise-tasks/test/bench, we would be able to run it via mise run test:bench [ARGS] (we'd need to remove the test:bench task from mise.toml).

Sample bench:test task

I adapted this from your script, but maybe dropped something meaningful (please double check 😄).

#!/usr/bin/env bash
#MISE description="Run benchmarks, optionally comparing against another revision"
#USAGE flag "-b --base <ref>" help="Compare the working tree against this revision with benchstat"
#USAGE flag "-c --count <count>" default="10"
#USAGE flag "-t --time <time>" default="1s"
#USAGE flag "-p --packages <packages>" default="./..."
set -euo pipefail

bench() {
  go test -run '^$' -bench . -benchmem -count="$1" -benchtime="$usage_time" $usage_packages
}

if [[ -z "${usage_base:-}" ]]; then
  bench "$usage_count"
  exit
fi

work="$(mktemp -d)"
git worktree add --detach "$work/base" "$usage_base" >/dev/null
trap 'git worktree remove --force "$work/base"; rm -rf "$work"' EXIT

# Alternate one round at a time so noise lands evenly on both sides.
for i in $(seq "$usage_count"); do
  echo "round $i/$usage_count" >&2
  (cd "$work/base" && bench 1) >>"$work/base.txt"
  bench 1 >>"$work/cand.txt"
done

# TODO: optionally tee to a gitignored file/dir in root for further analysis
benchstat "$work/base.txt" "$work/cand.txt"

Do you think this makes sense? Definitely open to discussing any/all of it more.

Comment thread internal/router/relay_bench_test.go Outdated
Comment thread internal/router/mux_bench_test.go Outdated
Comment thread internal/protoutil/translate_bench_test.go Outdated
Comment thread internal/protoutil/translate_bench_test.go Outdated
Comment thread internal/protoutil/translate_bench_test.go Outdated
Comment thread bench/run-benchmarks.sh Outdated
Comment thread bench/run-benchmarks.sh Outdated
Comment thread bench/run-benchmarks.sh Outdated
Comment thread bench/run-benchmarks.sh Outdated
Comment thread bench/run-benchmarks.sh Outdated
@rainman456

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass. All ten comments addressed in ca41963 and 3cfa989:

  • The shared StartWorkflowExecution request moved to internal/testutils and is reused by the extractor, translator, and relay benchmarks.
  • The standalone mux benchmark is gone; its shapes (metadata match, system upstream) run as sub-benches of BenchmarkDirector_Resolve, which exercises the real Resolve path including the decision metric.
  • The cold translator benchmark, the IsWarm assertion, and the file-header comment are dropped; warm-service behavior stays asserted in the unit tests.
  • run-benchmarks.sh now uses 2-space indentation, portable cpus/cpu_model helpers instead of nproc/direct /proc/cpuinfo reads, no GOMAXPROCS export, and the pinned benchstat from mise.toml. Artifacts are gitignored.

One note on the lockfile: golang.org/x/perf has no release tags, so mise.lock pins the pseudo-version @latest currently resolves to (v0.0.0-20261009192801-be2c69fb417e). A regular mise lock run will keep it current.

Fork CI (lint, test, build) is green on the pushed head 3cfa989.

@pseudomuto

Copy link
Copy Markdown
Collaborator

This is looking good 👍. Do you have any thoughts on the mise task, etc. I mentioned in the review notes?

@rainman456

Copy link
Copy Markdown
Contributor Author

Agreed, a mise task is a better home for this. 47f84ed replaces bench/run-benchmarks.sh with mise-tasks/test/bench, so mise run test:bench and test:bench -b <ref> work as you described, and the TOML test:bench task is gone so the names do not collide.

I used your sample as-is, plus two small keeps from the script:

  • A base-empty guard. Right now origin/main predates the benchmark files, so test:bench -b origin/main would hand benchstat an empty side. One grep fails it with a message instead.
  • An echo of the two short SHAs before the benchstat output, so a pasted comparison says what it compared.

Agree on dropping the profiling pass; #220 covers real profiling now. The rest of the header (kernel, CPU model, Go version) goes with it, the SHAs are what a captured run needs. Your tee TODO stays, along with the gitignore entries for when someone implements it.

Fork CI (lint, test, build) green on 47f84ed.

@pseudomuto

Copy link
Copy Markdown
Collaborator

@rainman456 I think it needs a rebase since #234 was merged. Otherwise, this looks great!

Once the rebase is done, we'll get this in right away. Thanks for all your work in seeing this through ✊

The repo has no benchmarks, so performance claims have nothing to measure
against. This adds benchmarks for every stage of the request path: namespace
extraction and translation (protoutil), rule matching and the routing decision
including the decision metric (router), and one full unary round trip through
the gateway shape - hybrid codec, metrics interceptor, PeekInterceptor,
Director, per-upstream proxy - against a no-proxy baseline over the same
bufconn upstream. Whole-path allocations are the headline number: a proxied
unary call allocates 400 times against 160 for a direct one.

bench/run-benchmarks.sh drives A/B comparison of two revisions: it alternates
base and candidate one round at a time, pins GOMAXPROCS, and reports benchstat
plus pprof tops. The header it prints carries the commit SHAs, CPU model, and
Go version, so a captured run is self-attesting. CI compiles the benchmarks
but does not run them; mise test:bench runs them on demand.

The test helpers dialBufconn, serve, prepared, newTestReporter, and newRelayWith
take testing.TB now, so benchmarks reuse them instead of duplicating the
bufconn harness.
Share the StartWorkflowExecution bench request through internal/testutils
so the extractor, translator, and relay benchmarks describe one payload.
Fold the standalone mux benchmark into BenchmarkDirector_Resolve, which
covers the same shapes through the real Resolve path, and drop the cold
translator benchmark and the warm-service assertion the fx module already
guards.
Use getconf and sysctl instead of nproc and /proc/cpuinfo so the header
works on macOS, and rely on Go's default GOMAXPROCS like every other run.
benchstat now comes from the mise tools section instead of go run
@latest, and the artifacts the driver writes are gitignored.
bench/run-benchmarks.sh grew a profiling pass and a header block that
duplicated what config-gated pprof routes and benchstat already do. The
mise task keeps the useful core: alternate A/B rounds against a base
revision in a worktree, then compare with the pinned benchstat. Task
arguments cover count, benchtime, and packages; running without --base
just benchmarks the working tree, replacing the old test:bench task.
The module path changed to go.temporal.io/proxy in temporalio#234 while this branch
was open, so the benchmark files introduced here still imported the old
path and broke typechecking on the merge. Rewritten to the new path; the
imports keep their group order, which gci now reads as the localmodule
section.
@rainman456

Copy link
Copy Markdown
Contributor Author

Rebased, thanks for the nudge.

#234 moved the module path to go.temporal.io/proxy, and this branch merged with main textually clean, which is exactly why CI went red: the benchmark files added here still imported github.com/temporalio/temporal-proxy, so lint and test fell over on typechecking rather than on any real conflict. 8c68cff rewrites those 14 imports to the new path; they keep their own group, so gci still reads them as localmodule.

The branch is now the four bench commits plus that one, on top of 0cbd1cc. Fork CI (lint, test, build) is green on 8c68cff and it's pushed here. Ready for another look.

@pseudomuto pseudomuto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this @rainman456!

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 13 lines in your changes missing coverage. Please review.

📢 Thoughts on this report? Let us know!

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@pseudomuto
pseudomuto merged commit a01f5c8 into temporalio:main Oct 20, 2026
6 checks passed
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.

4 participants