Skip to content

Commit 44634ed

Browse files
gmaclennanclaude
andauthored
build: compress V8 pointers in the lite flavor (#6)
lite now configures V8 with --experimental-enable-pointer-compression on both targets, so tagged pointers become 32-bit offsets from a per-isolate 4GB cage instead of full 64-bit addresses. The saving lands on the JS heap rather than on the binary, which is the budget that decides whether a mobile process stays resident. An addon that includes V8's headers directly has to be compiled with the matching V8_COMPRESS_POINTERS / V8_31BIT_SMIS_ON_64BIT_ARCH defines. N-API addons — the ABI libnode exports, and what the crc-native gate builds — are unaffected. full stays the upstream-standard binary every consumer gets by default; lite is the opt-in one whose consumers are trading compatibility for footprint. Android gates the flag on arm64/x64. Upstream's common.gypi already force-disables pointer compression for `target_arch in "arm ia32 mips mipsel"`, so an ungated flag would build the same uncompressed armeabi-v7a library — but that guard zeroes the gyp variable, not the copy configure wrote into config.gypi, so the 32-bit build would ship a process.config claiming compression it doesn't have. tools/test.py derives its $pointer_compression status variable from exactly that, and two parallel tests branch on it. iOS builds arm64 slices only, so it needs no gate. Both CI libnode cache keys already hash the file that changed (android_configure.py, tools/ios_framework_prepare.sh), so neither flavor can restore a pre-compression binary. docs/BUILDING.md and the native-modules FAQ entry carry the ABI consequence; expected-tree.txt moves with the patch. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9eec30e commit 44634ed

5 files changed

Lines changed: 102 additions & 13 deletions

File tree

‎docs/BUILDING.md‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,9 @@ target's build command.
132132
The build ships in two flavors (selected by `NODEJS_MOBILE_FLAVOR`, default
133133
`full`). **`full`** is the general-purpose binary all consumers get. **`lite`**
134134
is a smaller binary for consumers that don't need the full feature set, built by
135-
layering feature-drops on top of the full configure — so the full binary and its
136-
test gate are unchanged.
135+
layering feature-drops — and one V8 configuration change, [pointer
136+
compression](#pointer-compression-lite-only) — on top of the full configure, so
137+
the full binary and its test gate are unchanged.
137138

138139
What `lite` drops (all already-available upstream `configure` flags, so no extra
139140
patch-stack surface):
@@ -158,12 +159,65 @@ Measured shipping sizes (arm64, after symbol strip):
158159
both flavors; no
159160
`--v8-lite-mode` (Android keeps the JIT and V8's native WASM for undici).
160161

162+
Those figures were measured before pointer compression was turned on; it aims
163+
at the heap rather than at the binary, and the release CI re-measures the
164+
shipping artifacts on every release.
165+
161166
`build-id` (`-Wl,--build-id=sha1`) is emitted on the Android `libnode.so` in
162167
**both** flavors so crash reporters (e.g. Sentry) can symbolicate native
163168
crashes. The safeguard for `intl=none` is running your own application's test
164169
suite against the lite binary — it catches `Intl` breakage from future
165170
dependency changes.
166171

172+
### Pointer compression (lite only)
173+
174+
`lite` also configures V8 with `--experimental-enable-pointer-compression`.
175+
V8 then stores tagged pointers as 32-bit offsets from a per-isolate 4 GB
176+
*cage* base instead of as full 64-bit addresses, so every object field, array
177+
element and map slot on the JS heap halves. The lever here is **runtime memory,
178+
not file size**: V8's own measurements put the saving at roughly 40% of the JS
179+
heap ([v8.dev/blog/pointer-compression](https://v8.dev/blog/pointer-compression)),
180+
which on a phone is the margin between staying resident and being reaped by the
181+
low-memory killer (Android) or jetsam (iOS).
182+
183+
Why it is `lite`-only, when the flag is available to both flavors:
184+
185+
- **It changes the V8 ABI.** A native addon that includes V8's headers directly
186+
(`v8.h`, NAN, node-addon-api's V8 escape hatches) has to be compiled with the
187+
same defines as the library it loads into — `V8_COMPRESS_POINTERS` and
188+
`V8_31BIT_SMIS_ON_64BIT_ARCH`, the two the public V8 headers branch on.
189+
Mismatched, it reads object fields at the wrong offsets and corrupts the
190+
heap. The artifact ships headers without a `config.gypi`, so those
191+
defines have to be passed by hand; see the
192+
[FAQ](./FAQ.md#are-nodejs-native-modules-supported). **N-API
193+
addons are unaffected**: that ABI hides V8's object layout, it is what
194+
`libnode.so` exports, and it is what the addon gate (`crc-native`, see
195+
[TESTING.md](./TESTING.md#the-napi-addon-gate)) builds and loads. `full` is
196+
the binary every consumer gets by default, so it keeps the upstream-standard
197+
ABI; `lite` is opt-in, and its consumers are the ones trading compatibility
198+
for footprint.
199+
- **It caps the JS heap at 4 GB** and is still flagged `--experimental-` upstream
200+
— neither costs anything on a device whose whole budget is a fraction of that,
201+
but both are reasons not to impose it on the default binary.
202+
203+
**64-bit slices only.** Upstream's `common.gypi` force-disables pointer
204+
compression for `target_arch in "arm ia32 mips mipsel"`, so the Android
205+
`armeabi-v7a` slice cannot have it, and passing the flag there would build the
206+
same uncompressed library either way. `android_configure.py` gates it on
207+
`arm64`/`x64` anyway, because that upstream guard zeroes the gyp variable but
208+
not the copy `configure` already wrote into `config.gypi`: ungated, a 32-bit
209+
lite build would ship a `process.config` claiming compression it doesn't have —
210+
`tools/test.py` derives its `$pointer_compression` status variable from exactly
211+
that, and `test-max-old-space-size-percentage` and
212+
`test-experimental-shared-value-conveyor` branch on it. iOS needs no such gate —
213+
every slice it builds is arm64.
214+
215+
Each isolate (the main one, plus one per `Worker`) reserves its own cage, so
216+
the cost is 4 GB of *address space* per isolate — reserved, not committed, and
217+
harmless on a 64-bit address space. Node's shared-cage mode
218+
(`--experimental-pointer-compression-shared-cage`) is deliberately left off,
219+
matching upstream's default.
220+
167221

168222
---
169223

‎docs/FAQ.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,18 @@ plugins expose an app-data-dir call that does this for you.
132132

133133
Node native modules, which contain native code, are able to run on nodejs-mobile, as long as they can be cross-compiled for the target platform / CPU. The cross-compiling feature is integrated into the plugins and instructions can be found in the [nodejs-mobile-cordova](https://github.com/nodejs-mobile/nodejs-mobile-cordova#native-modules) or in the [nodejs-mobile-react-native](https://github.com/nodejs-mobile/nodejs-mobile-react-native#native-modules) README, but only Linux and MacOS development machines are currently supported. Modules that contain custom build steps and platform specific code may need workarounds/changes to get them to work. We've created a github repository so that the workarounds/changes can be discussed and shared: https://github.com/nodejs-mobile/nodejs-mobile-module-compat
134134

135+
One caveat if you ship the **lite** flavor: it is built with V8 pointer
136+
compression, which changes the V8 ABI (see ["Pointer compression" in
137+
BUILDING.md](./BUILDING.md#pointer-compression-lite-only) on the recipe
138+
branch). Modules written against **N-API** — the ABI `libnode` exports, and
139+
what most maintained modules use — are unaffected. A module that includes V8's
140+
headers directly (`v8.h`, NAN) has to be compiled with the same defines the
141+
library was: the shipped `include/node` headers carry no `config.gypi`, so add
142+
`-DV8_COMPRESS_POINTERS -DV8_31BIT_SMIS_ON_64BIT_ARCH` to that module's build
143+
for the lite flavor (and nothing extra for full). Compiled without them and
144+
loaded into lite, it reads object fields at the wrong offsets. The `full`
145+
flavor keeps the upstream-standard ABI.
146+
135147
## How can I improve Node.js load times?
136148

137149
Applications that contain a large number of files in the Node.js project can have their load times decreased by reducing the number of files. While installing npm modules, these can be installed with the `--production` flag, so that modules that are used for development only are not included in your project, e.g.: `npm install --production <module_name>`. Using tools that merge all nodejs project files into a bundle, such as [`noderify`](https://www.npmjs.com/package/noderify) or [`esbuild`](https://esbuild.github.io/) and using the bundle instead has been observed to improve load times in most situations.

‎expected-tree.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
1414
# run prepare.sh (or regenerate-patches.py, which prints the hash) and
1515
# update the value below in the same commit. If you forget, CI fails and
1616
# prints the hash it got.
17-
51d99404a1d3920f5a7750bb81a9f4b42119b50a
17+
ea05e80f5f91af8c949376ec0b066f4ea16b6e54

‎mobile-src/tools/ios_framework_prepare.sh‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,17 @@ V8_DISABLE_MAGLEV="--v8-disable-maglev"
4545
LITE_FLAGS=""
4646
if [ "$FLAVOR" = "lite" ]; then
4747
INTL="none"
48-
# lite additionally drops features size-constrained consumers don't need.
49-
LITE_FLAGS="--without-amaro --without-inspector --without-sqlite"
48+
# lite additionally drops features size-constrained consumers don't need, and
49+
# compresses V8 pointers to 32 bits inside a 4GB cage — the lever on the JS
50+
# heap rather than on the binary. No arch gate is needed here the way there is
51+
# in android_configure.py: every slice this script builds is arm64, and
52+
# upstream only force-disables pointer compression for 32-bit target_arch.
53+
# It is lite-only because it changes the V8 ABI — an addon that includes V8
54+
# headers directly must be compiled with the matching
55+
# V8_COMPRESS_POINTERS/V8_31BIT_SMIS_ON_64BIT_ARCH defines or it reads object
56+
# fields at the wrong offsets. N-API addons are unaffected (that ABI hides V8
57+
# layout). See "The lite variant" in docs/BUILDING.md on the recipe branch.
58+
LITE_FLAGS="--without-amaro --without-inspector --without-sqlite --experimental-enable-pointer-compression"
5059
fi
5160

5261
declare -a outputs_common=(

‎patches/build-Android-iOS-dest-os-and-toolchain-configure.py.patch‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ Co-authored-by: Andre Staltz <andre@staltz.com>
2323
Co-authored-by: Julia Samól <j.samol@papers.ch>
2424
Co-authored-by: Vincent Breitmoser <look@my.amazin.horse>
2525
---
26-
android_configure.py | 58 ++++++++++++++++++++++++++++++++------------
27-
configure.py | 8 ++++++
28-
2 files changed, 51 insertions(+), 15 deletions(-)
26+
android_configure.py | 72 +++++++++++++++++++++++++++++++++++---------
27+
configure.py | 8 +++++
28+
2 files changed, 65 insertions(+), 15 deletions(-)
2929

3030
diff --git a/android_configure.py b/android_configure.py
31-
index 5cea0393f4..ec76c900ca 100644
31+
index 5cea0393f4..dfec53e67e 100644
3232
--- a/android_configure.py
3333
+++ b/android_configure.py
3434
@@ -1,25 +1,14 @@
@@ -68,7 +68,7 @@ index 5cea0393f4..ec76c900ca 100644
6868
toolchain_path = android_ndk_path + "/toolchains/llvm/prebuilt/darwin-x86_64"
6969

7070
elif platform.system() == "Linux":
71-
@@ -65,13 +54,52 @@ elif platform.system() == "Linux":
71+
@@ -65,13 +54,66 @@ elif platform.system() == "Linux":
7272
os.environ['PATH'] += os.pathsep + toolchain_path + "/bin"
7373
os.environ['CC'] = toolchain_path + "/bin/" + TOOLCHAIN_PREFIX + android_sdk_version + "-" + "clang"
7474
os.environ['CXX'] = toolchain_path + "/bin/" + TOOLCHAIN_PREFIX + android_sdk_version + "-" + "clang++"
@@ -102,9 +102,9 @@ index 5cea0393f4..ec76c900ca 100644
102102
+# lite — size-reduced subtractions (see "The lite variant" in
103103
+# docs/BUILDING.md on the recipe branch).
104104
+# Set via env NODEJS_MOBILE_FLAVOR=lite. Android lite keeps the JIT and V8's
105-
+# native WASM engine (undici uses them); it only drops features that
106-
+# size-constrained consumers don't need. Dead-code stripping applies to both
107-
+# flavors (common.gypi).
105+
+# native WASM engine (undici uses them); it drops features that
106+
+# size-constrained consumers don't need and compresses V8 pointers (below).
107+
+# Dead-code stripping applies to both flavors (common.gypi).
108108
+flavor = os.environ.get("NODEJS_MOBILE_FLAVOR", "full").strip().lower()
109109
+if flavor not in ("full", "lite"):
110110
+ print("\033[91mError: \033[0m" + "NODEJS_MOBILE_FLAVOR must be 'full' or 'lite'")
@@ -116,6 +116,20 @@ index 5cea0393f4..ec76c900ca 100644
116116
+if flavor == "lite":
117117
+ intl = "none"
118118
+ extra_flags = " --without-amaro --without-inspector --without-sqlite"
119+
+ # Pointer compression: 32-bit compressed pointers inside a 4GB cage — the
120+
+ # size lever on the JS heap rather than on the binary. lite only, because it
121+
+ # changes the V8 ABI for addons that include V8's headers directly (N-API
122+
+ # addons are unaffected). See "The lite variant" in docs/BUILDING.md on the
123+
+ # recipe branch.
124+
+ #
125+
+ # Gated to 64-bit rather than left to upstream's common.gypi, which already
126+
+ # force-disables it for `target_arch in "arm ia32 mips mipsel"`: that guard
127+
+ # zeroes the gyp variable but not the one configure wrote into config.gypi,
128+
+ # so an ungated flag would leave process.config claiming compression on an
129+
+ # uncompressed armeabi-v7a build (tools/test.py derives $pointer_compression
130+
+ # from it, and two parallel tests branch on it).
131+
+ if arch in ("arm64", "x64"):
132+
+ extra_flags += " --experimental-enable-pointer-compression"
119133
+
120134
os.environ['GYP_DEFINES'] = GYP_DEFINES
121135

0 commit comments

Comments
 (0)