Skip to content

jsc: make control-flow-profiler getExecutedRanges linear instead of quadratic - #36137

Open
robobun wants to merge 8 commits into
mainfrom
farm/73bfe023/jsc-coverage-getexecutedranges
Open

jsc: make control-flow-profiler getExecutedRanges linear instead of quadratic#36137
robobun wants to merge 8 commits into
mainfrom
farm/73bfe023/jsc-coverage-getexecutedranges

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

BasicBlockLocation::getExecutedRanges() in JavaScriptCore sorts its gap list with a selection sort (repeated min-scan + Vector::removeAt), so a coverage report on a module with N top-level functions costs O(N^2) inside JSC. Each enclosed function body is a gap in the module-level basic block, so N functions means N gaps and N^2 comparisons every time the report is taken.

for N in 5000 10000 20000 40000; do
  node -e 'let n='$N',s="";for(let i=0;i<n;i++)s+=`export function fn${i}(a){ if(a>${i}) return a*${i}; return -a; }\n`;require("fs").writeFileSync("huge.mjs",s)'
  bun -e '
    import { Session } from "node:inspector/promises";
    const s = new Session(); s.connect();
    await s.post("Profiler.enable");
    await s.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
    await import("./huge.mjs");
    const t = performance.now();
    await s.post("Profiler.takePreciseCoverage");
    console.log("N='$N'", Math.round(performance.now() - t) + "ms");'
done
N takePreciseCoverage before after
5,000 43 ms 12 ms
10,000 150 ms 25 ms
20,000 559 ms 50 ms
40,000 2170 ms 103 ms
60,000 4784 ms 175 ms

This is the JSC-side half of the quadratic bun test --coverage overhead; #36129 removes the Rust-side half in generate_report_from_blocks. With both applied, bun test --coverage on a 20,000-function module drops from ~30 s of overhead to ~0.3 s.

Fix

oven-sh/WebKit#362 replaces the selection-sort loop with a single std::sort on a copy of m_gaps followed by a linear pass. The result vector size is known up front (gaps.size() + 1), so it is reserved once. Output is identical: the original comparator and the new one both order by .first, and the existing "gaps aren't enclosed within one another" invariant guarantees distinct .first values so stability does not matter.

This PR bumps WEBKIT_VERSION to that PR's preview build and adds:

  • a correctness test that exercises the sort on unsorted input (function declarations and expressions interleave, so the gap list is not in source order) and checks every function is reported,
  • a scaling test that times Profiler.takePreciseCoverage at N=4,000 and N=16,000 and asserts the ratio stays under 8 (linear is ~4x, the selection sort was ~12x). The node:inspector path reaches getExecutedRanges without touching the Rust-side report generator, so the measurement is independent of test(coverage): process blocks in offset order so report generation is linear #36129.

Verification

Release build, test/js/node/inspector/inspector-profiler.test.ts -t "many top-level functions":

# before (WEBKIT_VERSION = 549170099226f816a4b204ea1d8fa102fb79eefa)
(pass) reports every function when declarations and expressions are interleaved
(fail) scales sub-quadratically in top-level function count
  Received: { small: 33.1, large: 334.6, ratio: 10.1 }

# after (this PR)
(pass) reports every function when declarations and expressions are interleaved
(pass) scales sub-quadratically in top-level function count [215ms]

bun bd test test/js/node/inspector/inspector-profiler.test.ts: 46 pass.
bun bd test test/cli/test/coverage.test.ts: 12 pass, 10 snapshots unchanged.

In a debug+ASAN build the linear per-item cost of JSON building, JSON.parse, and buildScriptCoverageList is large enough to mask the quadratic term at these sizes (both runs scale ~4x), so the scaling test only fails on release builds. The fix lives entirely in the WebKit prebuilt (via scripts/build/deps/webkit.ts), not in src/, so stashing src/ does not revert it and the fail-before step cannot observe the old behaviour.

Depends on oven-sh/WebKit#362. WEBKIT_VERSION currently points at that PR's preview build and should be updated to the merged commit's autobuild- tag before this lands.


[decide:webkit] gate passed · iteration 1 · 2 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/node/inspector/inspector-profiler.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/node/inspector/inspector-profiler.test.ts
bun test v1.4.0 (a97c4ddd3)

test/js/node/inspector/inspector-profiler.test.ts:
(pass) node:inspector > Session > Session is a constructor [12.79ms]
(pass) node:inspector > Session > Session extends EventEmitter [2.29ms]
(pass) node:inspector > Session > connect() establishes connection [5.56ms]
(pass) node:inspector > Session > connect() throws if already connected [4.02ms]
(pass) node:inspector > Session > connectToMainThread() throws ERR_INSPECTOR_NOT_WORKER on the main thread [4.54ms]
(pass) node:inspector > Session > disconnect() closes connection cleanly [2.58ms]
(pass) node:inspector > Session > disconnect() is a no-op if not connected [2.33ms]
(pass) node:inspector > Session > post() throws if not connected [7.81ms]
(pass) node:inspector > Session > post() with callback calls callback with error if not connected [8.71ms]
(pass) node:inspector > Profiler > Profiler.enable succeeds [19.02ms]
(pass) node:inspector > Profiler > Profiler.disable succeeds [2.55ms]
(pass) node:inspector > Profiler > Profiler.start without enable throws [2.95ms]
(pass) node:inspector > Profiler > Profiler.start after enable succeeds [7.47ms]
(pass) node:inspector > Profiler > Profiler.stop without start throws [3.14ms]
(pass) node:inspector > Profiler > Profiler.stop returns valid profile [20.79ms]
(pass) node:inspector > Profiler > complete enable->start->stop workflow [52.85ms]
(pass) node:inspector > Profiler > samples and timeDeltas have same length [11.62ms]
(pass) node:inspector > Profiler > samples reference valid node IDs [13.42ms]
(pass) node:inspector > Profiler > Profiler.setSamplingInterval works [2.73ms]
(pass) node:inspector > Profiler > Profiler.setSamplingInterval throws if profiler is running [13.14ms]
(pass) node:inspector > Profiler > Profiler.setSamplingInterval requires positive interval [5.55ms]
(pass) node:inspector > Pro
... (truncated)
Exit: 0
diff hotspot
scripts/build/deps/webkit.ts                      |   2 +-
 test/js/node/inspector/inspector-profiler.test.ts | 109 +++++++++++++++++++++-
 2 files changed, 109 insertions(+), 2 deletions(-)

gate history · 6 passed · 0 rejected · iteration 1

evidence per changed file
file                                               reads  edits  tests
scripts/build/deps/webkit.ts                           0      0      0
test/js/node/inspector/inspector-profiler.test.ts      2      6      0

root cause · written by the author bot

The precise coverage range computation in JSC's inspector profiler maintained its range list in sorted order through repeated in-place insertion, which degraded to quadratic time as the number of top-level functions in a script grew, making coverage collection pathologically slow on large files. The fix replaces that incremental ordering with a single sort over the collected ranges, reducing the work to O(n log n) while producing identical output. Accompanying tests generate fixtures with many top-level functions to verify both the correctness of the reported ranges and that collection time…

…uadratic

BasicBlockLocation::getExecutedRanges() sorted its gap list with a
selection sort (repeated min-scan + Vector::removeAt), so a module
with N top-level functions made Profiler.takePreciseCoverage and
bun test --coverage report generation cost O(N^2) inside JSC.

The fix is in oven-sh/WebKit#362; this bumps WEBKIT_VERSION to its
preview build and adds a scaling test via node:inspector (which hits
getExecutedRanges without the Rust-side coverage report path).
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Updates the WebKit prebuilt version and adds inspector profiler tests that validate precise coverage ranges and performance scaling across generated top-level functions.

Changes

WebKit dependency update

Layer / File(s) Summary
WebKit version bump
scripts/build/deps/webkit.ts
Changes WEBKIT_VERSION to a new autobuild preview tag used by WebKit artifact derivation.

Profiler coverage validation

Layer / File(s) Summary
Precise coverage scaling tests
test/js/node/inspector/inspector-profiler.test.ts
Adds generated-fixture coverage tests, validates top-level ranges, and skips performance assertions in debug and ASAN builds.

Possibly related PRs

Suggested reviewers: cirospaciari

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning This PR does not address #39’s Node.js build-output blockers like require handling, node:* externals, or CommonJS output. Address #39’s Node.js output blockers in code, or remove #39 as the linked issue if this PR is only about WebKit coverage performance.
Out of Scope Changes check ⚠️ Warning The WebKit version bump and inspector coverage tests are unrelated to #39’s Node.js build-output objectives. Either retarget the PR to a coverage-performance issue or trim the WebKit/test changes from a PR linked to #39.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: making getExecutedRanges linear and improving coverage performance.
Description check ✅ Passed The description covers what changed and how it was verified, though it uses different headings than the template.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Status: waiting on CI.

Self-review found the first version of the correctness test was asserting on functionHasExecutedCache output (independent of getExecutedRanges), so a broken comparator would still have passed; and the scaling ratio had only ~2x headroom under debug+ASAN where the linear JSON work masks the quadratic anyway. 1cd9363 asserts the top-level entry's split-range count (which collapses from ~N+1 to a handful under a broken sort) and gates the scaling test behind !(isDebug || isASAN) since only release builds can observe the ratio.

The fix lives entirely in the WebKit prebuilt (oven-sh/WebKit#362, via scripts/build/deps/webkit.ts), not in src/, so stashing src/ does not revert it and the fail-before step cannot observe the old behaviour. Fail-before holds against a release build at the current WEBKIT_VERSION:

# release build, WEBKIT_VERSION = 549170099226f816
(fail) scales sub-quadratically in top-level function count
  Received: { small: 25.0, large: 293.7, ratio: 11.7 }

The end-to-end bun test --coverage timing only moves a little with this change alone (the Rust-side quadratic in #36129 dominates); the node:inspector scaling test here isolates the JSC contribution.

Comment thread test/js/node/inspector/inspector-profiler.test.ts Outdated
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:42 PM PT - Jul 27th, 2026

@robobun, your commit a97c4dd has 1 failures in Build #83620 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36137

That installs a local version of the PR into your bun-36137 executable, so you can run:

bun-36137 --bun

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/build/deps/webkit.ts`:
- Line 6: Update the WEBKIT_VERSION constant to the stable autobuild-* release
tag generated from the merged upstream commit, replacing the transient
autobuild-preview-pr-362-084879ae pin while preserving the existing version
configuration format.

In `@test/js/node/inspector/inspector-profiler.test.ts`:
- Line 746: Update the child-process invocation containing cmd and run.mjs to
resolve the disposable fixture path to an absolute path before passing it to
bunExe(). Preserve the existing fixture selection and argument String(n) while
removing reliance on the spawned process’s cwd.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: df21731f-1462-49db-8afc-a8d510622f94

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and 1cd9363.

📒 Files selected for processing (2)
  • scripts/build/deps/webkit.ts
  • test/js/node/inspector/inspector-profiler.test.ts

Comment thread scripts/build/deps/webkit.ts
Comment thread test/js/node/inspector/inspector-profiler.test.ts
Comment thread test/js/node/inspector/inspector-profiler.test.ts Outdated
Comment thread test/js/node/inspector/inspector-profiler.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/inspector/inspector-profiler.test.ts`:
- Line 789: Remove the 30_000 per-test timeout argument from the affected test
in inspector-profiler.test.ts, leaving the test to use the repository’s
runner-level timeout and preserving its existing test logic.
- Around line 782-787: Update the benchmark setup around the ratio assertion so
the small workload reliably produces an elapsed time of at least 20 ms through
increased work or repetitions, then calculate the ratio using small.elapsed
directly without Math.max. Preserve the existing ratio threshold and reported
timing fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 35e9da51-6541-42d4-85a4-8e4505a388ea

📥 Commits

Reviewing files that changed from the base of the PR and between 1cd9363 and e6b1958.

📒 Files selected for processing (1)
  • test/js/node/inspector/inspector-profiler.test.ts

Comment thread test/js/node/inspector/inspector-profiler.test.ts Outdated
Comment thread test/js/node/inspector/inspector-profiler.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tests look good now — the vacuous assertion is gone and topLevelRanges >= N is the load-bearing check, and summing 5 takes removes the denominator-floor concern. Not approving because the actual fix lives in oven-sh/WebKit#362 (which I can't review from here) and WEBKIT_VERSION still points at the preview tag that needs swapping to the merged autobuild- tag before this lands, per the PR description.

What was reviewed:

  • Fixture correctly isolates getExecutedRanges via node:inspector (independent of the Rust-side #36129 path).
  • Scaling test's skipIf(isDebug || isASAN) gate and ratio bound (< 8 vs. observed ~12 pre-fix / ~4 post-fix) look reasonable; serial execution is justified for the timing ratio.
  • Subprocess pattern (tempDir, drain-all-pipes, stderr-before-exitCode) matches file conventions.
Extended reasoning...

Overview

This PR bumps WEBKIT_VERSION in scripts/build/deps/webkit.ts to a preview build of oven-sh/WebKit#362 and adds two tests to test/js/node/inspector/inspector-profiler.test.ts: a correctness test (N=120, asserts the top-level block is split into ≥N ranges) and a release-only scaling test (N=4000 vs N=16000, asserts the timing ratio stays under 8). The Bun-side diff is test-only; the actual algorithmic fix — replacing a selection sort with std::sort in BasicBlockLocation::getExecutedRanges() — lives entirely in the WebKit fork.

Security risks

None. The change is a performance fix in JSC's control-flow profiler plus test additions. No new user-facing surface, no untrusted-input parsing.

Level of scrutiny

High, for two reasons that both require a human:

  1. The fix is not in this diff. It's in oven-sh/WebKit#362. A reviewer needs to look at that C++ change to confirm the std::sort comparator and linear pass produce identical output to the old selection sort (the PR description argues stability doesn't matter because gap .first values are distinct, but that claim lives in the other repo's diff).
  2. WebKit version bumps are dependency changes. The pin currently points at autobuild-preview-pr-362-084879ae, and the PR description explicitly says this must be swapped to the merged commit's stable autobuild- tag before landing. Merging as-is would break builds once the preview release is pruned. This is a known two-step dance (per prior bumps #35754/#35910) that a human coordinates.

Other factors

I reviewed three earlier revisions of the tests and flagged vacuous assertions (allValid, then sorted) that buildScriptCoverageList's filter+sort on inspector.ts:309 made true by construction; both are now removed and only the observable topLevelRanges >= N count remains. CodeRabbit's denominator-floor and per-test-timeout concerns were also addressed (229bada, 91f1c97). The author declined to make the N=120 test test.concurrent on the grounds it's ~35 ms and not worth splitting from the timing-sensitive block — reasonable. All prior review threads are resolved. The remaining blocker is structural (cross-repo dependency + preview tag), not a defect in this diff.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the change itself is done and reviewed, CI is green except one lane.

  • test/js/bun/http/serve.test.ts fails on the darwin 14 x64 lane only, in "releases a paused request body when the handler responds without reading it" (EPIPE, ~359ms, identical in builds 83598 and 83620, so it looks deterministic on that lane). The test exercises Bun.serve request-body backpressure, which this PR does not touch; it passes 5/5 locally against the new WebKit. It may be surfaced by the JSC main delta the preview build carries (549170099..98392399a), which any future WebKit bump would also pull in. Reported separately for triage.
  • Remaining step before landing: BasicBlockLocation::getExecutedRanges: replace O(n^2) selection sort with std::sort WebKit#362 needs to merge, then WEBKIT_VERSION gets swapped from the preview tag to the stable autobuild tag of the merged commit.

Needs a maintainer call on the darwin lane; I am not re-rolling CI again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant