Repository navigation
Commit 1f8cb8b
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: #597021 parent 225e8aa commit 1f8cb8b
3 files changed
Lines changed: 90 additions & 6 deletions
File tree
- packages/react-native
- ReactCommon/react/renderer/uimanager
- src/private/__tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
26 | 36 | | |
27 | 37 | | |
28 | 38 | | |
| |||
245 | 255 | | |
246 | 256 | | |
247 | 257 | | |
248 | | - | |
| 258 | + | |
249 | 259 | | |
250 | 260 | | |
251 | 261 | | |
| |||
254 | 264 | | |
255 | 265 | | |
256 | 266 | | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
257 | 274 | | |
258 | 275 | | |
259 | 276 | | |
| |||
339 | 356 | | |
340 | 357 | | |
341 | 358 | | |
342 | | - | |
| 359 | + | |
343 | 360 | | |
344 | 361 | | |
345 | 362 | | |
| |||
365 | 382 | | |
366 | 383 | | |
367 | 384 | | |
368 | | - | |
| 385 | + | |
369 | 386 | | |
370 | 387 | | |
371 | 388 | | |
| |||
393 | 410 | | |
394 | 411 | | |
395 | 412 | | |
396 | | - | |
| 413 | + | |
397 | 414 | | |
398 | 415 | | |
399 | 416 | | |
| |||
820 | 837 | | |
821 | 838 | | |
822 | 839 | | |
823 | | - | |
| 840 | + | |
824 | 841 | | |
825 | 842 | | |
826 | 843 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
0 commit comments