Skip to content

perf: string-keyed Map is 4.2x node with repeated constant keys (integer-keyed Map wins); template literals ~1.9x #10697

Description

@proggeramlug

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 main 68a545439 + #10611, node v26.8.1, bun 1.3.14. Output checked equal to node on every row.

1. Map with string keys — 4.2× node in the commonest shape

shape perry node bun vs node
integer keys, set+get 285 504 350 1.76× — perry wins
string keys, 1,024 distinct ("k"+i) 1,184 958 1,006 0.80×
4 constant string keys, get-or-default 1,298 320 289 0.24×

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 Map is genuinely good and needs nothing.

2. Template literals — ~1.9× node

shape perry node bun
4-interpolation template, content consumed 2,640 1,416 1,657

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 .length without materialising the string, so it is not building one at all. Forcing the content to be consumed (charCodeAt across 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 Map is the larger of the two and has the clearer mechanism.

Related: #10695 (crossover map and the real-program decomposition), #10696 (JSON per-object cost).

Activity

  1. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    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 get and 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]), CATS module-level 558.2 87.2
    const k = CATS[i&3]; m.get(k), module-level 558.3 64.5
    m.get(CATS[i&3]), CATS function-local 560.3 90.0
    const k = CATS[i&3]; m.get(k), function-local 560.2 78.0
    m.get("alpha") — string literal 255.0 102.1
    index alone, no map (control) 154.0 33.4

    Net of the index control, a computed-key get costs 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) where category came 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's LocalGet arm requiring stable_local_type_proof), so a bound key reaches generic js_map_get while an inlined one reaches js_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 annotated new Map<string,number>() versus a bare new Map() — the annotation sits directly on the variable under test.

    I also grepped a --debug-symbols build and found js_map_get in all five arms and js_map_get_string_key in 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions