Inside V8's Elements Kinds Lattice: SMI, Double & Tagged Pointers
To execute array loops at near-native C++ speeds while preserving full ECMAScript semantics, V8 classifies the backing store of every JSArray along two orthogonal dimensions:
- Payload Representation (
SMIvsDOUBLEvs GenericELEMENTS):SMI_ELEMENTS(Small Integers): Stores 31-bit signed integers (on 64-bit V8 with pointer compression) directly inside the backing store as immediate tagged words (least significant bit0), requiring zero heap box allocations.DOUBLE_ELEMENTS(Unboxed IEEE-754float64): As soon as you push3.14orNaNinto anSMIarray, V8 reallocates the backing store into a contiguous C++double[]array (8 bytes per element) so TurboFan can emit raw SSE/AVX floating-point instructions without pointer dereferencing.ELEMENTS(Tagged Object Pointers): Pushing a string, object, or function forces V8 to box all numbers and store an array of tagged heap pointers—reintroducing pointer chasing!
- Density Invariant (
PACKEDvsHOLEY):PACKED_*: Guarantees that every index $i \in [0, \text{length} - 1]$ contains an initialized value. Readinga[i]requires only a single bounds checki < a.lengthand a direct memory load.HOLEY_*: Created when you allocatenew Array(1000), write past the enda[10] = 1, or executedelete a[2]. Uninitialized slots contain a special sentinel (the_hole). Because JavaScript mandates that reading a missing property must walk upArray.prototypeandObject.prototype, readingthe_holein aHOLEYarray forces V8 to check prototype guards or bail out of JIT fast paths!
| V8 Elements Kind | Backing Store Memory Layout | CPU Cache Locality | Bounds & Hole Checks on a[i] |
Triggering JS Code |
|---|---|---|---|---|
PACKED_SMI_ELEMENTS |
Contiguous immediate 31-bit ints | 16 ints / 64B line | 1 bounds check (i < len) |
const a = [1, 2, 3]; |
PACKED_DOUBLE_ELEMENTS |
Contiguous unboxed 64-bit IEEE-754 | 8 doubles / 64B line | 1 bounds check (i < len) |
a.push(3.14); |
PACKED_ELEMENTS |
Contiguous tagged pointers to Heap | Pointer chase per read | 1 bounds check + type tag check | a.push("str"); |
HOLEY_DOUBLE_ELEMENTS |
IEEE-754 + the_hole NaN bit-pattern |
8 doubles / 64B line | Bounds + the_hole NaN check + prototype guard |
const a = [1.5, , 3.5]; |
DICTIONARY_ELEMENTS |
Hash table (NumberDictionary) | Hash probe + scattered | Full hash table lookup per index | const a = []; a[1000000] = 1; |
Formal Specification: The Irreversible Lattice Monotonicity Invariant
Transitions in V8's Elements Kind lattice are strictly one-way (monotonic toward more general types):
$$\texttt{PACKED\_SMI} \longrightarrow \texttt{PACKED\_DOUBLE} \longrightarrow \texttt{PACKED\_ELEMENTS}$$
$$\downarrow \hspace{9.5em} \downarrow \hspace{9.5em} \downarrow$$
$$\texttt{HOLEY\_SMI} \;\longrightarrow\; \texttt{HOLEY\_DOUBLE} \;\longrightarrow\; \texttt{HOLEY\_ELEMENTS} \longrightarrow \texttt{DICTIONARY}$$
Why does V8 never transition backwards (e.g., from HOLEY_SMI back to PACKED_SMI after all holes are filled, or from PACKED_DOUBLE back to PACKED_SMI after popping the float)? Because checking whether all $N$ elements are integers or hole-free on every mutation would turn an $O(1)$ array write into an $O(N)$ scan, and oscillating states would cause infinite JIT deoptimization loops in TurboFan!
Visualizing V8 Backing Store Layouts & The Irreversible Lattice
The SVG diagram below traces the exact transition lattice and shows why PACKED_DOUBLE_ELEMENTS stores raw 64-bit IEEE-754 floats inline whereas HOLEY_ELEMENTS scatters boxed HeapNumber objects across the V8 heap.
Compare contiguous stride-1 traversal (1 cache miss per 8 elements, ~4 cycles L1 hit) against stride-8 and heap pointer-chasing (100% cache misses, ~200 cycles RAM stall).
Invariant & State
Hardware Reality: Why PACKED_DOUBLE to PACKED_ELEMENTS Copies the Entire Array
Unlike transitioning from PACKED_SMI to HOLEY_SMI (which only updates the Hidden Class Map metadata pointer because both store 32-bit/64-bit tagged words), transitioning from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTS (e.g., pushing "hello" or undefined into a float array) changes the physical bit-pattern of every slot from raw 64-bit IEEE-754 floats to tagged heap pointers! V8 must immediately allocate $N$ separate HeapNumber objects on the V8 heap and copy all $N$ floats into them—turning a single .push() call into an unexpected $O(N)$ allocation and GC pressure spike.
Writing V8-Friendly High-Performance JavaScript & TypedArrays
The JavaScript patterns below demonstrate how to preserve PACKED elements kinds and when to bypass V8's lattice entirely using Float64Array / Int32Array.
// V8 Performance Engineering: Avoiding Irreversible Lattice Degradations
// 1. ANTI-PATTERN: Pre-allocating with new Array(N) permanently brands the array
// as HOLEY_SMI_ELEMENTS, even after you fill every single slot!
function sumHoleyAntiPattern(n) {
const arr = new Array(n); // Immediate transition to HOLEY_SMI_ELEMENTS!
for (let i = 0; i < n; i++) arr[i] = i;
let sum = 0;
for (let i = 0; i < arr.length; i++) sum += arr[i];
return sum;
}
// 2. PRODUCTION PATTERN A: Start with [] (PACKED_SMI_ELEMENTS) and push sequentially,
// OR for fixed-size numeric buffers, use TypedArrays (Int32Array / Float64Array)
// which CANNOT become holey and CANNOT box elements!
function sumPackedOrTyped(n) {
const vec = new Int32Array(n); // Guaranteed contiguous unboxed 32-bit ints (16 per cache line)
for (let i = 0; i < n; i++) vec[i] = i;
let sum = 0;
for (let i = 0; i < vec.length; i++) sum += vec[i];
return sum;
}
// 3. ANTI-PATTERN: Storing -0 or NaN or undefined in an integer counter array
// Note: In V8, -0 cannot be represented as an SMI (because 0 is already an SMI),
// so arr.push(-0) silently transitions PACKED_SMI_ELEMENTS -> PACKED_DOUBLE_ELEMENTS!
Failure Modes & Adversarial Pitfalls
- Out-of-Bounds Reads (
i <= arr.length) Trigger Prototype Chain Walks: Accidentally writingfor (let i = 0; i <= arr.length; i++)readsarr[arr.length]on the final iteration, which returnsundefinedonly after V8 checks theNoElementsProtectorcell or walksArray.prototypeandObject.prototype. Never read pastarr.length - 1to terminate a loop! - Mutating
Array.prototypeDeoptimizes EveryHOLEYArray in the Entire Process: V8 uses a global speculation guard called theNoElementsProtectorto skip prototype walks onHOLEYarray reads, assumingArray.prototypehas no indexed properties. If any third-party library executesArray.prototype[0] = "poison", V8 permanently invalidates the global protector for the lifetime of the V8 Isolate, slowing down everyHOLEYarray access across the entire application!