Skip to content

src: compress embedded icu, builtins, and snapshot - #66186

Open
anonrig wants to merge 4 commits into
nodejs:mainfrom
anonrig:compress-embedded-blobs
Open

anonrig wants to merge 4 commits into
nodejs:mainfrom
anonrig:compress-embedded-blobs

Conversation

@anonrig

@anonrig anonrig commented Sep 21, 2026

Copy link
Copy Markdown
Member

Why

An arm64 macOS Release node from 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.

Before After
File size 146,822,344 (140 MB) 94,918,152 (91 MB)
__text 43,945,048 43,929,088
__const 43,107,560 40,141,432
__cstring 12,888,258 2,340,626
__LINKEDIT 44,061,896 5,685,248
ICU data 33,105,536 33,105,536
One-byte builtin sources 10,513,764 2,128,812
Snapshot + code caches 6,593,200 1,508,240

Before is the base arm64 macOS Release binary. After is this branch, measured with size -m on the same host. ICU is icudt78.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 -x and -S, which is where __LINKEDIT shrinks. It does not pass -dead_strip: that flag strips napi_* exports that addons resolve from the executable, and doc generation segfaulted in lightningcss. __text stays next to the base size for that reason.

A process that touches DateTimeFormat, NumberFormat, Collator, and DisplayNames for 15 locales had a 34 MB physical footprint. Ten of those processes in parallel stayed at 33–34 MB each. The executable __TEXT mapping was 26.1 MB resident, 0 dirty, SM=COW.

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>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/gyp
  • @nodejs/security-wg
  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Sep 21, 2026
Wrap lines past 80 columns and store the zstd frame size in uint64_t.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
@jasnell
jasnell requested a review from srl295 September 21, 2026 20:21

@jasnell jasnell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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:

  1. Reads the 8.8 MiB compressed frame.
  2. Heap allocates a 31.6 MiB anonymous buffer.
  3. Writes every page of that buffer during decompression.
  4. 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

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.08219% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.27%. Comparing base (67a4416) to head (465e71b).
⚠️ Report is 27 commits behind head on main.

Files with missing lines Patch % Lines
src/zstd_blob.cc 45.00% 6 Missing and 5 partials ⚠️
src/node_union_bytes.h 66.66% 2 Missing and 2 partials ⚠️
src/node_snapshotable.cc 97.56% 0 Missing and 1 partial ⚠️
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     
Files with missing lines Coverage Δ
src/node_snapshotable.cc 73.67% <97.56%> (-0.07%) ⬇️
src/node_union_bytes.h 82.60% <66.66%> (-17.40%) ⬇️
src/zstd_blob.cc 45.00% <45.00%> (ø)

... and 48 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@srl295

srl295 commented Sep 22, 2026

Copy link
Copy Markdown
Member

@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.
Try running 10 separately launched processes in parallel and compare the overall memory and cpu hit. Should be about 30M+ per, versus very flat otherwise.

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
@anonrig
anonrig requested a review from jasnell September 22, 2026 19:04
@anonrig

anonrig commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

@jasnell @srl295 yeah, you're right about ICU. I put the data file back in the binary (465e71b1dcd), so we map it again instead of inflating a private copy at startup. udata_setCommonData() is only used for small ICU. The tarball is already compressed, and I'm not going to add a side cache for the .dat or change anything in ICU itself.

Ran the 10-process check on an arm64 Release build, touching 15 locales (dates, numbers, collation, display names):

  • 1 process: 34 MB footprint. __TEXT is 26.1 MB resident, 0 dirty, SM=COW.
  • 10 at once: still 33–34 MB each, same mapping, still 0 dirty.

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 -dead_strip stripped napi_* symbols that only addons use (8afd2ef6352). I removed that flag. -x and -S are still on. Binary is 91 MB, and __text is basically back to the original size.

@srl295

srl295 commented Sep 22, 2026

Copy link
Copy Markdown
Member

@anonrig so this is no longer an ICU PR?

@anonrig

anonrig commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

@anonrig so this is no longer an ICU PR?

Yes. Although, I'm experimenting with compressing ICU data as well right now.

@anonrig

anonrig commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

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. --icu-data-dir still wins when you set it.

@srl295

srl295 commented Sep 22, 2026

Copy link
Copy Markdown
Member

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.

yes, but why?

If the temp dir isn't writable, we keep the private copy so ICU still starts. --icu-data-dir still wins when you set it.

@anonrig

anonrig commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

yes, but why?

I didn't get your question. It reduces the bundle size by 20mb

@anonrig

anonrig commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

Pulled the ICU compression back out of this PR. It stays file-mapped here.

The shared-cache version is #66211, stacked on this branch.

@srl295

srl295 commented Sep 22, 2026

Copy link
Copy Markdown
Member

thoughts:

  • this still doesn't make a lot of sense to me, with the downsides. maybe it could be a build option (as small-icu is) for packagers who want to go this way.
  • i think you should engage with upstream ICU. can make introductions.
  • fwiw i'm launching an ad-hoc group to discuss the problem of the impact on additional language support (as we onboard digitally disadvantaged languages) on installers, etc.
  • i was going to suggest pulling the ICU part out separately, which it looks like you have.

@akx

akx commented Sep 22, 2026

Copy link
Copy Markdown

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 jasnell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@srl295

srl295 commented Sep 22, 2026

Copy link
Copy Markdown
Member

Pulled the ICU compression back out of this PR. It stays file-mapped here.

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.

@srl295

srl295 commented Sep 22, 2026

Copy link
Copy Markdown
Member

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.

Agreed, they do not seem as connected.

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.

Is there a specific use case this was aimed at or just, as the prompt, "make node smaller"?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants