Repository navigation
perf: string-keyed Map is 4.2x node with repeated constant keys (integer-keyed Map wins); template literals ~1.9x #10697
Description
Activity
Re-measured on v0.5.1618: the discriminator is literal vs computed key, not hashing — and my "no benefit from key repetition" reading was pointing at the wrong mechanism
One
getand one index in every arm, map pre-populated outside the loop, output identical to node, warm fit 100k→1M on perrymaster.shape perry node m.get(CATS[i&3]),CATSmodule-level558.2 87.2 const k = CATS[i&3]; m.get(k), module-level558.3 64.5 m.get(CATS[i&3]),CATSfunction-local560.3 90.0 const k = CATS[i&3]; m.get(k), function-local560.2 78.0 m.get("alpha")— string literal255.0 102.1 index alone, no map (control) 154.0 33.4 Net of the index control, a computed-key
getcosts perry ~404 against node's ~50.A literal key is 2.2× cheaper than a computed one (255 vs 558). That is the only thing in this table that moves. So there is a fast path and it is reachable — it just requires the key to be a literal, which is not the shape real code uses. A
counts.get(category)wherecategorycame from anywhere at all pays full price.Two corrections to what this issue implied
1. It is not about hashing or identity. The original text said perry "appears to derive no benefit from key repetition, which suggests hashing and comparing the string contents on every lookup rather than exploiting identity." That inference does not survive: the literal-key arm proves repeated identical keys can be fast, and the computed-key arms are flat at 558–560 regardless of how the key is produced or where the source array lives. Whatever the literal path does, it is not gated on repetition.
2. There is no scaling defect. I said elsewhere in this campaign that the cost grows with N. That was never in this issue's body and it is unsupported. A sweep from 16 to 4,096 entries on a second host (thanks @perry-28's lane) is flat, mildly decreasing, past the 8-entry threshold. The apparent growth was a key-length artifact: keys built as
"key" + (i % size)acquire longer decimal suffixes as the map grows, so key length correlates with N. Normalising the suffix to constant width makes both arms decrease.The headline row in this issue used 4 constant string keys, so key length is constant by construction there and the 4.2× stands. But anyone reading this issue as "string Maps degrade as they grow" should stop — they do not.
A mechanism that was proposed and does not reproduce here
It was suggested that
let key = arr[i]loses a string-type proof through the binding (string_value_is_runtime_guaranteed'sLocalGetarm requiringstable_local_type_proof), so a bound key reaches genericjs_map_getwhile an inlined one reachesjs_map_get_string_key.On v0.5.1618 / x86-64 that does not reproduce: inline and bound agree to the decimal (558.2 vs 558.3, and 560.3 vs 560.2), and moving the source array from module scope to function scope changes nothing. I specifically expected the module-level array to be the confound — a module-global carries no type fact, so both arms would lose the proof and show no difference — and making it function-local did not change the result either, so that explanation is dead too.
Untested candidate differences, for anyone reconciling the two readings: compiler version (that reading may predate #10570/#10651), Apple silicon vs x86-64, a get-then-set-or-default shape versus a pure
get, or an annotatednew Map<string,number>()versus a barenew Map()— the annotation sits directly on the variable under test.I also grepped a
--debug-symbolsbuild and foundjs_map_getin all five arms andjs_map_get_string_keyin none, but that is weak evidence — the fast path may be inlined, and absence of a symbol in a disassembly is not absence of a code path. The measurements are what settle it.Where that leaves the issue
Still open, still ~8× node net of the index on the shape real code uses. The question is narrower now: what does the literal-key path do that a computed key cannot reach, and can that be extended to a key whose value is only known at runtime? That is a different question from the hashing one this issue originally posed.
- added 6 commits that reference this issue
on Sep 20, 2026
Context
#10695 shows regex is 63% of a realistic program, and that removing it moves perry from 0.18× to 0.54× against node. This issue is the residue — what remains after regex, which still leaves perry ~1.9× behind node at 100k lines. Two items account for most of it.
Per-operation, fitted across N=10,000→100,000 so startup drops out.
perf stat -e instructions:u, perrymaster, perry from main68a545439+ #10611, node v26.8.1, bun 1.3.14. Output checked equal to node on every row.1.
Mapwith string keys — 4.2× node in the commonest shapeset+get"k"+i)The third row is the ordinary "count things by category" pattern — a handful of repeated constant string keys — and it is the worst case, at 4.2× node and 4.5× bun.
The interesting part is that it is worst where it should be easiest. node gets faster with repeated constant keys (958 → 320) because they intern and the lookup becomes pointer comparison. perry gets slightly slower (1,184 → 1,298). perry appears to derive no benefit from key repetition, which suggests hashing and comparing the string contents on every lookup rather than exploiting identity.
Integer-keyed
Mapis genuinely good and needs nothing.2. Template literals — ~1.9× node
Modest, but it is the base layer of any string-producing program, so it multiplies through.
A measurement warning, because I nearly filed a wrong number
Measured with
h = (h*31 + t.length) | 0— reading only the template's length — the same program gives perry 1,619 vs node 173, which looks like 9.4×. That is an artifact: node's JIT can satisfy.lengthwithout materialising the string, so it is not building one at all. Forcing the content to be consumed (charCodeAtacross the string) gives node 1,416 and the honest ratio 1.9×.Any benchmark that builds a string and then only measures its length is measuring nothing in node or bun. Consume the content.
Priority
Both are second-tranche: worth doing after regex (#10166, #10518, #10519), which is 63% of a real program against these two together being a fraction of the remaining 37%. The string-keyed
Mapis the larger of the two and has the clearer mechanism.Related: #10695 (crossover map and the real-program decomposition), #10696 (JSON per-object cost).