Parser memory reduction - #1122
Merged
Merged
Conversation
Add generated workloads for large array literals, declarations, references, closures, and labels. These tests are intended to expose catastrophic parser scaling and compare compilation changes; source construction and Function() compilation are both included in the measured interval.
Large literals switch to slow arrays above the fast-array capacity limit, but the previous initialization path left their indexed values unset. Initialize slow arrays directly so literals above 32760 elements preserve their contents.
The generic object-property lowering used six parser nodes and several generator continuations for each array element. Store array values and indexes in compact records and emit a dedicated immediate-index initializer instead. This also avoids interning numeric element indexes and generates elements in source order with constant continuation depth. Keep template backing arrays flat because template concatenation requires contiguous storage.
Store the atom identifier and resolved variable directly in parser nodes and move the undefined-reference flag into the common node flags. This removes the separately allocated reference object and helps reduce parser nodes to the 64-byte memory-pool class.
Create a pool for each parser invocation and allocate parser nodes, array-item storage, continuations, preread tokens, and active labels from it. Destroy the pool after generation and on parser or generator failures. Keep atoms, lambdas, regexp patterns, scopes, variables, bytecode, and runtime constants in the VM pool. Clear persistent scope AST roots before releasing the temporary pool.
Use the parser invocation pool for generator stack entries, copied contexts, blocks, jump patches, index caches, and traversal scratch. Nested function generators share the same temporary pool, while generated code and runtime metadata remain in the VM pool.
Allocate non-global scopes, local variables, variable tree nodes, references, and declaration arrays from the compilation pool. The global scope and its symbol table remain persistent for runtime lookup and later compilation. Build closure-index arrays temporarily, then copy the finalized indexes into VM-owned storage referenced by each lambda. Preserve declaration-origin type without retaining pointers to released block scopes.
Module compilation publishes a provisional module before parsing to support recursive loading. Remove that module on failure and release code records, line maps, and code buffers produced after the compilation checkpoint so a failed load does not poison subsequent lookups.
Value-preservation analysis recursively examines the upcoming expression before the generator decides whether mutable-slot operands need temporary copies. Deeply nested binary expressions and arrays could therefore exhaust the native stack. Move the generator-specific predicate out of the parser API and limit its recursive walk to 128 levels. At the limit, conservatively require preservation: an extra move is safe, whereas missing a deeper side effect can change evaluation semantics.
VadimZhestikov
approved these changes
Sep 10, 2026
VadimZhestikov
left a comment
Contributor
There was a problem hiding this comment.
Looks good
Notes (non-blocking)
- The two bug fixes (large-array data loss, stack-exhaustion DoS) deserve CHANGES entries; the DoS is remotely
triggerable via crafted source and is a backport candidate. - I verified the pool-lifetime and module-cleanup logic by reading plus the ASAN suite; the failure paths for module
cleanup aren't exercised by a specific unit test I could see (they'd need a module that fails to compile after
provisional publish), but the logic is straightforward and defensive. - The stress benchmarks (commit db592c9) are infrastructure to measure the memory/scaling win — I didn't benchmark,
as it's not a correctness concern.
Contributor
Author
JS code is trusted according to NJS thread model. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This series substantially reduces memory consumption and compilation time in the built-in njs engine. It introduces a compact representation for array literals, reduces the size of common parser structures, and separates temporary parser and generator state from data that must remain alive after compilation.
Previously, each element in an array literal was lowered through the generic object-property path. A single element such as
xin[x, x, ...]required six parser nodes, several generator continuation entries, an interned numeric property key, and a generic property-initialization instruction. At one million elements, compilation retained more than 1 GB in the VM memory pool.Array elements are now represented by compact
{ value, index }records and generated sequentially using a dedicated immediate-index initialization instruction. This removes five parser nodes per element, avoids numeric index constants, keeps generator continuation depth constant, and reduces generated bytecode from approximately 32 MB to 24 MB.The series also fixes an existing correctness issue: array literals larger than 32,760 elements previously had the expected
lengthbut did not contain their initialized values after switching to the slow-array representation.Structure Changes
njs_parser_node_tnjs_variable_reference_tnjs_parser_scope_tnjs_variable_tActive labels are stored in a parser-wide indexed set instead of allocating complete variables and wrappers in every scope. Statement labels use atom identifiers instead of storing an
njs_str_tin every parser node.Compilation Lifetime
Each parser invocation now owns a temporary memory pool. It contains parser nodes, compact array-item storage, parser continuations, lexer preread tokens, active labels, generator stack entries and copied contexts, generator blocks and jump patches, traversal and index-cache scratch, non-global scopes, local variables and variable-tree nodes, reference maps, declaration arrays, and temporary closure-index builders.
The pool is destroyed after bytecode generation and on parser or generator failure in the main script, module, and runtime
Functioncompilation paths.The global scope and global variables, atoms and parser-created strings, function lambdas and regexp patterns, generated bytecode and line maps, runtime constants, module records, and finalized closure-index arrays remain persistent because runtime state references them.
Closure indexes are accumulated in temporary arrays and copied into exact-sized VM-owned storage before the compilation pool is destroyed. Persistent global variables retain declaration-origin type information without retaining pointers to released block scopes.
The series also adds local failure cleanup for AST serialization and provisional module compilation. Failed module compilation removes the provisional module and releases generated code records, line maps, and code buffers created after the compilation checkpoint. Top-level global compilation is intentionally not made transactional; callers should discard the VM after a failed top-level compilation.
Results
Median fresh-process measurements on AArch64:
test/buffer.t.jsRetained VM memory after compilation:
test/buffer.t.jsThe semantic-state lifetime changes provide additional retained-memory reductions relative to the initial node-only temporary pool:
Peak RSS changes little during the semantic-state stage because scopes, variables, references, and AST nodes are all needed until generation completes. The primary benefit is reducing memory retained by long-lived VMs.
NGINX Master and Workers
A separate benchmark loaded a 6.89 MB module containing a one-million-element numeric array through
js_import, using the njs engine and four workers.The imported compilation state is initially almost entirely shared through copy-on-write. After all workers execute the module, each worker materializes private runtime array state, but the process group remains approximately 849 MiB smaller than the baseline. Memory remained stable after 100, 1,000, and 10,000 requests and after a subsequent idle period.
NGINX startup time, including configuration loading, import compilation, daemon startup, and worker creation, improved from approximately 4.43 seconds to 0.71 seconds.
Stress Benchmarks
The benchmark suite now includes focused generated workloads for one million array references, 100,000 sequential block declarations, 100,000 sequential labels, 100,000 unique declarations, 100,000 referenced declarations, 20,000 closures, and 10,000 nested labels.
These are scaling stress tests rather than isolated parser timings. Their measured interval includes source construction, runtime
Functioncompilation, VM cloning, and cleanup.Validation
MOVEcounts forbench4.js,raytrace.js,deltablue.js,navier-stokes.js,splay.js,richards.js, andcrypto.js. The depth guard therefore adds no bytecode overhead for these ordinary workloads.