Skip to content

Commit 1f8cb8b

Browse files
authored
Report the native memory of created shadow nodes to the JS GC
## Summary: The JS object that `createNode` returns owns the new shadow node, so the shadow node (with its props, family and event emitter) is only freed when the GC collects that object. The GC doesn't know about this native memory: it only sees a small JS object. With Hermes, it can go a long time without a full collection, and the shadow nodes of trees that were already unmounted pile up in native memory. This change reports an estimate of that native memory with `setExternalMemoryPressure` when `createNode` returns, like the Blob module does for blob data (#44336). The GC then accounts for it and collects unmounted trees in time. The estimate is 2 KB per node. A `View` with a few style props takes about 2.4 KB on Android (shadow node, props, family and event emitter, measured with heapprofd), a bit less on iOS, which doesn't keep a `folly::dynamic` copy of the props. Only nodes from `createNode` are reported, not clones: that's where most of the memory is, and reporting clones too made no difference in the measurements below while it would make the GC run more often during frequent re-renders. JSC's `setExternalMemoryPressure` is a no-op, so this only changes behavior with Hermes. ## Changelog: [GENERAL] [FIXED] - With Hermes, the native memory of unmounted shadow trees is freed in time instead of piling up until the JS heap grows ## Test Plan: **Fantom test.** `ShadowNodeExternalMemory-itest.js` renders 101 Views, checks that Hermes' external memory (`HermesInternal.getInstrumentedStats().js_externalBytes`) grows by at least 101 × 2 KB, then unmounts the tree and checks it goes back to where it started after a GC. ``` yarn fantom packages/react-native/src/private/__tests__/ShadowNodeExternalMemory-itest.js ``` It passes with this change and fails without it (`Expected 0 to be greater than or equal to 206848`), with the Fantom tester rebuilt in each case. `yarn flow`, ESLint, Prettier and `node scripts/clang-format.js --check` pass on the changed files. **Android devices.** RNTester release build, a screen that mounts 1000 `View`s in a `ScrollView` and unmounts them again, 24 times. After each unmount, a Java GC is forced and the app's native heap is read from `dumpsys meminfo` with nothing mounted. The same APK runs both arms, with the change switched by a system property; on the Pixel 9a the runs alternate (without, with, with, without), on the Galaxy A16 there is one run per arm. | Native heap after unmounting (MB) | Without this change | With this change | |---|--:|--:| | Pixel 9a, after the last cycle (2 runs) | 56 / 57, still growing | **35 / 34** | | Pixel 9a, mean of cycles 13–24 | 50.8 / 50.4 | **39.7 / 39.2** | | Galaxy A16, after the last cycle | 73, still growing | **47** | | Galaxy A16, mean of cycles 13–24 | 67.3 | **46.8** | Without the change, native memory grows by about 1 MB per cycle on both phones. With it, Hermes runs a full collection after about 10 cycles and native memory then stays flat. Total memory (PSS) on the Pixel 9a, mean of cycles 13–24: 176.2 / 173.8 MB without, 164.2 / 161.1 MB with. Reporting clones as well gave the same result (38.8 / 39.2 MB). **Cost.** Galaxy A16, a benchmark inside the same RNTester build: 33 rounds that mount and unmount 1000 `View`s and then 1000 cards (about 10,000 nodes), the first 3 rounds discarded. Mount time is measured from `setState` to the layout effect of the commit, and Hermes' GC statistics are compared over the whole run. Two runs per arm: | | Without this change | With this change | |---|--:|--:| | Mount 1000 `View`s, median of 60 | 90.2 ms | 92.6 ms | | Mount 1000 cards, median of 60 | 1579 ms | 1535 ms | | GCs during the run | 259 / 260 | 496 / 496 | | Total GC time | 1.38 / 1.38 s | 1.50 / 1.47 s | | Longest GC | 10 / 10 ms | 8 / 8 ms | Mount time doesn't change measurably. Hermes collects about twice as often, but the collections are smaller: total GC time grows by about 8%, and the longest one doesn't get longer. JS allocation grows by 1.2%, from the internal property Hermes adds to each node's object. With the 1000 cards mounted, the same JS allocation work took as long and ran as many GCs as with nothing mounted, in both arms, so the memory reported for a large mounted tree doesn't make collections more frequent. **iPhone.** iPhone 15 Pro (iOS 26.6), RNTester release build, the same screen and 24 cycles. The app's memory footprint (`phys_footprint`) is read every second and taken 7.5 s after each unmount, with nothing mounted. The same build runs both arms, with the change switched by a launch argument; the runs alternate (without, with, with, without). | Memory footprint after unmounting (MB) | Without this change | With this change | |---|--:|--:| | After the last cycle (2 runs) | 55.8 / 50.0 | **44.2 / 41.9** | | Highest | 70.3 / 65.9 | **58.5 / 58.5** | | Mean of cycles 13–24 | 58.5 / 51.0 | **47.3 / 44.1** | Without the change, the footprint grows by about 3 MB per cycle until Hermes runs a full collection on its own after 9 to 13 cycles. With it, the collection comes after about 6 cycles, and the footprint then goes down to 42–44 MB. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Differential Revision: D129160732 Pull Request resolved: #59702
1 parent 225e8aa commit 1f8cb8b

3 files changed

Lines changed: 90 additions & 6 deletions

File tree

‎packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@
2323

2424
namespace facebook::react {
2525

26+
namespace {
27+
28+
// Native memory that the JS object returned by `createNode` keeps alive: the
29+
// shadow node with its props, family and event emitter. A View with a few
30+
// style props takes about 2.4 KB on Android (measured with heapprofd), a bit
31+
// less on iOS, which doesn't keep the props as `folly::dynamic`.
32+
constexpr size_t kCreatedShadowNodeNativeMemory = 2048;
33+
34+
} // namespace
35+
2636
void UIManagerBinding::createAndInstallIfNeeded(
2737
jsi::Runtime& runtime,
2838
const std::shared_ptr<UIManager>& uiManager) {
@@ -245,7 +255,7 @@ jsi::Value UIManagerBinding::get(
245255
return jsi::Value::undefined();
246256
}
247257

248-
return valueFromShadowNode(
258+
auto node = objectFromShadowNode(
249259
runtime,
250260
uiManager->createNode(
251261
tagFromValue(arguments[0]),
@@ -254,6 +264,13 @@ jsi::Value UIManagerBinding::get(
254264
RawProps(runtime, arguments[3]),
255265
std::move(instanceHandle)),
256266
true);
267+
// The shadow node is freed only when the GC collects this object,
268+
// so tell the GC about the native memory it keeps alive. Without
269+
// this, unmounted shadow trees can pile up until the JS heap
270+
// itself grows enough for a full collection.
271+
node.setExternalMemoryPressure(
272+
runtime, kCreatedShadowNodeNativeMemory);
273+
return node;
257274
} catch (const std::logic_error& ex) {
258275
LOG(FATAL) << "logic_error in createNode: " << ex.what();
259276
}
@@ -339,7 +356,7 @@ jsi::Value UIManagerBinding::get(
339356
// rolled out
340357
// validateArgumentCount(runtime, methodName, paramCount, count);
341358

342-
return valueFromShadowNode(
359+
return objectFromShadowNode(
343360
runtime,
344361
uiManager->cloneNode(
345362
*Bridging<std::shared_ptr<const ShadowNode>>::fromJs(
@@ -365,7 +382,7 @@ jsi::Value UIManagerBinding::get(
365382
size_t count) -> jsi::Value {
366383
validateArgumentCount(runtime, methodName, paramCount, count);
367384

368-
return valueFromShadowNode(
385+
return objectFromShadowNode(
369386
runtime,
370387
uiManager->cloneNode(
371388
*Bridging<std::shared_ptr<const ShadowNode>>::fromJs(
@@ -393,7 +410,7 @@ jsi::Value UIManagerBinding::get(
393410
// validateArgumentCount(runtime, methodName, paramCount, count);
394411

395412
bool hasChildrenArg = count == 3;
396-
return valueFromShadowNode(
413+
return objectFromShadowNode(
397414
runtime,
398415
uiManager->cloneNode(
399416
*Bridging<std::shared_ptr<const ShadowNode>>::fromJs(
@@ -820,7 +837,7 @@ jsi::Value UIManagerBinding::get(
820837
return jsi::Value::null();
821838
}
822839

823-
return valueFromShadowNode(runtime, shadowNode);
840+
return objectFromShadowNode(runtime, shadowNode);
824841
});
825842
}
826843

‎packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ struct ShadowNodeListWrapper : public jsi::NativeState {
4343
std::shared_ptr<std::vector<std::shared_ptr<const ShadowNode>>> shadowNodeList;
4444
};
4545

46-
inline static jsi::Value valueFromShadowNode(
46+
inline static jsi::Object objectFromShadowNode(
4747
jsi::Runtime &runtime,
4848
std::shared_ptr<const ShadowNode> shadowNode,
4949
bool assignRuntimeShadowNodeReference = false)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @fantom_mode *
8+
* @fantom_disable_coverage
9+
* @flow strict-local
10+
* @format
11+
*/
12+
13+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
14+
15+
import * as Fantom from '@react-native/fantom';
16+
import nullthrows from 'nullthrows';
17+
import * as React from 'react';
18+
import {View} from 'react-native';
19+
20+
// Must match `kCreatedShadowNodeNativeMemory` in UIManagerBinding.cpp.
21+
const CREATED_SHADOW_NODE_NATIVE_MEMORY = 2048;
22+
23+
function getExternalMemoryAfterGC(): number {
24+
global.gc();
25+
const stats = nullthrows(
26+
nullthrows(global.HermesInternal).getInstrumentedStats,
27+
)();
28+
const externalBytes = stats.js_externalBytes;
29+
if (typeof externalBytes !== 'number') {
30+
throw new Error('Expected js_externalBytes to be a number');
31+
}
32+
return externalBytes;
33+
}
34+
35+
describe('shadow node external memory', () => {
36+
it('reports the native memory of created shadow nodes to the GC until they are collected', () => {
37+
const before = getExternalMemoryAfterGC();
38+
39+
let root: ?Fantom.Root = Fantom.createRoot();
40+
Fantom.runTask(() => {
41+
nullthrows(root).render(
42+
<View>
43+
{Array.from({length: 100}, (_, i) => (
44+
<View key={i} />
45+
))}
46+
</View>,
47+
);
48+
});
49+
50+
// The 100 children and their parent are alive, so their native memory
51+
// stays reported after a GC.
52+
expect(getExternalMemoryAfterGC() - before).toBeGreaterThanOrEqual(
53+
101 * CREATED_SHADOW_NODE_NATIVE_MEMORY,
54+
);
55+
56+
Fantom.runTask(() => {
57+
nullthrows(root).destroy();
58+
});
59+
root = null;
60+
61+
// Once the tree is unmounted and collected, its memory isn't reported
62+
// anymore.
63+
expect(getExternalMemoryAfterGC() - before).toBeLessThan(
64+
CREATED_SHADOW_NODE_NATIVE_MEMORY,
65+
);
66+
});
67+
});

0 commit comments

Comments
 (0)