Skip to content

UnlinkedMetadataTable: hold only the offset table while no CodeBlock is linked - #496

Closed
Jarred-Sumner wants to merge 1 commit into
mainfrom
claude/unlinked-metadata-unlinked-footprint
Closed

UnlinkedMetadataTable: hold only the offset table while no CodeBlock is linked#496
Jarred-Sumner wants to merge 1 commit into
mainfrom
claude/unlinked-metadata-unlinked-footprint

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Split out of the startup-snapshot branch (#397's description: "the UnlinkedMetadataTable compaction that used to be here is unrelated to snapshots and will be proposed separately"). Independent of #494/#495.

What

UnlinkedMetadataTable::finalize() allocated valueProfiles + LinkingData + offset table + all metadata entries as one buffer, and the bytecode-cache decoder (CachedMetadataTable::decode → the 3-argument constructor) allocated the same full-size buffer for every function it decoded. The UnlinkedCodeBlock then owned that buffer for its lifetime: the first link() shared it, later ones copied, and when the sharing MetadataTable died unlink() only cleared m_isLinked — the value-profile/metadata bytes stayed allocated with no CodeBlock using them. For executables generated from a bytecode cache the UnlinkedCodeBlock edge is strong, so that is the life of the process.

Now the unlinked state holds only the offset table (s_offset16TableSize, plus the 32-bit table when needed). link() always allocates a fresh zeroed LinkingData + totalSize buffer and copies the offset table in (the first link previously reused the finalize-time buffer and memset it; same cost). unlink() of the owning table frees it and keeps a compact copy of the offset table. buffer()/offsetTable16()/offsetTable32() go through prefixSize(), which is 0 while unlinked. The cache decoder's constructor loses its now-unused lastOffset parameter.

MetadataTable::destroy already documents that unlink frees the table's memory and destroys LinkingData first; sizeInBytesForGC(MetadataTable&) still only compares pointers.

Measured

jsc shell, x64 Linux, decoding every function of a 75 MB cache built from a 12 MB minified bundle (48,040 UnlinkedCodeBlocks, none linked): peak RSS 217.4 MB → 174.8 MB (−42.6 MB, ~890 B per unlinked code block). For a running program the same bytes come back whenever a CodeBlock is collected while its UnlinkedCodeBlock survives (aging window, CodeCache, or cache-generated executables).

JSTests/stress (default run + disk-cache run, 5,259 tests): identical pass/fail to main.

…is linked

finalize() (and the bytecode-cache decoder) allocated the full value-profile + LinkingData +
metadata buffer up front and the UnlinkedCodeBlock kept it for its whole life, including
after every CodeBlock linked from it had been destroyed, and for cache-decoded functions
that were never linked at all. The unlinked table now holds just the offset table; link()
allocates the full buffer (zeroed, as before) and unlink() of the owning MetadataTable
shrinks back to the offset table.

Decoding every function of a 75 MB bytecode cache (48k UnlinkedCodeBlocks, none linked):
peak RSS 217 MB -> 175 MB.
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your current included review allowance is based on your included PR review attempts over the past 7 days.

Next review available in: 37 minutes

Limit details: You’ve used the included review currently available. Your 63 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 28 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dfa51d67-bb83-4072-a593-5379abef0845

📥 Commits

Reviewing files that changed from the base of the PR and between 4ef6222 and dfd7895.

📒 Files selected for processing (4)
  • Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.cpp
  • Source/JavaScriptCore/bytecode/UnlinkedMetadataTable.h
  • Source/JavaScriptCore/bytecode/UnlinkedMetadataTableInlines.h
  • Source/JavaScriptCore/runtime/CachedTypes.cpp

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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I reviewed this PR and didn't find any bugs. Because it changes buffer-lifetime and layout invariants in UnlinkedMetadataTable — a structure every executed function's CodeBlock depends on — a human look would still be worthwhile.

What was reviewed:

  • prefixSize() gating of buffer()/offsetTable16/32() — all callers (totalSize, link, unlink, sizeInBytesForGC) read the correct region in both linked and unlinked states.
  • link() sizing: zeroedMalloc(sizeof(LinkingData)+totalSize) with totalSize = valueProfileSize + offsetTable[last] matches the old allocation; the offset-table memcpy lands at the same offset the old code used.
  • unlink() freeing the shared buffer: MetadataTable::destroy moves out and destructs LinkingData before calling unlink, so the free is safe; the compact copy preserves the zeroed 16-bit prefix for 32-bit tables via the full offsetTableSize memcpy.
  • sizeInBytesForGC(MetadataTable&) still only compares pointers against buffer(); a torn read of m_isLinked/m_rawBuffer from the GC thread affects only the reported byte count, not correctness.
Extended reasoning...

Overview

The PR changes UnlinkedMetadataTable so that while no MetadataTable shares its buffer (!m_isLinked), m_rawBuffer holds only the offset table rather than the full valueProfiles + LinkingData + offsets + metadata layout. finalize() and the bytecode-cache constructor now allocate only the offset table; link() always allocates a fresh full-size zeroed buffer and copies the offset table in; unlink() of the owning MetadataTable frees the shared buffer and keeps a compact offset-table copy. buffer()/offsetTable16()/offsetTable32() are routed through a new prefixSize() that returns 0 when unlinked and valueProfileSize + sizeof(LinkingData) when linked. CachedMetadataTable::decode drops the now-unused lastOffset argument.

Security risks

No new external inputs or attack surface. The risk profile is memory-safety within the engine: use-after-free or buffer-layout confusion if the m_isLinked ↔ buffer-shape invariant were ever violated. I traced the state transitions (finalize → link → unlink → link …) and the invariant holds at each step; MetadataTable::destroy already destructs LinkingData before unlink() frees the underlying allocation, and the destructor's ASSERT(!m_isLinked) still holds because the owning MetadataTable keeps a Ref to the unlinked table. The 32-bit path's zeroed 16-bit prefix (used by MetadataTable::is32Bit()) is preserved through zeroedMalloc in finalize/link and the full-offsetTableSize memcpy in unlink.

Level of scrutiny

High. This is core bytecode infrastructure on the execution path of every JS function, and the change replaces a "buffer never moves after finalize" invariant with one where m_rawBuffer is freed and reallocated on both link() and owning-unlink(). The class is ThreadSafeRefCounted and sizeInBytesForGC(MetadataTable&) is called from GC visitation; its existing comment about m_rawBuffer being realloced concurrently now applies to more transitions. The PR description addresses this (pointer comparison only), and I agree, but the concurrency and lifetime reasoning here is subtle enough that it should not be merged on an automated review alone.

Other factors

The author reports JSTests/stress (default + disk-cache, 5,259 tests) passing identically to main, and a −42.6 MB RSS win on a 48k-function bytecode cache. The diff is small and focused, and I found no defects — deferring purely on criticality of the code path, not on any identified problem.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
dfd7895d autobuild-preview-pr-496-dfd7895d 2026-08-23 11:43:44 UTC

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Folded into #494.

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