← All reports

[JSC] Cache result of Symbol.prototype.toString

Component: JSC | 5e57a58

An "intrinsic" in JSC is a built-in function the JIT recognizes by identity and replaces with a hand-coded IR node instead of a generic call. Adding one means DFGByteCodeParser emits a specialized node, DFGFixupPhase constrains its input with a speculative type guard, and SpeculativeJIT/FTL lower it to machine code — while the GC must trace every heap pointer the new node touches.

JSTests/stress/symbol-prototype-to-string-intrinsic.js

+// SymbolUse fast path: repeated .toString() on the same Symbol
+(function () {
+ function test(sym) { return sym.toString(); }
+ noInline(test);
+ const sym = Symbol("cocoa");
+ for (let i = 0; i < testLoopCount; ++i)
+ shouldBe(test(sym), "Symbol(cocoa)");
+}());
+
+// String(symbol) must match sym.toString() through the new cache path
+(function () {
+ function viaString(sym) { return String(sym); }
+ function viaToString(sym) { return sym.toString(); }
+ noInline(viaString); noInline(viaToString);
+ const sym = Symbol("matcha");
+ for (let i = 0; i < testLoopCount; ++i)
+ shouldBe(viaString(sym), viaToString(sym));
+}());

A per-Symbol cached string field is added so Symbol.prototype.toString() returns a pre-allocated JSString on repeat calls. A new SymbolToString DFG/FTL intrinsic node compiles the operation down to a direct field load from that slot, with a slow path that allocates and writes the cache on first call. StringConstructor.cpp is independently modified to use the same cache through the String(symbol) path.

JS: sym.toString()
        │
        ▼
 DFGByteCodeParser
  sees SymbolPrototypeToStringIntrinsic ──► emits SymbolToString(sym)
        │
        ▼
 DFGFixupPhase
  constrains input ──► SymbolUse
        │
        ▼
 SpeculativeJIT64 / FTLLowerDFGToB3
  load Symbol::m_cachedString
    ┌── non-null ──► return cached JSString   (fast path, no alloc)
    └── null     ──► slow-path call ──► allocate JSString, write cache, return

A new JIT intrinsic now performs a raw memory load of a GC-traced field on the Symbol cell, so type speculation, GC liveness, and slow-path write-back all have to be correct simultaneously. A mismatch between the intrinsic's type assumptions and runtime behavior is a classic type-confusion vector in JSC.

Narrow: the type-speculation boundary. FixupPhase constrains the input to SymbolUse, but Symbol.prototype.toString is callable with a SymbolObject — the boxed wrapper produced by Object(Symbol()) — as this. If the intrinsic emits SymbolUse where it should emit SymbolObjectUse or handle both, the speculation guard is wrong and the type confusion goes unchecked downstream. The review tell for any new intrinsic is a single Use kind on a prototype method whose spec text accepts both the primitive and its wrapper.

Wider: GC tracing of the new field. Symbol::visitChildrenImpl must now trace the cached JSString — an incorrect field offset, a missing DECLARE_VISIT_CHILDREN expansion, or a stale pointer after compaction yields a dangling string reachable through the fast-path load. This is the same manual-visit obligation the JSPromise rework above carries; sweep every commit that adds a cell-pointer member to an existing JSC cell type for a matching visitChildrenImpl update in the same diff.

Widest: consistency across independent consumers of one cache. The slow path initializing m_cachedString is called from JIT code on multiple tiers, so check whether the store is visible across concurrent compiles and whether a second slow-path entry racing the first can expose a partially-written pointer. StringConstructor.cpp reaches the same cache through String(symbol); any divergence between its validity assumptions and the intrinsic's is a consistency bug. Whenever a cache gains a second reader in a different file, the two readers' preconditions are the thing to diff.