Skip to content

[JSC] Stack positions: keep the divot of an instruction, and let a provider find the line and column - #730

Closed
robobun wants to merge 3 commits into
mainfrom
bun/stack-position-cache
Closed

robobun wants to merge 3 commits into
mainfrom
bun/stack-position-cache

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

On hold. The cache half of this PR is now on its own in #732, which is ready. What stays here is the virtual SourceProvider::lineAndColumnForOffset(). It waits for a decision on oven-sh/bun#43955 (where the line index should live). If the index goes into the fork, this PR closes.

Part of oven-sh/bun#43882.

Problem

  • After the upgrade to upstream 7b485a76e9, each frame of a stack trace costs more. One call of CodeBlock::lineColumnForBytecodeIndex() is 1,051 instructions in a release build of Bun. It was 78.
  • The cause is upstream c76c52f5b1. It removed ExpressionInfo::m_cachedLineColumns together with the line and column fields, so every lookup runs ExpressionInfo::entryForInstPC(), which decodes from the start of the chapter.
  • The first position in a source also builds LineStartTable, one entry per line, from a scan of the whole source. For a 10 MB module that is 6.8 ms and 6 MB of RSS at the first read of error.stack.

Fix

  • ExpressionInfo::divotForInstPC() keeps the divot of each instruction it was asked for, in a hash map, as lineColumnForInstPC() kept the line and column. CodeBlock::lineColumnForBytecodeIndex() asks for the divot through it.
  • SourceProvider::lineAndColumnForOffset() is a virtual that the two documentLineColumnForOffset functions ask first. The default returns false, and the table answers as before. Bun's provider overrides it in Find the line of a stack frame without a table of every line bun#43955 (draft).
  • All the code is under USE(BUN_JSC_ADDITIONS).
  • Verified: JSTests/stress/stack-position-is-the-same-on-every-read.js, and the measurements below.

Measured on Linux x64 with release builds of Bun: main (WebKit 35e8970dfd), the upgrade (74650443cb), and the upgrade with the cache commit 9f0ea1adf1 of this PR. Instruction counts come from gdb (stepi from the entry of the function to its return). Times are medians of interleaved runs.

main upgrade upgrade + cache
Instructions for one frame 78 1,051 259
Instructions for one read of error.stack, 11 frames 73,653 84,833 75,546
new Error().stack, call depth 10 14.2 µs 17.1 µs 14.1 µs
error.stack, frame late in module code of 100 statements 3.79 µs 9.89 µs 3.89 µs
The same, 1,000 statements 3.66 µs 64.1 µs 3.71 µs
The same, 100,000 statements 5.54 µs 21.4 µs 5.60 µs

Background

  • ExpressionInfo maps an instruction to the source range of its expression. It is a compressed stream with a chapter every 10,000 words, and a lookup decodes from the start of a chapter.
  • The divot is the offset in the source that a frame reports. Since c76c52f5b1 the line and the column come from the source provider, which derives them from the divot.
  • The 181 instructions per frame that remain above main are the lookup in the line start table. The virtual is the place where Bun replaces that table.

Downsides

  • One more pointer (8 bytes) in each ExpressionInfo, and a hash map slot of 8 bytes for each instruction that a stack trace, the sampling profiler or the debugger asked for. The removed cache had the same pointer and slots of 12 bytes.
  • One virtual call for each position that a provider does not answer itself. It is not measured yet.

Upstream c76c52f removed ExpressionInfo::m_cachedLineColumns together
with the line and column fields. CodeBlock::lineColumnForBytecodeIndex()
then decodes the expression info from the start of the chapter on every
call. For one frame that is 1,051 instructions where it was 78, and
30 us for a frame late in a function of 1,000 statements.

ExpressionInfo::divotForInstPC() keeps the divot of each instruction it
was asked for, as lineColumnForInstPC() kept the line and column.
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Preview build of f44d2ea: autobuild-preview-pr-730-f44d2ea8

The calls to makeStack() are on different lines, so the frames of the
caller differ between two stacks.
SourceProvider::lineAndColumnForOffset() is a virtual that the two
documentLineColumnForOffset functions ask first. The default returns
false, and the line start table answers as before.

Bun's provider overrides it, so that the first position in a source
does not scan the whole source and does not keep one entry per line.
@robobun robobun changed the title [JSC] Keep the divot of an instruction that a stack trace asked for [JSC] Stack positions: keep the divot of an instruction, and let a provider find the line and column Sep 25, 2026
@robobun

robobun commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #734 replaces both halves of this PR. It keeps positions in ExpressionInfo, and an embedder supplies the table through SourceProvider::setEncodedLineStarts(), so the virtual is not needed.

@robobun robobun closed this Sep 26, 2026
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.

1 participant