Skip to content

bytecode: avoid quadratic work in basic block linking and liveness propagation - #356

Open
robobun wants to merge 1 commit into
mainfrom
robobun/bytecode-basic-block-binary-search
Open

bytecode: avoid quadratic work in basic block linking and liveness propagation#356
robobun wants to merge 1 commit into
mainfrom
robobun/bytecode-basic-block-binary-search

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

A deeply nested array destructuring pattern emits O(depth) basic blocks, O(depth) exception handlers, and O(depth) callee locals (each nesting level wraps IteratorClose in a synthesized try/finally). When such a pattern sits inside a try/catch, the first time the catch executes LLInt calls ensureCatchLivenessIsComputedForBytecodeIndex, which builds the bytecode graph and runs the liveness fixpoint over the whole code block.

const N = 3000;
const src = "try { let " + "[".repeat(N) + "z" + "]".repeat(N) + " = w } catch (e) { print('caught', e.constructor.name) }";
(0, eval)(src);

Release jsc before this change:

depth time
500 155 ms
1000 543 ms
2000 2346 ms
3000 5692 ms
5000 16584 ms

Two separate quadratic costs showed up under sampling:

BytecodeBasicBlock::computeImpl

Linked each branch and throw to its successor block by scanning the entire basic block list per branch. Blocks are already sorted by leaderOffset (they are created in program order, and BytecodeGraph::findBasicBlockWithLeaderOffset already binary-searches over that ordering), so replace the linear scan with tryBinarySearch. addSuccessor() already dedupes, which covers the switch case where one block appears more than once among the branch targets.

BytecodeLivenessPropagation::stepOverInstruction

Called handlerForBytecodeIndex (a linear scan over all handlers) and then forEachSetBit over the handler block's live-in set, setting one bit at a time, for every instruction, on every fixpoint iteration.

computePreciseJumpTargets adds every handler's start, end, and target to the precise jump target set, so a handler range cannot start or end in the middle of a basic block, and every instruction in a block resolves to the same innermost handler. Hoist the handler lookup to once per block, precompute the handler-block mapping once at the start of runLivenessFixpoint, and fold the handler's live-in into out once with a word-wise |=; keep it folded in by having the def functor leave a local alone when the handler keeps it live, instead of re-applying the merge after every def.

Result

Release jsc with this change:

depth before after
500 155 ms 38 ms
1000 543 ms 95 ms
2000 2346 ms 325 ms
3000 5692 ms 701 ms
5000 16584 ms 1808 ms

The remaining growth is the inherent O(blocks x numCalleeLocals / 64) bitvector dataflow in the liveness fixpoint itself.

Verification

--dumpBytecodeLivenessResults=true output is byte-identical before and after on a script exercising nested try/catch, nested array destructuring, and for-of. 80 JSC stress tests covering catch/try/destructur/for-of/iterator/finally/liveness produce identical output and exit codes against an unpatched build.

This is complementary to #355, which adds a native-stack guard in ArrayPatternNode::bindValue; the depths in the table above are within that guard's budget on a release build.

…opagation

A deeply nested array destructuring pattern emits O(depth) basic blocks,
O(depth) exception handlers, and O(depth) callee locals: each nesting
level wraps IteratorClose in a synthesized try/finally. When such a
pattern sits inside a try/catch, the first time the catch executes LLInt
calls ensureCatchLivenessIsComputedForBytecodeIndex, which builds the
bytecode graph and runs the liveness fixpoint over the whole code block.
At N=3000 that takes several seconds; debug+ASAN runs for minutes.

Two separate quadratic costs showed up under profiling:

1. BytecodeBasicBlock::computeImpl linked each branch and throw to its
   successor block by scanning the entire basic block list per branch.
   Blocks are already sorted by leaderOffset (they are created in
   program order; BytecodeGraph::findBasicBlockWithLeaderOffset already
   relies on that ordering), so replace the linear scan with
   tryBinarySearch. addSuccessor() already dedupes, which covers the
   switch case where one block appears more than once among the branch
   targets.

2. BytecodeLivenessPropagation::stepOverInstruction called
   handlerForBytecodeIndex (a linear scan over all handlers) and then
   forEachSetBit over the handler block's live-in set, setting one bit
   at a time, for every instruction, on every fixpoint iteration.
   Exception handler start/end/target offsets are added to the precise
   jump target set by computePreciseJumpTargets, so a handler range
   cannot start or end in the middle of a basic block, and every
   instruction in a block therefore resolves to the same innermost
   handler. Hoist the handler lookup to once per block, precompute the
   handler-block mapping once at the start of runLivenessFixpoint, and
   fold the handler's live-in into `out` once with a word-wise `|=`;
   keep it folded in by having the def functor leave a local alone when
   the handler keeps it live, instead of re-applying the merge after
   every def.

For `try { let [[[...2500 deep...]]] = w } catch (e) {}` this brings
the first-catch cost from ~4s to ~0.5s in a release build.
dumpBytecodeLivenessResults output is unchanged on code exercising
nested try/catch and array destructuring.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8ee36ce4-0836-466b-a46e-a53598400684

📥 Commits

Reviewing files that changed from the base of the PR and between d103ebd and 6f3056d.

📒 Files selected for processing (4)
  • Source/JavaScriptCore/bytecode/BytecodeBasicBlock.cpp
  • Source/JavaScriptCore/bytecode/BytecodeLivenessAnalysis.cpp
  • Source/JavaScriptCore/bytecode/BytecodeLivenessAnalysis.h
  • Source/JavaScriptCore/bytecode/BytecodeLivenessAnalysisInlines.h

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

Comment on lines 197 to 199
case op_iterator_open: {
return result;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 This removes the case op_async_iterator_open: branch, but that opcode has two checkpoints (symbolCall, getNext) — when tmpLivenessForCheckpoint is called at checkpoint 1 the !checkpoint early-return is skipped and execution now falls through to RELEASE_ASSERT_NOT_REACHED(). Any for await loop that reaches DFG and queries tmp liveness at the getNext checkpoint will crash the process; the case (which just returns the empty result, same as op_iterator_open) should be restored.

Extended reasoning...

What was removed

The diff deletes:

case op_async_iterator_open: {
    return result;
}

from the switch in tmpLivenessForCheckpoint(). This function is unrelated to the basic-block linking / liveness-fixpoint work described in the PR — it computes checkpoint-tmp liveness for DFG, not local liveness — so the deletion looks accidental (perhaps a stray hunk from an editor cleanup).

Why it crashes

op_async_iterator_open is defined in BytecodeList.rb:231-250 with two checkpoints:

checkpoints: {
    symbolCall: nil,
    getNext: nil,
}

so numberOfCheckpoints() == 2. tmpLivenessForCheckpoint begins with:

if (!checkpoint)
    return result;

which handles checkpoint 0 (symbolCall). For checkpoint 1 (getNext) that guard is skipped and the switch is entered. With the op_async_iterator_open case removed, none of the remaining cases match, execution falls to default: break;, and the function hits RELEASE_ASSERT_NOT_REACHED().

Step-by-step

  1. A for await (const x of y) loop emits op_async_iterator_open (BytecodeGenerator).
  2. The loop tiers up to DFG. DFGByteCodeParser.cpp handles op_async_iterator_open via handleAsyncIteratorOpen, so it is DFG-compilable.
  3. Something asks for tmp liveness at the getNext checkpoint — e.g. DFGForAllKills.h:84-94, DFGGraph.h:1164 (forAllLiveInBytecode), DFGGraph.cpp:1205, or DFGByteCodeParser.cpp:696, all of which call tmpLivenessForCheckpoint(codeBlock, bytecodeIndex) whenever bytecodeIndex.checkpoint() != 0.
  4. checkpoint == 1!checkpoint is false → switch on op_async_iterator_open → no case matches → default: break;RELEASE_ASSERT_NOT_REACHED() → process crash.

Why nothing else prevents it

The only guard before the switch is if (!checkpoint) return result;, which by design only covers checkpoint 0. Every multi-checkpoint opcode must therefore appear explicitly in the switch. op_iterator_open (the sync twin, same two checkpoints) is still present; op_async_iterator_open was present in the base commit (HEAD~1 line 195) and is removed only by this PR.

Impact

This is a release-build crash (RELEASE_ASSERT_NOT_REACHED), not a debug-only assert. Any async-iteration-heavy code that warms up enough to reach DFG will terminate the process. It is a strict regression introduced by this PR and unrelated to its performance goal.

Fix

Restore the deleted case (it can share the op_iterator_open arm since both return the empty bitset):

case op_iterator_open:
case op_async_iterator_open: {
    return result;
}

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
6f3056df autobuild-preview-pr-356-6f3056df 2026-07-26 12:27:01 UTC

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.

2 participants