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).
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).