Skip to content

GC: Give back the YG room that a young external memory debit releases - #2263

Open
YevheniiKotyrlo wants to merge 1 commit into
meta:static_hfrom
YevheniiKotyrlo:fix/young-gen-debit-returns-room
Open

YevheniiKotyrlo wants to merge 1 commit into
meta:static_hfrom
YevheniiKotyrlo:fix/young-gen-debit-returns-room

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

HadesGC::debitExternalMemory lowers ygExternalBytes_ for a young cell and leaves the YG's effective end where creditExternalMemory put it (HadesGC.cpp):

    setYoungGenExternalBytes(oldYGExtBytes - sz);
    // Don't modify the effective end here. creditExternalMemory is in charge
    // of tracking this. We don't expect many debits to not be from finalizers
    // anyway.

So the room a credit took comes back only at the next YG collection, and memory a young cell releases while it is alive — a lowered jsi::Object::setExternalMemoryPressure, a detached ArrayBuffer, a shrunk OrderedHashMap — still brings that collection forward as if it were held. JSI hosts that release native memory eagerly meet this on every release: react-native-skia's dispose() lowers an object's pressure to 256 bytes (JsiSkNativeObjects.h), so an app that bakes each view's drop shadow into an offscreen surface and disposes the surface once it has the image keeps every texture's room taken until the next YG collection. React Native 0.86 ships this code as Hermes V1 (250829098.0.17, from 250829098.0.0-stable), and main has the same debit. Sixteen credits each released before the next allocation run 15 YG collections on static_h, and none with this PR (measurements).

The YG segment now records the room external memory has taken since its effective end was last set, its external memory charge, and a young debit caps that charge at the young external memory still credited, giving back the rest. A credit that found the segment full took less, so its debit gives back only what it took. Setting the effective end resets the charge, so the OG's request for a YG collection (creditExternalMemory setting the end to the level) still stands after a debit, and a finalizer's debit, which runs during a YG collection or finalizeAll, is overwritten by the end each of them sets afterwards, as before. With a charge of its own on the segment, clearExternalMemoryCharge becomes resetEffectiveEnd: it sets the end to the real end, lifting the size factor and the OG's request with the charge, as the comment in youngGenCollection already describes it. A young debit costs a min, an add and a store more; the allocation path and the JIT's offsets are untouched.

An old cell's debit is unchanged: it never moved the YG's end, and the OG's request, once made, stays until the YG collection it asks for. The checked-in sandbox picks this up at its next regeneration.

Test Plan

Linux x86-64 (WSL Ubuntu 22.04), GCC 13.3, Debug and ASan+Debug at -O1. The reproduction is the new JSI case, which starts from an empty YG and runs:

for (int i = 0; i < 16; ++i) {
  jsi::Object o{rt};
  o.setExternalMemoryPressure(rt, 16 * 1024 * 1024);
  o.setExternalMemoryPressure(rt, 0);
}

On static_h built with this PR's tests, the YG collects on every iteration after the first:

$ APITests --gtest_filter='*ExternalMemoryReleasedBeforeCollectionTest*'
unittests/API/APITest.cpp:1457: Failure
      Expected: youngCollections()
      Which is: 17
To be equal to: before
      Which is: 2
[  FAILED  ] Runtimes/HermesRuntimeTest.ExternalMemoryReleasedBeforeCollectionTest/0
[  FAILED  ] Runtimes/HermesRuntimeTest.ExternalMemoryReleasedBeforeCollectionTest/1

With this PR it does not collect, and the case passes on the plain and the thread-safe runtime; the sandbox reports no heap information, so its case returns early.

  • HermesVMRuntimeTests --gtest_filter='GCBasicsTestExternalMemory.*:AlignedHeapSegmentTest.*ExternalMemory*' — YoungGenDebitGivesBackRoom runs 15 YG collections on static_h and none with the fix; the other cases pin the charge and its cap in exact bytes, and each case fails against a copy of the fix broken the way it rules out (which).
  • HermesVMRuntimeTests, APITests and check-hermes pass, with no AddressSanitizer report; test262 at the revision build.yml pins passes on Linux (38,702) and on Windows with Visual Studio 2026, Debug and Release (37,030), none failing.
  • clang-format 20.1.8 leaves the changed files as they are.
  • No issue or PR tracks this: I searched issues for debitExternalMemory and external memory young generation collection, and PRs for debitExternalMemory effective end; OrderedHashMapBase::clear() strands external-memory credit; per-frame Set/Map clear()+refill OOMs long sessions #2111 is a different imbalance in the same accounting.
  • Not run here: the macOS, Linux arm64 and ARMv7 legs, and Windows arm64.

creditExternalMemory lowers the YG's effective end by the room a young
cell's credit takes, and debitExternalMemory left it there until the
next YG collection. Memory released from a young cell while it lives -
a lowered setExternalMemoryPressure, a detached ArrayBuffer, a shrunk
OrderedHashMap - therefore brought the next YG collection forward as if
it were still held.

The YG segment now records the room external memory has taken since its
effective end was last set, its external memory charge, and a young
debit caps that charge at the young external memory still credited,
giving back the rest. Setting the effective end resets the charge, so
the OG's request for a YG collection stands after a debit.
clearExternalMemoryCharge becomes resetEffectiveEnd, since it lifts
every limit on the end, not only the charge.
@meta-cla meta-cla Bot added the CLA Signed Do not delete this pull request or issue due to inactivity. label Oct 22, 2026
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

The measurements behind this PR, from runs of its head ea5b3fa89 and of static_h at 2b09a7fa8.

Base against head. Linux x86-64 (WSL Ubuntu 22.04), GCC 13.3, Debug with asserts. static_h is built with this PR's unittests/API/APITest.cpp and unittests/VMRuntime/GCBasicsTest.cpp and nothing else of it. Each case ran five times, each time in a fresh runtime (--gtest_repeat=5), and every run of a side gave the same count, so the spread is zero:

YG collections inside the case's window static_h this PR
HermesRuntimeTest.ExternalMemoryReleasedBeforeCollectionTest, plain and thread-safe runtime 15 0
GCBasicsTestExternalMemory.YoungGenDebitGivesBackRoom 15 0

The JSI case is the reproduction. It starts from an empty YG and runs this, each object's pressure released before the next object is made:

for (int i = 0; i < 16; ++i) {
  jsi::Object o{rt};
  o.setExternalMemoryPressure(rt, 16 * 1024 * 1024);
  o.setExternalMemoryPressure(rt, 0);
}

On static_h the YG collects on every iteration after the first, for memory no object holds; with this PR it does not collect.

What each case rules out. Each was run against a copy of the change broken one way, and fails there:

The change broken so that Fails
a young debit gives back nothing, as on static_h YoungGenDebitGivesBackRoom, the JSI case
a debit gives back the whole charge YoungGenDebitKeepsRoomOfRemainingCredit, CapExternalMemoryCharge, CapExternalMemoryChargeThatStoppedAtLevel
a debit gives back the charge only when no external memory remains CapExternalMemoryCharge
the cap sets the charge to the remaining total CapExternalMemoryChargeThatStoppedAtLevel
setEffectiveEnd keeps the charge SetEffectiveEndForgetsExternalMemoryCharge, YoungGenDebitKeepsOldGenRequest
a charge is measured from start() rather than the level the charge's assertion, and ChargeExternalMemoryStopsAtLevel without it

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 27, 2026 18:41

This branch was successfully deployed

1 active deployment
npm-publish — ea5b3fa8 Deployed Oct 22, 2026 by YevheniiKotyrlo via publish #1626
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed Do not delete this pull request or issue due to inactivity.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant