How SMI Array Packing Optimizes JavaScript Memory
Modern JavaScript engines utilize Small Integer (SMI) packing to store whole numbers directly in memory without heap allocation overhead. In engines like Google’s V8, numbers are treated dynamically, but keeping arrays populated strictly with small integers allows the runtime to switch to optimized, unboxed storage representations. This article explains how the SMI representation works, how JavaScript engines organize packed arrays, and why this design significantly reduces memory consumption and improves CPU cache efficiency.
The Concept of Small Integers (SMIs)
In standard JavaScript, the language specification treats all numbers as double-precision 64-bit binary floating-point values (IEEE 754). Allocating 64 bits on the heap for every single number would lead to severe memory bloat and garbage collection pressure.
To prevent this, engines use a technique called pointer
tagging. In 64-bit architectures, memory addresses are
typically aligned to word boundaries, leaving the least significant bits
unused. The engine uses the lowest bit as a tag: * Pointers to
heap objects have their lowest bit set to 1. *
SMIs (Small Integers) have their lowest bit set to
0.
Because of this tag, an integer (typically signed 31-bit or 32-bit depending on the architecture) is stored directly inside the pointer field itself. It requires no heap allocation, no object header, and zero garbage collection overhead.
Array Element Kinds: PACKED_SMI_ELEMENTS
Engines track the contents of arrays through dynamic categories known
as Elements Kinds. When an array is initialized and
populated solely with small integers, the engine tags it internally as
PACKED_SMI_ELEMENTS.
const numbers = [1, 2, 3, 4, 5]; // PACKED_SMI_ELEMENTSIn this state: 1. Contiguous Storage: The engine
allocates a contiguous buffer in memory containing raw SMIs. 2.
No Object Wrappers: Unlike generic arrays that hold
references pointing to distinct HeapNumber objects on the
heap, the SMI array stores the values inline. 3. No Sentinel
Values: Because the array is “packed” (dense), every index from
0 to length - 1 contains a valid value,
avoiding overhead associated with missing keys or holes.
How Packed SMIs Save Memory
1. Elimination of Heap Allocations
If an array stored standard floating-point numbers outside the SMI
range or arbitrary objects, each unique number could require a
HeapNumber allocation. A HeapNumber includes a
map pointer, object flags, and the 64-bit float itself (consuming
roughly 16 to 24 bytes per number). Storing integers as SMIs eliminates
this heap wrapper entirely, reducing the footprint to just the size of
the pointer slot (4 to 8 bytes).
2. Elimination of Indirection
In a generic array (PACKED_ELEMENTS), the array buffer
contains pointers, each directing the runtime to a different location in
heap memory. With PACKED_SMI_ELEMENTS, the indirection
layer is gone. The value retrieved from the array slot is the
number, cutting the total memory footprint in half or better.
3. High CPU Cache Locality
Because SMIs are stored in a contiguous, flat block of memory, reading elements sequentially allows the CPU to fetch multiple values inside a single L1/L2 cache line. This reduces cache misses, which indirectly minimizes the memory bandwidth required during data processing loops.
The Cost of Element Transitions
Array optimizations are one-directional. If an array tagged as
PACKED_SMI_ELEMENTS receives a non-SMI value, the engine
must convert the underlying memory structure to a more generic
format:
- Adding a floating-point number (e.g.,
4.5) transitions the array toPACKED_DOUBLE_ELEMENTS. - Adding an object or string transitions the array to
PACKED_ELEMENTS. - Creating a gap (e.g.,
numbers[100] = 10) transitions the array to aHOLEYvariation.
Once an array transitions to a more generic type, it cannot
transition back to PACKED_SMI_ELEMENTS, permanently
increasing the memory allocation and GC overhead for that array
instance.
Best Practices for Memory-Efficient Arrays
- Initialize with fixed sizes or contiguous pushes: Avoid creating sparse arrays (holes) by initializing elements sequentially.
- Keep types homogeneous: Do not mix integers with
floats, strings, or
null/undefinedin performance-critical datasets. - Use TypedArrays for large datasets: If data exceeds
standard 31-bit/32-bit integer ranges or contains only floating-point
values, using explicit typed buffers like
Int32ArrayorFloat64Arrayguarantees flat, unboxed memory allocation without engine speculation.