Part 6 · 6 chapters · ~45 min

Values

A number is either a tagged integer in the word itself or an object on the heap, and the engine works to keep it the first. This part is Smis and HeapNumbers and pointer compression, the representations the optimiser tracks for numbers and where boxing happens, the five shapes of a string and when a concatenation gets flattened, the cost of every string operation, symbols and BigInt and the oddballs, and the tools that show representation.

46

Tagged values: Smis and pointers

the question

"Is a number an object? Where does the 42 in x = 42 actually live?"

In the variable itself. V8 represents every value as a machine word with a tag bit. If the low bit is 0, the word is a small integer (Smi) and the value is the upper bits: no heap object, no allocation, no indirection. If the low bit is 1, the word is a pointer to a heap object: a string, an object, a function, or a HeapNumber holding a double. This one decision shapes the performance of all numeric code.

the representations
  1. Smi: a 31-bit signed integer (with pointer compression; 32-bit without) encoded as value << 1. Range about ±1.07 billion. Array indices, counters, small ids, character codes, most loop variables. Free to create, copy, compare.
  2. HeapNumber: an object with a map and an 8-byte IEEE 754 double. Allocated in the young generation like any object. Holds every number that is not a Smi: fractions, integers beyond the Smi range, -0, NaN, Infinity.
  3. Oddballs: undefined, null, true, false, and the hole are singleton heap objects in read-only space; comparisons are pointer compares.
  4. Everything else: a pointer to a heap object with a map that says what it is.
what the tag bit buys
  1. No allocation for small integers. A loop to a million allocates nothing for its counter.
  2. Fast type checks. "Is this a Smi" is test al, 1. Every IC and every optimised arithmetic sequence starts with it.
  3. Fast arithmetic. Two Smis add as integers (after one shift, or using the fact that (a<<1) + (b<<1) = (a+b)<<1 directly) with the CPU's overflow flag as the guard.
  4. Uniform storage. Arrays, fields, registers and the stack all hold tagged words; the GC can tell pointers from integers by the bit, so it never mistakes a number for an address.
pointer compression
  1. The heap is a 4 GB cage; pointers are stored as 32-bit offsets from its base. Tagged slots are 4 bytes. Objects are roughly 40% smaller than with 8-byte pointers; memory bandwidth and cache use drop with them.
  2. The cost: a base-register add on every pointer load, and Smis shrink to 31 bits. The trade was measured as a clear win on real workloads and is on in Chrome and Node.
  3. External memory (ArrayBuffer backing stores, some string data) lives outside the cage; the heap limit does not count it; process.memoryUsage().external does.
run it
node --allow-natives-syntax -e "%DebugPrint(42); %DebugPrint(2**30); %DebugPrint(4.5); %DebugPrint(-0)". The first is a Smi; the other three are HeapNumbers, and the second one is an integer.
TAGGED VALUES
how a 32-bit word is an integer or a pointer
swipe the figure sideways, or tap expand for full screen
1/6
Smi 42
The value 42. Shifted left by one: binary ...0101010 with a 0 in the low bit. That is the whole representation: no object, no allocation, no pointer. Smi range with pointer compression: −2^30 to 2^30 − 1 (31 bits).
47

Numbers in optimised code: unboxed, boxed, converted

The engine has one Number type and at least four representations for it. Which one a value gets at each point in a function is chosen by the optimiser, and the choice decides whether a numeric loop allocates. Knowing the conversion points is knowing where the HeapNumbers come from.

the representations the compiler tracks
  1. TaggedSigned: a Smi in a tagged slot or register.
  2. Word32: a raw 32-bit integer in a machine register. From untagging a Smi, from | 0, from integer feedback.
  3. Float64: a raw double in an xmm register or a double array slot or a Double field.
  4. Tagged / TaggedPointer: a pointer to a HeapNumber (or anything). The representation of "escaped" numbers.
  5. Conversions between them are explicit nodes: ChangeInt32ToFloat64 (free), ChangeFloat64ToTagged (an allocation unless the value is Smi-representable), ChangeTaggedToFloat64 (a load from the HeapNumber), CheckedTaggedToInt32 (a Smi check or a HeapNumber load plus a truncation check).
where boxing happens
  1. At function boundaries that are not inlined: a double returned or passed becomes a HeapNumber (unless it is an integer in Smi range, which is re-tagged for free).
  2. At stores into Tagged slots: generic arrays (PACKED_ELEMENTS), object fields with Tagged representation, Map and Set values, closure context slots (contexts are tagged), arguments.
  3. Not at stores into Double slots: PACKED_DOUBLE_ELEMENTS arrays, Double-representation fields, typed arrays.
  4. In the interpreter and Sparkplug: every non-Smi result of arithmetic is boxed, because those tiers have no unboxed representation. A hot numeric function that fails to reach Maglev or TurboFan allocates a HeapNumber per operation.
code
// integer-ness is a representation, not a type
const a = 1;        // Smi
const b = 1.0;      // also Smi: 1.0 === 1 and V8 stores it as the integer
const c = 1.5;      // HeapNumber (or unboxed in a register/double field)
const d = 2 ** 31;  // HeapNumber: integer, but outside the 31-bit Smi range
const e = -0;       // HeapNumber: -0 cannot be a Smi (Smi 0 is +0)

// so: a field initialised to 0 then assigned 1.5 generalises Smi → Double (one-time deopt, then fine)
// a field initialised to 0 then assigned 2 ** 31 does the same
// a field initialised to 0 then assigned null generalises Smi → Tagged: boxing forever. avoid.

// integer intent, stated
(x / 2) | 0         // truncate: int32 in optimised code, one instruction
Math.trunc(x / 2)   // same meaning; optimised to the same thing in modern V8
Math.imul(a, b)     // 32-bit wraparound multiply: never a double, never overflow deopt
x >>> 0             // reinterpret as uint32

// BigInt: arbitrary precision, heap-allocated digits, never a Smi, every operation allocates
const big = 2n ** 64n;   // fine for ids and crypto; wrong for a hot loop counter
run it
A loop computing a float sum with --trace-gc: no scavenges once optimised. The same loop with the sum stored into this.total where total was initialised to null: scavenges every few hundred ms. Change the initialiser to 0 and they stop.
NUMBER REPRESENTATIONS THROUGH A LOOP
int32, float64, boxed, and the conversions between
swipe the figure sideways, or tap expand for full screen
1/6
int32 sum
let total = 0; for (const p of prices) total += p; with prices all Smis. Feedback: SignedSmall for the add. TurboFan: total in an int32 register; add with overflow check; the loop body is a load, an add, a compare.
48

Strings: five shapes behind one interface

A string is immutable and indexable, and V8 exploits the immutability to avoid copying: concatenation makes a pair node, slicing makes a view, and only an operation that needs the characters in a row pays for the copy. The result is that string-building code has a cost profile that depends on when the characters are read, not only on how many there are.

the shapes
  1. Sequential (SeqOneByteString, SeqTwoByteString): header plus characters. One byte per character if every character fits in Latin-1, two bytes otherwise. Decided at creation. A one-byte string is half the memory and faster to hash, compare, and scan.
  2. Cons (ConsString): two pointers and a length. Created by +, template literals, concat, when the result is over a small threshold (13 characters). Concatenation is O(1); the characters stay in the leaves.
  3. Sliced (SlicedString): parent, offset, length. Created by slice, substring, substr for results of 13+ characters. A view; the parent is retained.
  4. Thin (ThinString): a forwarding pointer to an internalised copy. Created when a non-internalised string is internalised (used as a property key) so existing references find the canonical copy.
  5. External: characters owned by the embedder (e.g. a large file loaded by Node), referenced by V8 without copying.
  6. Internalised: not a shape but a flag: the string is in the string table, unique for its contents. All literals, identifiers and property names. Comparison by pointer; hashing precomputed.
flattening
  1. When: any operation that needs random access or a contiguous buffer: charCodeAt, indexing, comparison beyond length, regex matching, most methods, use as a property key, passing to a builtin that calls into C++.
  2. What: allocate a sequential string of the full length; traverse the cons tree copying leaves; patch the cons node to point at the flat string (so other holders of the same node also benefit). O(n) once per tree.
  3. The consequence: building with += and reading once is fine (one flatten). Building with += and reading after every append is quadratic (each read flattens the growing tree). Array.join computes the total length first and copies once: always flat, always linear.
code
// build a string: three ways, 100k pieces
let s = ''; for (const p of parts) s += p;        // 100k ConsString nodes; one flatten on first read (O(n))
const t = parts.join('');                          // one allocation of the final length; one copy. flat.
const u = parts.reduce((a, b) => a + b, '');       // same as the first; the reduce adds call overhead

// all three produce the same characters. the difference is when the copy happens and how many nodes exist meanwhile.
// for a few pieces: += is fine and clearer. for thousands in a hot path: join.

// keys: these are not the same cost
obj.name            // internalised at parse time: pointer compare in the IC
obj['na' + 'me']    // constant-folded by the parser: same as above
obj['na' + suffix]  // built at runtime: hash the string, probe the table (internalise), then the lookup. every time.
map.get(key)        // Map hashes the string once per get; for dynamic keys this is the right structure
run it
node --allow-natives-syntax -e "let s='';for(let i=0;i<5;i++)s+='abcdefgh'; %DebugPrint(s); s.charCodeAt(0); %DebugPrint(s)". The first print shows a ConsString tree; the second shows it flattened.
STRING REPRESENTATIONS
sequential, cons, sliced, thin, internalised
swipe the figure sideways, or tap expand for full screen
1/6
sequential
const a = "hello world": a sequential one-byte string. Header (map, hash, length) then the characters, one byte each because every character is Latin-1. "日本語" would be two-byte: 2 bytes per UTF-16 unit. The choice is made at creation and never changes.
49

String operations and their costs

OperationWhat happensCost
a + b, template literalConsString if result ≥ 13 chars; else a copyO(1) or O(small)
s.lengthStored in the header for every shapeO(1)
s[i], s.charCodeAt(i)Flatten if cons; then indexO(n) once, then O(1)
s.slice(a, b)SlicedString if ≥ 13 chars (view); else copyO(1), retains parent
a === bPointer compare; if both internalised and different pointers, false immediately; else length compare, then flatten and compare bytesO(1) for internalised; O(n) worst
s.indexOf(t), includesFlatten both; Boyer-Moore-Horspool or similar for longer needlesO(n) with a small constant
s.split(sep)Flatten; scan; create substrings (sliced or copied); the result arrayO(n) plus allocations per piece
s.replace(re, fn)Regex engine (Irregexp, compiled to bytecode or native code); the callback per matchO(n) plus calls
s.toLowerCase(), normalize()Copy with ICU for non-ASCII; fast path for one-byte ASCIIO(n)
JSON.stringify(obj)A fast path for plain objects with simple values; builds one flat string via an incremental bufferO(size); fastest serialiser you have
JSON.parse(s)Flatten; a dedicated parser that builds objects with precomputed maps for repeated shapesO(n); often faster than an equivalent object literal in source for large data
obj[dynamicString]Internalise the key (hash, string table probe), then the property lookupO(len) per access; use Map for dynamic keys
new TextEncoder().encode(s)Flatten; UTF-8 transcode into a Uint8ArrayO(n); unavoidable at I/O boundaries
Buffer.from(s) (Node)Same transcode, into Node's pooled buffersO(n)
the rules
  1. Build with an array and join when the piece count is large or the result is read incrementally.
  2. Keep hot strings one-byte where you can: a single emoji or non-Latin character in a template makes the whole result two-byte.
  3. Use Map for dynamic keys, objects for static ones.
  4. Be careful with slices of huge strings you keep around; copy if the parent should die.
  5. Serialise once: JSON.stringify is faster than hand-built string concatenation for structured data, and it produces a flat string.
  6. Regex: compiled once per literal (cached); a regex built from a string each call is recompiled each call. Hoist them.
50

Symbols, BigInt, and the oddballs

symbols
  1. A unique heap object with an optional description. Two symbols are equal only if they are the same object. Symbol.for(key) uses a global registry so the same key gives the same symbol across realms.
  2. As property keys they live in the same descriptor array as string keys and are just as fast; they are skipped by for...in, Object.keys and JSON.stringify, which is their main use: metadata and protocol hooks that do not collide with user data.
  3. Well-known symbols (Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive, Symbol.hasInstance, Symbol.toStringTag, Symbol.dispose) are how built-in operations dispatch to user code. The engine looks them up like any property, through the IC, so defining them on a prototype is cheap and defining them per-instance makes instances polymorphic.
  4. Private fields (#x) are implemented as private symbols: hidden from all reflection, stored like normal properties, with a brand check on access. As fast as a normal field once the IC is warm.
BigInt
  1. Arbitrary precision integers stored as a heap object with a sign and an array of 64-bit digits. Never a Smi; every arithmetic result is a new allocation.
  2. Cost: a small BigInt add is tens of nanoseconds and an allocation; a Number add is one instruction. Fine for 64-bit ids, timestamps in nanoseconds, cryptography, exact money if you must. Wrong for loop counters and hot arithmetic.
  3. No mixing: 1n + 1 throws. Conversions (Number(big), BigInt(n)) are explicit. BigInt64Array stores them unboxed.
  4. Feedback: the binary operation lattice has a BigInt kind; a site that sees both Numbers and BigInts becomes Any.
the oddballs and holes
  1. undefined, null, true, false: singletons in read-only space. Comparing to them is a pointer compare. typeof on them is a map check.
  2. The hole: an internal oddball marking an uninitialised array element or a let in its temporal dead zone. Reading a holey array element checks for it; reading a TDZ variable throws on it. Never observable from JavaScript directly, but the reason holey arrays and TDZ checks cost something.
  3. NumberOrOddball feedback: an arithmetic site that saw numbers and undefined (a missing property added to a sum) compiles a path that handles the oddball conversion. Slower than Number; common in code that sums optional fields. Default the field.
the pointer
Part 7 is objects and prototypes from the engine's side: the chain walk, accessors, Proxy, and what each reflective operation costs. The tagged-value and map machinery from parts 3 and 6 is the vocabulary for it.
51

Reading values: tools

ToolShowsUse it for
%DebugPrint(v)Smi vs HeapNumber; string shape (Seq, Cons, Sliced, Thin, one-byte or two-byte, internalised); BigInt digitsWhat representation a value has right now
%IsSmi(v), %HasFastProperties, %StringIsFlat(s) (name varies by version)BooleansAssertions in experiments
--trace-turbo + Turbolizer, look for Change* nodesRepresentation changes in the graph; ChangeFloat64ToTagged is a boxing pointFinding where a numeric loop allocates
--trace-deopt reasons "not a Smi", "not a heap number", "lost precision", "minus zero"Numeric representation guards failingNumeric type instability
DevTools Memory → snapshot → (string) and (concatenated string) and (sliced string) entriesStrings by shape, with sizes and retainersA cons tree never flattened; a slice retaining a huge parent; two-byte strings that could be one-byte
DevTools Memory → snapshot → HeapNumber count (under (number) or system entries)How many boxed doubles existA Tagged field or generic array full of boxed doubles
--trace-gc while running a numeric loopScavenges that should not be happeningBoxing in a hot loop, found without a profiler
Buffer.byteLength(s) vs s.length (Node)UTF-8 bytes vs UTF-16 unitsSizing I/O; remembering that length is not bytes
run it
Take a JSON API response of a few hundred KB. JSON.parse it; snapshot; sort the (string) entries by shallow size. Many will be sliced strings into the original response text, which is still alive because of them. That is normal, and it is also why "parsed and discarded the raw text" does not free the memory you expected.