Conversation
Store the full ICU data file, one-byte builtin sources, and the V8 startup snapshot plus code caches as zstd frames instead of raw bytes. Decompress each once at startup and keep it for the process lifetime. On macOS Release, link the executable with -dead_strip, -x, and -S. A Release arm64 macOS binary dropped from 140 MB to 59 MB. Intl, builtins, the snapshot, and code cache behavior are unchanged. Startup RSS rose by about 40 MB because those blobs are private heap instead of file-backed pages. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
|
Review requested:
|
Wrap lines past 80 columns and store the zstd frame size in uint64_t. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
jasnell
left a comment
There was a problem hiding this comment.
Before this PR, the 33+ MiB ICU package is mapped directly from the executable. Mapping that does not make all of it resident. ICU accesses individual resources in place, so only the pages actually used are brought in. Those pages remain clean, reclaimable, and shareable between Node.js processes.
With this PR, however, default bundled-ICU startup:
- Reads the 8.8 MiB compressed frame.
- Heap allocates a 31.6 MiB anonymous buffer.
- Writes every page of that buffer during decompression.
- Retains that buffer for the process lifetime.
I had to dig back into the ICU details to make sure it still worked the way I remembered.
udata_setCommonData() does not copy or consume the package. It stores its address in a UDataMemory descriptor, and resource lookup returns pointers such as base + dataOffset into that storage. We cannot free the decompressed buffer after ICU initialization.
The resulting trade-off for ICU alone is approximately:
- 22.8 MiB less executable size.
- 31.6 MiB of private anonymous memory materialized per process.
- Up to another 8.8 MiB of clean compressed input resident around decompression, making the ICU startup working set approach 40.4 MiB.
For workloads using only a small part of ICU, in the current impl only a small part of the data is made resident. With this PR all of it is made resident. Under memory pressure, the old clean pages can simply be discarded and read from the executable again; the new anonymous pages must be compressed or swapped.
Given that, I'm generally -1 on this change.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #66186 +/- ##
==========================================
- Coverage 90.28% 90.27% -0.01%
==========================================
Files 790 791 +1
Lines 272044 272900 +856
Branches 51949 52128 +179
==========================================
+ Hits 245625 246374 +749
- Misses 16915 16974 +59
- Partials 9504 9552 +48
🚀 New features to boost your workflow:
|
|
@jasnell is right. Also, multiple processes (even multiple users) using the same data can no longer share those readonly mapped pages. ICU is designed very carefully to efficiently run against the mapped data (whether linked in or as a disk file) as a shareable page, which should only need to be seen ONCE for the whole machine, per page. If you're trying to reduce distribution disk footprint just make sure the distribution is compressed. Or uncompressed the .dat file once the first time node is used (or intl is used, though that would be a bigger hit). So I don't see the benefit. If anything you should consider discussing this upstream with ICU, not here in Node. |
-Wl,-dead_strip on the Release executable removed napi_* and other globals that nothing in the executable calls. Addons resolve those symbols from the host, so doc-kit's lightningcss addon called a null pointer inside napi_register_module_v1 and macOS CI died with SIGSEGV while generating docs. -x and -S stay; they only drop local and debug symbols. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com> Assisted-by: Grok
udata_setCommonData() stores the package address and later lookups return pointers into that storage, so the decompressed buffer cannot be freed. Inflating the full ICU data file at startup turned every page into private dirty memory. Map the original data from the executable again so unused pages stay demand-paged, clean, and shareable across processes. Builtin sources and the startup snapshot remain compressed. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com> Assisted-by: Grok
|
@jasnell @srl295 yeah, you're right about ICU. I put the data file back in the binary ( Ran the 10-process check on an arm64 Release build, touching 15 locales (dates, numbers, collation, display names):
So we're not paying that extra ~30 MB of private ICU memory per process. I don't have the compressed binary anymore, so I can't give you a CPU before/after, but that per-process inflate is gone. Adding up RSS looks worse than it is, because each process counts the shared pages. Builtin sources and the snapshot are still compressed. That showed up as about 16 MB of private malloc per process in the same run. Happy to drop those too if you want them treated the same way. One other fix, unrelated to ICU: macOS doc builds were segfaulting because |
|
@anonrig so this is no longer an ICU PR? |
Yes. Although, I'm experimenting with compressing ICU data as well right now. |
|
ICU is compressed again, but not as a private buffer. The binary holds a 9.2 MB frame (33,105,536 bytes down to 9,223,488). The first launch writes the plain data under the temp directory and maps that file read-only. The next process maps the same file, so those pages stay clean and get faulted only where ICU actually looks something up. On an arm64 Release build a cold start was a 32 MB footprint. The mapping was 31.6 MB virtual, about 3.7 MB resident, 0 dirty. A second process was 31 MB. The whole binary is 68 MB. If the temp dir isn't writable, we keep the private copy so ICU still starts. |
yes, but why?
|
I didn't get your question. It reduces the bundle size by 20mb |
8e075c9 to
465e71b
Compare
|
Pulled the ICU compression back out of this PR. It stays file-mapped here. The shared-cache version is #66211, stacked on this branch. |
|
thoughts:
|
|
I replied to your tweet on X, but maybe best put it here too: did you measure if this has a startup time or peak RSS impact? |
jasnell
left a comment
There was a problem hiding this comment.
I'm still -1 on the compression parts of this, independent of ICU.
The linker flag edits should be moved into a separate PR on their own and reviewed by @nodejs/build.
The compression bits cause the same kind of problem as the larger ICU objection. We're just size on disk for more memory where memory is often the scarcer resource.
I also think you've got an endianness bug in the js2c.cc change.
Can you rename the PR? This phrase "Full ICU data stays a file-mapped image so those pages remain clean, demand-paged, and shareable." is out of place in the description. |
Agreed, they do not seem as connected.
Is there a specific use case this was aimed at or just, as the prompt, "make node smaller"? |
Why
An arm64 macOS Release
nodefrom the base tree is 140 MB (146,822,344 bytes). One-byte builtin sources and the V8 startup snapshot are embedded as raw bytes, and the link keeps a large local-symbol and STABS table. Full ICU data stays a file-mapped image so those pages remain clean, demand-paged, and shareable.__text__const__cstring__LINKEDITBefore is the base arm64 macOS Release binary. After is this branch, measured with
size -mon the same host. ICU isicudt78.dat(33,105,536 bytes), linked in unchanged. Builtin sources and the snapshot are the raw payload and the stored zstd frame from this build (js2c: builtin sources 10513764 -> 2128812,snapshot: embedded blob 6593200 -> 1508240).What
One-byte builtin sources and the startup snapshot plus code caches are zstd frames, decompressed once and kept for the process lifetime. Full ICU data is linked in as before. Compressing it without giving up the shared pages is #66211.
On macOS Release the link passes
-xand-S, which is where__LINKEDITshrinks. It does not pass-dead_strip: that flag stripsnapi_*exports that addons resolve from the executable, and doc generation segfaulted inlightningcss.__textstays next to the base size for that reason.A process that touches
DateTimeFormat,NumberFormat,Collator, andDisplayNamesfor 15 locales had a 34 MB physical footprint. Ten of those processes in parallel stayed at 33–34 MB each. The executable__TEXTmapping was 26.1 MB resident, 0 dirty,SM=COW.