Memory: where VS Code's memory goes
Generated from docs/perf/memory-vscode.md when the site was built at 7aa23e2.
VS Code for the web 1.145.2 (app.html in the standalone build: the
workbench with its builtin extensions, no test driver) idles at the Welcome
page in about 370 MB headless, 600 MB in niso-iced and 640 MB in the SwiftUI
host, against about 550 MB for Chrome across its nine processes. Measured on
beako (Mac mini M4 Pro, macOS 27) on 2026-11-09, release builds of main
725ab09 plus the instrumentation on this branch.
Most of it is not the DOM. The page has 754 nodes, and blitz, taffy, parley and fontique hold under 6 MB together. QuickJS holds 165 to 190 MB for the window alone, malloc keeps another 60 to 150 MB of freed memory dirty after the load, and the iced host’s graphics are 50 to 225 MB.
The causes, ranked
Section titled “The causes, ranked”Idle at the Welcome page unless noted; “headless” is niso-run, “iced” is niso-iced at 1280x800. MB is 2^20 bytes throughout, as footprint, vmmap and malloc_history print it. Savings are measured where the table says so, and estimated from the measured parts otherwise.
| # | Cause | MB | Fix | Saving | Size |
|---|---|---|---|---|---|
| 1 | QuickJS’s main-thread heap beyond what V8 needs: 165 MB counted after a full GC against Chrome’s 52 MB V8 heap (rows 2 and 5 are parts of it; row 4 adds to it under load) | 165-190 | rows 2, 4, 5 | ||
| 2 | QuickJS keeps a copy of every function’s source, and a nested function’s text is inside each enclosing copy | 39.5 | Drop function source behind a runtime flag (Function.prototype.toString then gives a placeholder); or keep one source per script and offsets in functions |
measured: -43 MB counted heap, but code that rebuilds a function from its text breaks (VS Code’s perfBaseline worker; see the follow-up); one shared copy instead: about -16 MB | flag: ~20 lines in QuickJS; shared source: ~200 |
| 3 | Freed load-time buffers left dirty: three copies of the 20.7 MB workbench bundle (niso-run’s loader, Net::load_local, Dom::out_bytes) plus 11 MB of parser buffers, held as “Malloc Large (empty)” |
62-73 headless, ~21 iced | Hand the loaded bytes to QuickJS without copying (one owned, NUL-terminated buffer from the loader to JS_Eval), or map files instead of reading them |
40-60 | ~100 lines in niso-core’s net.rs, ffi.rs and loading.rs (owned by the architecture agents) |
| 4 | QuickJS’s GC threshold: a cycle collection runs only when the heap reaches 1.5x what survived the last one, so under editing the window’s heap swings between 190 and 380 MB and malloc keeps the high-water mark | 60-100 under load | Collect earlier: threshold at 1.125x, or a GC when the event loop goes idle | measured with QuickJS’s own factor patched to 1.125 (a measurement build of 725ab09, not kept): -82 MB footprint after 17 min of the driver (425 vs 507 MB); driver cycles about 13% slower | 10 lines in qe_quickjs.c |
| 5 | Never-called functions compiled eagerly: 101k of 119k functions (85%) were never called, and their headers, bytecode, variable tables and constants take 32 MB (and 30 MB of the source in row 2) | 32 | Lazy compilation (parse to a skeleton, compile on first call); a smaller step: shrink JSFunctionBytecode (128 B x 119k = 15 MB) |
25-32 | lazy: weeks, a QuickJS parser change; header: medium |
| 6 | Malloc fragmentation after the load peak: 78 MB free in the small zone (27%), freed after the load’s peak | 78 headless | Lower the peak (rows 2 and 3 do most of it); the QuickJS arena is not the cause (measured: system malloc for every block is 12 MB worse) | follows rows 2-3 | |
| 7 | iced: GPU objects of 4 MB the app’s Metal device did not create: 4 to 46 of them, 24 in the SwiftUI host | 0-185 | Unattributed; see “Hosts”. First experiment: draw only when the render stream changes instead of every vsync | unknown | small |
| 8 | Each worker: its own runtime and glue (4.3 MB before its script), its script’s heap, arena and malloc overhead, its share of wasm and a font collection | ~21 per worker (64 for the three live at idle in iced) | Precompile the glue to bytecode without source; skip fontique’s system scan in worker Doms | 1-2 per worker; the rest is the worker’s own script and state | small each |
| 9 | iced: wgpu’s own resources: MSAA target 16.6 + 4.3 MB, image atlas 16.9 MB, per-layer quad buffers 22 x 0.95 MB, per-layer image buffers 36 x 0.26 MB, drawables 8.3 MB | ~85 | Turn MSAA off (.antialiasing(false); iced turns it on by default) if the borders and paths iced’s triangle pipeline draws still look right; fewer iced layers, or smaller initial per-layer buffers |
21 (MSAA), 15-25 (layers) | MSAA: one line; layers: medium |
| 10 | Stylo for the 1.8 MB workbench stylesheet: declarations 6.8, selectors 5.3, invalidation map 3.7, rule parser 2.6, custom properties 2.1 MB | 26 | None cheap (upstream stylo); per-element computed values are under 1 MB | ||
| 11 | Wrappers kept alive by listener cycles (docs/listener-cycles.md): IndexedDB requests and transactions, MutationObservers, terminated Workers | grows ~1 MB per 10 driver cycles | The redesign in docs/listener-cycles.md; until then, drop the core’s listeners of an IDBRequest after its final event, of an IDBTransaction after complete/abort, of a disconnected MutationObserver with no records, and of a terminated Worker | the growth in row 11 | medium |
| 12 | iced: Fonts::register copied every font the render stream registers (data.to_vec()), though the core holds the same bytes mapped. Fixed in the follow-up: the host keeps the core’s Blob |
13.4 | Keep a reference to the core’s font data instead of a copy | 13 | small in the host, if the render stream can hand out a lasting reference |
| 13 | SwiftUI host: SwiftUI, AttributeGraph and Swift collections for one view per render node | 58 malloc + 37 untagged | Flatten static subtrees into one drawing view | large |
Rows 2, 4 and 5 overlap row 1. QuickJS’s heap is the largest single cost, but changing the engine alone does not shrink the process: niso-node on V8 peaks at 518-525 MB against niso-run’s 387 MB (measured separately on the same machine), because V8’s heap, its code space and Node’s own 40-50 MB replace QuickJS’s.
1. JS heap per runtime
Section titled “1. JS heap per runtime”How to read it
Section titled “How to read it”NISO_QJS_MEMORY=SECONDS (this branch, bindings/js/quickjs/qe_quickjs.c)
makes every engine, the window’s and each worker’s, run a full GC and print
JS_ComputeMemoryUsage as one line of key=value pairs at most every
SECONDS (at the end of a microtask checkpoint), after any other full GC (so
niso-run’s --time prints one at the end), and when the engine is freed. Each
line is labelled with the global’s name (VS Code names its workers) or its
location.
NISO_QJS_MEMORY=60 niso-run --page app.html --root . --url http://localhost/app.html --max-ms 30000 --timeniso-qjs-memory http://localhost/app.html gc t=... malloc_size=167283496 ... js_func_size=65482279 ...The window
Section titled “The window”After the workbench is up and a full GC (headless, three runs within 2 MB):
| Part | MB | Count |
|---|---|---|
Counted by QuickJS (malloc_size) |
167.3 | 1.44 M blocks |
Functions (js_func_size): headers 15.2, variable tables 6.9, closure variable tables 1.9, constant pools 2.0, source 39.5 |
65.5 | 119,122 |
| Bytecode | 13.2 | |
| pc2line (line and column tables) | 2.4 | 110,483 |
| Objects | 22.3 | 310,038 |
| Properties | 16.4 | 915,861 |
| Shapes | 3.7 | 18,793 |
| Atoms | 13.1 | 164,099 |
| Strings | 3.3 | 39,823 |
| Arrays (fast) | 32,056 with 201,332 elements | |
| Not itemized (8 B allocator header per block, Maps, closures’ var refs, async frames) | 27.4 |
The bundle the functions come from is 20.7 MB (21.7 million bytes, workbench.web.main.internal.js)
plus 1.6 MB of NLS messages. Chrome’s V8 heap for the same page and state is
52 MB used, 54 MB allocated (Runtime.getHeapUsage after
HeapProfiler.collectGarbage).
On top of what QuickJS counts, its small-block arenas (4 KB each, carved into
one size class) hold about 28 MB of free blocks after the GC: malloc_history
finds 142 MB of arenas and 51 MB of large blocks against 165 MB counted. A
build with the arena off (JS_ARENA_LARGE_BLOCKS_ONLY, every block from
the system allocator) idles at 380 MB against 368 MB with it, because
xzone malloc fragments more (105 MB free in the zone against 78), so the
arena stays.
Debug info and source
Section titled “Debug info and source”QuickJS-ng 0.18 has no way to strip either. For every function it keeps:
- The source text,
js_strndup’d from the script at parse time forFunction.prototype.toString. A function’s text contains every function nested in it, so the copies add up to 39.5 MB for 23.3 MB of scripts. Measured with a temporary patch that frees each function’s source after compiling (NISO_QJS_NOSOURCE, in the patch below): the counted heap goes from 165.5 to 122.5 MB after the same GC, and the run’s peak footprint from 378-388 MB (three runs of mem-breakdown’s build of 725ab09) to 362 MB (three of four runs; the fourth peaked at 304 MB). The workbench came up, but code that rebuilds a function from its text cannot work without it: VS Code’s perfBaseline worker builds its script fromfn.toString()and fails (the follow-up below). Bellard’s QuickJS hasJS_SetStripInfofor this; QuickJS-ng would need the same, or one refcounted source per script with offsets in each function, which keepstoStringand costs one copy (23.3 MB) instead of 39.5. - pc2line: 2.4 MB of line and column tables, for stack traces. Small.
- Names: the file name atom, and each local’s name in the variable table (6.9 MB), which closures need anyway.
Lazy compilation
Section titled “Lazy compilation”QuickJS compiles every function when it parses the script. A temporary patch
that marks a function’s bytecode when it is first called (niso_called, a
spare bit in JSFunctionBytecode, set in JS_CallInternal and
async_func_init) finds that after the workbench is up 101,023 of 119,122
functions were never called. They hold 62.1 MB: 30.0 MB of source, 9.5 MB
of bytecode, and 22.6 MB of headers, variable tables, closure tables,
constant pools and pc2line. A V8-style lazy compiler keeps one source and a
small preparsed record per function until its first call, so it would save
most of the 32 MB that is not source (and the source with row 2). The
measurement patch is in docs/perf/quickjs-measure.patch; apply it to
third_party/quickjs to repeat it.
Workers
Section titled “Workers”At idle in iced three workers run besides the window; headless only one starts, and it ends two seconds later (headless runs skip ahead in time when idle, so VS Code’s idle timers stop its editor worker):
| Runtime | malloc_size at start |
Notes |
|---|---|---|
| A runtime with only the glue (about:blank, before any page script) | 4.3 MB | 7.3k atoms, 7.1k objects, 3.8k functions (1.8 MB with their source) |
| Worker 1 | 10.0 MB | atoms 3.4 MB |
| Worker 2 | 10.9 MB | atoms 3.5 MB |
| Worker 3 | 9.2 MB | atoms 3.3 MB |
| The short-lived worker | 4.2 MB | freed after 2 s |
Each worker’s atom table is seven times the glue’s, from something its script or its first messages bring in; that was not traced further.
2. What a worker costs
Section titled “2. What a worker costs”Measured as a whole, iced idling at the Welcome page for 150 s with workers
and with Worker deleted before the workbench starts (VS Code then runs
the worker code on the main thread):
| Footprint | Malloc small | Malloc large | |
|---|---|---|---|
| With workers (three live) | 659 MB | 323 MB | 61 MB |
delete globalThis.Worker |
595 MB | 279 MB | 41 MB |
So the three live workers (TextMateWorker, editorWorkerService and LanguageDetectionWorker, by NISO_QJS_MEMORY’s labels at 150 s) cost about 64 MB, 21 MB each. From the allocation stacks of the same idle state (malloc_history, the worker threads’ stacks):
- QuickJS in the three worker threads: 47.5 MB, against 30 MB counted. A worker’s arenas and malloc overhead are proportionally larger than the window’s.
- wasm: onig.wasm is instantiated twice, in the window and in the TextMate tokenization worker; each 32 MB linear memory is one zeroed allocation of which about 4.2 MB is touched (vmmap’s dirty size). So 8.5 MB dirty, not 67 MB.
- The workers’ Doms: 4.1 MB across the three workers’ threads, 1.6 MB of it fontique collections scanned from the system fonts. A worker has no stylesheet, so no stylo cost.
Under the driver (section 4), headless creates and terminates a worker per cycle; each is freed with 16.9 MB counted, and 436 came and went in a 29 minute run. The Worker objects of terminated workers stay held (section 4).
3. The core per document
Section titled “3. The core per document”754 nodes live after a full GC at idle headless (509 elements in iced; Chrome
counts 684 DOM nodes). From malloc_history’s live allocations
(docs/perf/malloc_owners.py --groups and --sites), iced at idle:
| Owner | MB | Per node | What |
|---|---|---|---|
| stylo (style, selectors) | 27.1 | the 1.8 MB stylesheet: selector lists 4.7, PropertyDeclaration blocks 4.1, invalidation map 3.7, rule parser 2.6, custom property values 1.9, selector map 0.8; cascade and computed values under 1 MB |
|
| blitz_dom, blitz_traits | 2.9 | ~3.8 KB | node slot map 0.43, text nodes 0.41, elements 0.23, attributes 0.17, taffy’s layout caches about 0.3 |
| fontique | 0.4 main, ~0.5 per worker | the system font collection scan, per Dom | |
| parley, harfrust, skrifa | 0.6 | shaping plans and buffers; text layouts are small for this page | |
| niso crates | 7.7 | niso-core 6.2 (net 1.4, worker setup 1.6), canvas 1.5 | |
| sqlite (IndexedDB) | 2.3 | ||
| Glyph rasters (iced’s swash bitmaps) | 0.14 | 47 text images, all small |
Mapped but clean, so not in the footprint: ICU’s data file, mapped by CoreText’s font fallback (36 MB), fonts mapped by fontique (20-28 MB) and FontParser (10-18 MB).
4. Growth over time
Section titled “4. Growth over time”tests/apps/vscode-web/memory-driver.js drives the workbench: every cycle opens
three untitled JavaScript files, types four functions into each, then
reverts and closes them. Runs of 10 to 40 minutes:
| Run | Start | End | Cycles | Rate |
|---|---|---|---|---|
| headless (niso-run) | 449 MB at 1 min | 524 MB at 26 min | 430 | 3.0 MB/min |
| iced | 772 MB at 2 min | 950 MB at 40 min | ~400 | 4.7 MB/min |
| headless, QuickJS’s GC factor patched to 1.125 (725ab09) | 402 MB at 1 min | 425 MB at 17 min | 210 | 1.4 MB/min |
iced, NISO_QJS_MEMORY=120 (a full GC every 2 min) |
741 MB at 2 min | 722 MB at 10 min | 50 | window heap after GC 181 to 191 MB |
| Chrome (page renderer) | V8 heap 67 MB | V8 heap 70 MB at 35 min |
The second iced run stopped cycling after 50 cycles, at blitz’s known
“invalid SlotMap key used” panic (layout/mod.rs:160, docs/compat/vscode-web.md),
so it only shows the start. The JS heap counted before collection swings
between 190 and 380 MB, because of the GC threshold (row 4).
After a full GC the headless window’s heap went from 167 MB idle to 205 MB after 430 cycles, and the handles JS holds from
1,179 to 12,290; nodes live from 754 to 1,011. NISO_JS_HELD=1 (this
branch, bindings/js/niso_js.c) prints the held handles by interface after
each full GC. After 165 cycles:
| Interface | Idle | After 165 cycles |
|---|---|---|
| IDBRequest | 207 | 1,365 |
| IDBTransaction | 104 | 1,216 |
| MutationObserver | 0 | 828 |
| EventTarget | 0 | 668 |
| HTMLDivElement | 315 | 468 |
| CSSStyleDeclaration | 195 | 288 |
| Worker | 0 | 168 |
So it is a leak, about 7 IndexedDB requests, 7 transactions, 5
MutationObservers, 4 EventTargets and one Worker per cycle, with their JS
objects (+38 MB after 430 cycles, about 90 KB a cycle). These are the objects
whose on* handlers and observer callbacks reach their own target, the
listener cycle docs/listener-cycles.md describes: the callback table is a GC
root, the core keeps the callback while the target lives, and the closure
keeps the target. Chrome’s heap grew 3 MB over the same driver. The rest of
the footprint’s growth is malloc keeping the higher water marks the 1.5x
threshold allows; with QuickJS’s factor patched to 1.125 the headless run
grew 1.4 MB a minute instead of 3.0.
The “nothing JS ever wrapped is freed” in docs/engines/porffor.md is Porffor’s limitation (it has no GC). QuickJS does free wrappers (the glue’s WeakRef and FinalizationRegistry, docs/engines/napi.md for Node’s), except for listener cycles and custom elements (docs/compat/vscode-web.md).
5. Hosts
Section titled “5. Hosts”iced: 50 to 225 MB of graphics
Section titled “iced: 50 to 225 MB of graphics”Metal’s own record of what the app allocated (xctrace’s Metal Resource Events, 150 s from launch) adds up to about 85 MB:
| Resource | Count | MB |
|---|---|---|
iced_wgpu::image texture atlas (2048x2048 RGBA) |
1 | 16.9 |
iced_wgpu::triangle::msaa attachment (4x) |
1 | 16.6 |
iced_wgpu.quad.gradient.buffer |
22 | 11.9 |
CAMetalLayer Display Drawable |
2 | 8.3 |
iced_wgpu.quad.solid.buffer |
22 | 6.1 |
iced_wgpu::image instance and uniform buffers |
72 | 9.4 |
iced_wgpu::triangle::msaa resolve target |
1 | 4.3 |
iced_wgpu::quad uniforms buffer |
22 | 2.9 |
| staging belts, glyphon (cryoglyph) atlas and buffers, triangle buffers | ~5 |
The per-layer buffers come from iced making one layer per clip: each layer gets quad buffers sized for 2,000 instances. The MSAA target is for iced’s triangle pipeline (paths and meshes); iced’s settings turn antialiasing on by default and niso-iced does not change it.
footprint also shows 4 to 46 GPU allocations of exactly 4 MB (256
pages) owned by the process but mapped in none of its address space, which
come and go over tens of seconds and are not in Metal’s record. Their size
does not follow the window (640x400 has 42 of them too), so they are not
drawables, and the SwiftUI host, which has no wgpu, has 24. Who creates them
needs footprint --unmapped, which needs root. niso-iced asks for a frame
every vsync (window::frames()), so an idle Welcome page costs 35% of a core
and redraws continuously; drawing only when the render stream changes is the
first experiment.
The 212 MB figure from the first measurement was one of the high samples.
iced: malloc
Section titled “iced: malloc”niso_iced::text::Fonts::register copied each font the render stream
registers into a Vec for swash (13.4 MB), though the core already holds
those bytes in fontique’s mapped files. Fixed (the follow-up’s “Fonts in
the iced host”). The text bitmaps themselves are small (47 images, 0.14 MB at idle).
SwiftUI host (VS Code (niso).app)
Section titled “SwiftUI host (VS Code (niso).app)”A prebuilt bundle from the swiftui-multiplatform work, dated 2026-11-09 22:05; its exact commit is not recorded, so these numbers predate that branch’s later changes.
At 90 s, 641 MB (705 MB with malloc stack logging’s own 64 MB):
| Part | MB |
|---|---|
| QuickJS (window 217 + workers 47) | 265 |
| Apple frameworks and Swift: AttributeGraph 12, SwiftUICore’s view lists, layouts and modifiers ~10, Swift dictionaries and sets 6, CoreFoundation, objc | 58 |
| Untagged (AttributeGraph’s tables, 88 MB reserved) | 37 |
| wasm | 45 allocated, ~8 dirty |
| stylo | 26 |
| Graphics: 24 of the 4 MB objects (96 MB), CALayer backing and IOSurfaces | 110 + 6 + 4 |
The 311 MB measured at 70 s was before VS Code’s workers and timers had run.
6. Chrome
Section titled “6. Chrome”Chrome 154.0.8037.58 with a fresh profile on the same page, idle at the
Welcome page after 70 s (footprint per process, SystemInfo.getProcessInfo for the
types, Runtime.getHeapUsage per target after a forced GC):
| Process | Footprint |
|---|---|
| browser | 117 MB |
| GPU | 54 MB |
| renderer, the page | 169 MB: V8 heap 52 MB used (54 MB allocated); 684 DOM nodes, 1,016 listeners |
| renderer, the extension host iframe (vscode-cdn.net, out of process) and its worker (2.1 MB heap) | 37 MB |
| renderers, spare and service | 52 + 26 MB |
| network, storage, passage embeddings services | 27 + 19 + 49 MB |
| Total | 550 MB |
Under the driver the page’s renderer went to 265-281 MB and the GPU process to 67-292 MB, while its V8 heap stayed at 67-72 MB.
The page’s renderer is the one to compare with niso: 169 MB holds what niso-run holds in 370 MB. V8’s heap is a third of QuickJS’s for the same JavaScript (rows 2 and 5: no per-function source, lazy compilation, and denser objects), and Chrome’s renderer has no copies of the bundle left over.
How to repeat this
Section titled “How to repeat this”All on macOS; Linux has heaptrack and DHAT instead of malloc_history.
cd vscode-web # the standalone build, as tests/apps/vscode-web/fetch.sh fetches it# QuickJS's own accounting, each engine:NISO_QJS_MEMORY=60 niso-run --page app.html --root . --url http://localhost/app.html --max-ms 30000 --time# Live allocations by owner: --hold keeps the process alive until stdin closes.MallocStackLogging=lite niso-run --page app.html --root . --url http://localhost/app.html --time --hold &malloc_history PID -allBySize > live.txtpython3 docs/perf/malloc_owners.py live.txt --groups # or --threads, --sites style,selectorsfootprint PID; vmmap -summary PID # dirty memory by category, malloc zone fragmentation# Held handles by interface after each full GC:NISO_JS_HELD=1 niso-run --page memory.html ... --time# The driver for growth runs:node tests/apps/vscode-web/make-index.mjs vscode-web tests/apps/vscode-web/memory-driver.js memory.html# GPU resources the app allocates:xctrace record --instrument "Metal Resource Events" --time-limit 150s --launch -- niso-iced --page app.html ...footprint’s categories include malloc stack logging’s own “Performance
Tool Data” (64 MB here); subtract it. malloc_history’s allocation sizes for
large zeroed blocks (wasm memories) are reserved sizes; vmmap gives their
dirty pages.
What the architecture agents would need to change
Section titled “What the architecture agents would need to change”- Load without copies (row 3):
Net::load_local(niso-core net.rs) copies the loader’s bytes, andDom::out_bytes(ffi.rs) copies them again into the C ABI buffer forniso_dom_load_script(loading.rs). An owned buffer handed through, NUL-terminated for QuickJS, removes two 20.7 MB copies of the workbench, and niso-run’s loader could map the file. - Listener cycles (row 11): the redesign in docs/listener-cycles.md, or, short of it, the core dropping the listeners of an object that can no longer fire (a finished IDBRequest, a finished IDBTransaction, a disconnected MutationObserver, a terminated Worker).
- Worker Doms (row 8): no fontique scan until a worker draws text (OffscreenCanvas).
Follow-up, 2026-11-10: what is not the engine, and VS Code on V8
Section titled “Follow-up, 2026-11-10: what is not the engine, and VS Code on V8”Branch mem-quickwins, on beako unless noted. The QuickJS-only experiments at the end were measured and then dropped, since VS Code moves to V8 (node-windows) and QuickJS stays as it is for small apps.
Malloc keeps what a load frees (macOS)
Section titled “Malloc keeps what a load frees (macOS)”macOS’s malloc keeps freed memory dirty: the three freed copies of the
bundle sit in “Malloc Large (empty)” (73 MB), and freed small blocks in
the small zone. malloc_zone_pressure_relief returns none of it
(niso-run --time now prints the footprint before and after calling it:
367.2 and 367.2 MB). Two settings libmalloc
reads at process start do return it:
| Headless, Welcome page (3 runs each, mem-quickwins’s build of 36ce7a6) | default | MallocLargeCache=0 |
MallocSpaceEfficient=1 |
|---|---|---|---|
| Idle footprint | 370-373 MB | 222-227 MB | 181-184 MB |
| Peak footprint | 370-373 MB | 266-267 MB | 244-245 MB |
| Growth driver, 60 cycles, footprint sampled each minute | 438-468 MB | 327-331 MB | 266-268 MB |
| Driver time, cycles 10 to 60 (the three ran together) | 156.2 s | 160.0 s (+2.4%) | 162.1 s (+3.8%) |
| VS Code in a niso window on V8 (node-windows), 70 s | 665 MB | 562 MB |
With MallocSpaceEfficient=1 vmmap shows 3 MB of Malloc Large against
77 MB, and 175 MB of Malloc Small dirty against 289 MB. Being read at
process start, they belong in the launch environment: LSEnvironment in a
macOS app’s Info.plist, or the environment the node running niso is
started with. iOS apps cannot set them; not tried there.
On Linux (yachiyo, glibc, niso-run at main 36ce7a6) the idle Welcome page is
280 MB RSS, and malloc_trim(0) (called from gdb at exit) takes anonymous
memory from 240.7 to 235-236 MB: glibc already unmaps large freed blocks.
So calling malloc_zone_pressure_relief or malloc_trim after a load or
under memory pressure is not worth wiring into the hosts: it returns
nothing on macOS and 5 MB on Linux. niso-run calls it only to measure.
What a worker costs besides its engine
Section titled “What a worker costs besides its engine”Trivial workers (a script that posts one message), kept alive:
| Per worker | Of which | |
|---|---|---|
| QuickJS (niso-run, 16 workers) | 7.1 MB footprint | engine 5.2 MB of malloc (4.2 counted by QuickJS: the glue); core 0.84 MB (fontique’s system font scan 0.53, CoreText under it 0.24, niso-core 0.02); stylo and blitz nearly nothing (no style sheet); stack 0.1; malloc slack about 1 |
| V8 (niso window, 8 workers) | 13.9 MB footprint | the worker’s isolate 10-14 MB physical (8.5-10.5 MB used: Node’s worker bootstrap and the glue); malloc 3.9 MB |
The core’s part per worker is under 1 MB, most of it the font scan, which worker Doms could skip until they draw text (architecture-owned). VS Code’s own workers cost 15-20 MB of V8 heap each at idle (21 MB each in QuickJS, section 2): their scripts, not niso.
Fonts in the iced host
Section titled “Fonts in the iced host”Fonts::register copied every font file the render stream named (13.4 MB
in niso-iced, 14.0 MB of niso_iced malloc in the node-windows window).
Dom::render_font_blob now hands the host the core’s own Blob, so the
host keeps a reference: niso_iced’s live malloc at idle is 1.4 MB. The
node-windows branch has the same copy (engine.rs and text.rs), which this
change replaces when the branches merge.
26.2 MB (QuickJS) and 27.5 MB (V8) for 16 style sheets with 11,910 rules and 2.33 MB of rule text, 10,029 of the rules in the workbench’s sheet: selectors 4.9 MB, declaration blocks 4.1 (6.8 in row 10, which adds the values parsed into them), the invalidation map 3.7, nested rule blocks 2.6, custom property values 2.1, hash tables 1.5, atoms 0.9, the selector map 0.8. The sheets are parsed once (there is no second copy for the CSSOM), about 2.2 KB a rule, as stylo keeps them; nothing to cut in niso.
VS Code on V8
Section titled “VS Code on V8”node-windows 3476f87, VS Code in a 1280x800 window, 70 s after start, one run at 665 MB (Subagent 108 measured 654-781 MB, median 729):
| Part | MB | What |
|---|---|---|
| V8 heaps | 219 | the window’s isolate 146.5 (144 used: the bundle’s source 41.4, code 40.8, other strings 13, objects, shapes, arrays and closures 47, plus onig’s 16 MB wasm memory outside the heap); three worker isolates 53 (15-20 each); the rest 20 |
| Graphics | 279 | GPU memory mapped nowhere in the process 217 (138 regions), IOAccelerator 50, IOSurface 12 |
| Malloc | 132 | 109 live: stylo 27.5, niso_iced 14.0 (the font copies, fixed above), niso-core, canvas and the rest of niso 12, text 4.7, blitz 4.6, Node, V8 and Apple’s frameworks about 43 (sqlite for IndexedDB 4.8, Metal 4.2, ArrayBuffers 3.9) |
| Page tables | 20 | |
| Node itself | 12-16 | a bare node, and with the addon loaded |
The bundle’s source is one 41.4 MB string: V8 keeps a script’s source two
bytes a character when any character is above U+00FF, and 3 of the
bundle’s 21.7 million are (。, ! and ।). Escaped as \u sequences, which
is what esbuild’s charset: 'ascii' writes, it would take 20.7 MB. That is
a build setting of the app, not niso’s.
The graphics are the same in niso-iced on QuickJS (215 MB unmapped in 126
regions at 60 s, and the same with --no-text), and 18 MB in 15 regions
for a page with one paragraph, so they follow the page’s content, not the
window size or the text images. GPU-private buffers and textures (iced’s
per-layer quad and image buffers, the atlas, MSAA) are what a process owns
without mapping; attributing them needs footprint --unmapped, which needs
root. They are the largest item here that is neither the engine nor the app.
QuickJS experiments, dropped
Section titled “QuickJS experiments, dropped”Measured before the change of direction, headless Welcome page, with a build of 36ce7a6 that had the experiments compiled in and off by default (367 MB idle and peak with all of them off):
- GC threshold (QuickJS’s 1.5x, set again after each GC from
qe_run_microtasks): idle 367-371 MB at 1.125x, 1.25x and 2.0x against 367 MB. This version never ran the growth driver; the driver run at 1.125x in row 4 and section 4 is the earlier one, with QuickJS itself patched. - Source stripped by writing compiled code without its source and
reading it back (QuickJS-ng has no strip option; only its writer drops
source): counted heap 164 to 122 MB, idle 250-256 MB against 367, peak
415-425 MB against 367. VS Code’s perfBaseline worker then fails: it
builds a worker from
fn.toString(). - Rewritten with source kept: idle 291-300 MB with a 493-503 MB peak when the copy is read while the compiled one is still alive; 359-367 MB when it is not. The gain was the freed compile’s memory going back, not the code.
- Lazy compilation: 101,023 of 119,122 functions were never called; they hold 32 MB besides 30 MB of their source. An engine change, for quickjs-ng upstream or with step 19’s rquickjs.