Repository navigation
feat: run each analysis benchmark in its own process #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
263b84c
76f8452
6217b10
16decfc
734108f
927aa7c
7d16c57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| /** | ||
| * Process isolation for walltime runs: every benchmark runs in fresh copies | ||
| * of the benchmark process (forks), so the allocator state, JIT state, and | ||
| * handles that other benchmarks or setup code leave behind can't change its | ||
| * measurement. Rounds within one process are correlated (JIT tier-up, heap | ||
| * sizing), so the rounds of several forks are pooled into one result. | ||
| * Process isolation: every benchmark runs in fresh copies of the benchmark | ||
| * process (forks), so the allocator state, JIT state, and handles that other | ||
| * benchmarks or setup code leave behind can't change its measurement. Walltime | ||
| * rounds within one process are correlated (JIT tier-up, heap sizing), so the | ||
| * rounds of several forks are pooled into one result. Analysis runs measure | ||
| * each benchmark once, in one fork. | ||
| */ | ||
| import { spawn, spawnSync } from "child_process"; | ||
| import { randomUUID } from "crypto"; | ||
|
|
@@ -12,6 +13,7 @@ import { tmpdir } from "os"; | |
| import { join } from "path"; | ||
| import { deserialize, serialize } from "v8"; | ||
| import { constants, gunzipSync, gzipSync } from "zlib"; | ||
| import { getInstrumentMode } from "./runnerMode"; | ||
|
|
||
| /** Accepts `0`, `false`, `1`, or `true`; defaults to enabled. */ | ||
| export const PROCESS_ISOLATION_ENV = "CODSPEED_PROCESS_ISOLATION"; | ||
|
|
@@ -44,10 +46,11 @@ export type IsolationMode = | |
| | { kind: "Fork"; uri: string; samplesFile: string }; | ||
|
|
||
| /** | ||
| * The isolation of a walltime run, from the environment. `forks` is the | ||
| * integration's option, which {@link WALLTIME_FORKS_ENV} overrides. | ||
| * The isolation of this run, from the environment. A walltime benchmark runs | ||
| * in `forks` forks, the integration's option, which {@link WALLTIME_FORKS_ENV} | ||
| * overrides; an analysis benchmark is measured once, so it runs in one. | ||
| */ | ||
| export function walltimeIsolation(forks?: number): IsolationMode { | ||
| export function processIsolation(forks?: number): IsolationMode { | ||
| const uri = process.env[ISOLATED_BENCHMARK_ENV]; | ||
| if (uri !== undefined) { | ||
| const samplesFile = process.env[FORK_SAMPLES_FILE_ENV]; | ||
|
|
@@ -67,7 +70,8 @@ export function walltimeIsolation(forks?: number): IsolationMode { | |
| `[CodSpeed] ${PROCESS_ISOLATION_ENV} must be 0, false, 1, or true, got \`${isolation}\``, | ||
| ); | ||
| } | ||
| return { kind: "Forks", forks: resolveWalltimeForks(forks) }; | ||
| const analysis = getInstrumentMode() === "analysis"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| return { kind: "Forks", forks: analysis ? 1 : resolveWalltimeForks(forks) }; | ||
|
Comment on lines
+73
to
+74
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One process per analysis benchmark costs a full Node or Vitest startup under Valgrind. On CI, from #124 to this PR: tinybench simulation 4m26 → 8m20 (+6.7 s per benchmark), memory 55 s → 1m52; Vitest simulation 6m20 → 11m43 (+14 s per benchmark), memory 1m07 → 2m48. A 100-benchmark Vitest suite would run ~30 min longer. We can ship it like this and follow up to lower the overhead. For the final release, it would be great to have numbers showing the variance improvement, to understand the tradeoff. https://github.com/CodSpeedHQ/codspeed-node/actions/runs/42632341179 |
||
| } | ||
|
|
||
| /** | ||
|
|
@@ -221,7 +225,11 @@ function forkSamples( | |
| } | ||
| } | ||
|
|
||
| /** Hands round samples (milliseconds) to the parent as gzip-compressed V8 data. */ | ||
| /** | ||
| * Hands round samples (milliseconds) to the parent as gzip-compressed V8 data. | ||
| * Analysis forks hand none: the instruments hold their measurement, and the | ||
| * file only tells the parent that they measured their benchmark. | ||
| */ | ||
| export function writeForkSamples( | ||
| samplesFile: string, | ||
| samples: ArrayLike<number>, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,17 +4,18 @@ import { | |
| getInstrumentMode, | ||
| InstrumentHooks, | ||
| mongoMeasurement, | ||
| processIsolation, | ||
| SetupInstrumentsRequestBody, | ||
| SetupInstrumentsResponse, | ||
| tryIntrospect, | ||
| wrapWithRootFrameSync, | ||
| type WalltimeOptions, | ||
| } from "@codspeed/core"; | ||
| import { Bench } from "tinybench"; | ||
| import { Bench, type FnOptions } from "tinybench"; | ||
| import { setupCodspeedAnalysisBench } from "./analysis"; | ||
| import { getOrCreateTaskDataMap } from "./taskData"; | ||
| import { getOrCreateUriMap } from "./uri"; | ||
| import { setupCodspeedWalltimeBench } from "./walltime"; | ||
| import { runsTaskFn, setupCodspeedWalltimeBench } from "./walltime"; | ||
|
|
||
| tryIntrospect(); | ||
|
|
||
|
|
@@ -29,6 +30,7 @@ export function withCodSpeed( | |
|
|
||
| const rootCallingFile = getCallingFile(1); | ||
| const instrumentMode = getInstrumentMode(); | ||
| const isolationMode = processIsolation(options.forks); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The description says this is stacked on #123, but the base is #124's branch and #123 isn't in it. Do we need to merge #123 to release this as well? Without it, every tinybench benchmark here pays the first-benchmark setup #123 removes (Memory 656 B → 34 KB, https://app.codspeed.io/CodSpeedHQ/codspeed-node/runs/compare/6af1f0830bbd4d5a590423c6..6af1f0950bbd4d5a590423cd |
||
|
|
||
| // Compute and register URI for bench | ||
| const uriMap = getOrCreateUriMap(bench); | ||
|
|
@@ -43,25 +45,40 @@ export function withCodSpeed( | |
| uri += `::${name}`; | ||
| uriMap.set(name, uri); | ||
| taskDataMap.set(name, { fn, fnOpts: opts }); | ||
| // In walltime mode the task is driven by tinybench's own measured loop, so | ||
| // the root frame must be baked into the function tinybench stores rather | ||
| // than injected later (the function is an inaccessible private field on the | ||
| // task from tinybench v6 onwards). The sync wrapper is transparent to the | ||
| // function's return value, so it preserves tinybench's sync/async handling. | ||
| const registeredFn = | ||
| instrumentMode === "walltime" ? wrapWithRootFrameSync(fn) : fn; | ||
| return rawAdd.bind(bench)(name, registeredFn, opts); | ||
| const add = rawAdd.bind(bench); | ||
| // tinybench calls a function that isn't `async` once when it's added, to | ||
| // tell whether it returns a promise. A process that doesn't run the task | ||
| // registers a stub instead, so the user's function isn't called there. | ||
| if (!runsTaskFn(isolationMode, uri)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return add(name, notRunHere, opts); | ||
| } | ||
| // The analysis runner calls the captured function itself. | ||
| if (instrumentMode === "analysis") { | ||
| return add(name, fn, opts); | ||
| } | ||
| // The task is driven by tinybench's own measured loop, so the root frame | ||
| // must be baked into the function tinybench stores rather than injected | ||
| // later (the function is an inaccessible private field on the task from | ||
| // tinybench v6 onwards). An async wrapper would add an await to every | ||
| // iteration, so the wrapper stays sync and tinybench is told that an async | ||
| // function is async instead (honored from tinybench v6 onwards). | ||
| const fnOpts: (FnOptions & { async?: boolean }) | undefined = | ||
| fn.constructor.name === "AsyncFunction" ? { ...opts, async: true } : opts; | ||
| return add(name, wrapWithRootFrameSync(fn), fnOpts); | ||
| }; | ||
|
|
||
| if (instrumentMode === "analysis") { | ||
| setupCodspeedAnalysisBench(bench, rootCallingFile); | ||
| } else if (instrumentMode === "walltime") { | ||
| setupCodspeedWalltimeBench(bench, rootCallingFile, options); | ||
| setupCodspeedAnalysisBench(bench, rootCallingFile, isolationMode); | ||
| } else { | ||
| setupCodspeedWalltimeBench(bench, rootCallingFile, isolationMode); | ||
| } | ||
|
|
||
| return bench; | ||
| } | ||
|
|
||
| /** Registered for tasks this process never runs; `async` so tinybench doesn't call it. */ | ||
| async function notRunHere(): Promise<void> {} | ||
|
|
||
| /** | ||
| * Dynamically setup the CodSpeed instruments. | ||
| */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's format this a bit better to be more readable.
Also the comment's first sentence hints at this process isolation being on no matter what.