Repository navigation
Intern symbol names as pointer-free IDs - #64699
Jake Bailey (jakebailey) wants to merge 16 commits into
Conversation
|
TypeScript Bot (@typescript-bot) perf test this |
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
lspComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
startupComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
23581b2 to
7428ff5
Compare
|
TypeScript Bot (@typescript-bot) perf test this faster |
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
TypeScript Bot (@typescript-bot) perf test this faster |
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Symbols should share canonical names so identity checks and symbol-table lookups do not repeatedly compare string contents. Preserve textual behavior for diagnostics, emitted output, and the JavaScript API.
Known names and intermediate name operations should stay interned; repeated interning and string round trips defeat the canonical representation in frequently used lookup paths.
Symbol names need their own constructor and representation boundary, without exposing generic string handles throughout the compiler. Give name serialization a lossless escaped spelling and distinguish unset names from valid empty names.
unique.Make registers a weak handle for every new string, which made binding substantially slower and added allocations. Canonical names do not need to be collected during a compilation, so a dedicated table can make interning cheap. Identifying names by a 32-bit ID also keeps them free of pointers, so the symbol tables, nodes, and caches that store them cost the garbage collector nothing to scan or write-barrier.
Binding, checking, and language features reinterned identifier text at every symbol-table lookup, so interning cost was paid many times per identifier and showed up as a large binding regression. Interning once when the identifier is created lets every later lookup use the stored name directly. The AST schema gains a SymbolName field type so the generated Go AST and encoder carry the interned name while the JavaScript API still sees a plain string.
Most identifiers in a file repeat a small set of names, and going to the shared intern table for each one makes parsing noticeably slower, especially when many files are parsed in parallel. A small per-parser cache resolves repeated names without touching shared state.
Interning every identifier at parse time hashes its text, but the
scanner has just walked the same bytes. Hashing while scanning saves a
second pass over each identifier in the parser's hottest path.
│ sec/op │ sec/op vs base │
Parse/empty.ts-20 610.1n ± 2% 610.2n ± 1% ~ (p=0.767 n=12)
Parse/checker.ts-20 46.61m ± 3% 45.68m ± 3% -1.99% (p=0.005 n=12)
Parse/dom.generated.d.ts-20 16.71m ± 2% 16.53m ± 3% ~ (p=0.178 n=12)
Parse/Herebyfile.mjs-20 786.8µ ± 2% 779.4µ ± 1% ~ (p=0.319 n=12)
Parse/jsxComplexSignatureHasApplicabilityError.tsx-20 218.6µ ± 2% 213.3µ ± 2% -2.41% (p=0.012 n=12)
geomean 606.0µ 598.2µ -1.29%
c5c9ae2 to
395d87b
Compare
type SymbolName unique.Handle[string]|
TypeScript Bot (@typescript-bot) perf test this faster |
|
Jake Bailey (@jakebailey) Here they are:
Provisional hosted-runner results: these VMs are not the dedicated benchmark machines. Results may be noisier and are not comparable to the historical series. For comparisons, both revisions of each scenario ran on the same VM. tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Hmm, will have to see if that perf slowdown is still real (I thought it was fixed) and if it can be improved. |
Interned IDs stay 32 bits, but every struct and map slot that holds a
SymbolName aligns its neighbors to 8 bytes, so the 32-bit name was
padded to 8 bytes anyway: Identifier.Text, symbolData.name, symbol
table map slots, and the checker types that hold names all had a 4-byte
hole after it. Widening the field costs nothing, and the spare bits
leave room to encode names that never need to enter the table.
tsc --extendedDiagnostics memory, median of 3 runs:
32-bit 64-bit
self-build-src 224,394K 224,449K +0.02%
xstate 268,337K 268,372K +0.01%
mui-docs 1,761,690K 1,761,642K -0.00%
vscode 2,829,089K 2,826,766K -0.08%
# Conflicts: # tsc/internal/api/session_symbolresponse_test.go # tsc/internal/ast/symbol.go # tsc/internal/ast/symbol_test.go # tsc/internal/binder/binder.go # tsc/internal/checker/checker.go # tsc/internal/checker/inference.go # tsc/internal/checker/nodebuilderimpl.go # tsc/internal/checker/services.go # tsc/internal/checker/symbolaccessibility.go # tsc/internal/checker/types_test.go # tsc/internal/checker/utilities.go # tsc/internal/ls/autoimport/extract.go # tsc/internal/printer/namegenerator.go # tsc/internal/transformers/declarations/transform.go
|
TypeScript Bot (@typescript-bot) perf test this faster testing 64 bit before another refactor |
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
So, this makes it look like 64 bit is worse, but it's actually not; the delta just got smaller because the symbol table layout change did most of the work that this PR did to reduce the memory requirements of tables. If changed back to 32, nothing changes. |
The checker and binder synthesize names with unbounded numeric parts: private names, unique ES symbols, module patterns, and the "(auto@N)"/"(generated@N)" names. Interning each of these grows the table without bound in a long-lived process, since interned names are never freed. The spare bits of the 64-bit SymbolName can hold them directly instead: a 3-bit kind, a 35-bit number, and the 26-bit ID of the interned base text. These names are never interned, and two names are equal exactly when their texts are equal, because MakeSymbolName parses derived-shaped text back into its derived form. Interned text that starts with the internal "\xFE" prefix is tagged with its own kind, and records the byte after the prefix. This makes IsInternal, IsPrivateName, IsLateBoundName and IsReservedMemberName bit tests rather than lookups of the text. The fixed names are now iota constants generated from a single list (npx hereby generate:symbolnames), so they are compile-time constants and are seeded into the table in order. The zero value is the empty name, so an unset SymbolName needs no special handling, and String is the only way to get the text.
|
TypeScript Bot (@typescript-bot) perf test this faster |
|
Jake Bailey (@jakebailey), the perf run you requested failed. You can check the log here. |
|
TypeScript Bot (@typescript-bot) perf test this faster |
| // | ||
| // bits 61-63: kind (0 for plain interned names) | ||
| // bits 26-60: number | ||
| // bits 0-25: interned ID of the base text (always an interned name) |
There was a problem hiding this comment.
I need to think harder about this; basically this means names are only 26 bits which is even worse than 32! Need to decide on a tradeoff
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
The fixed name list lived in a standalone Go program run via go:generate, separate from the rest of our generated AST code. The Go generators that remain need to be Go for other reasons; this one did not, so move the list into tools/scripts/tsc/symbolNames.ts and emit symbolnames_generated.go from generate-go-ast.ts alongside the other AST outputs.
Lookups only read the slot tables and chunk pointers, while inserts write the shard locks, counts, and next ID. The shard stride wasn't a multiple of 64 and Go only guarantees 8-byte alignment, so neighbouring shards shared lines, and nextID shared a line with the first chunk pointers. Inserts on one core kept evicting lines that lookups on every other core needed. Group the read-mostly fields together, ahead of padded shards.
Each interned name held a heap string in a chunk of string headers, so a cache hit loaded the chunk header and then the string data, and every new name was a small GC-visible allocation. Store names as length- prefixed records in pointer-free chunks instead and use the record's offset as its ID; text is then one dependent load and the GC never scans it. Each shard bump-allocates from its own chunk, claimed with an atomic counter, so inserts on different shards don't contend. Records too large for a shared chunk get a dedicated run of chunks. Fixed names are inserted first, so their IDs are offsets the AST generator can compute; initialization panics if they disagree. The InternalSymbolName enum now reads its members from the same list.
With arena offsets as IDs, the 26-bit base ID field capped the table at 512MB of name text. Give the base ID the full low 32 bits, the most the hash slots can hold, and shrink derived-name numbers to 29 bits; larger numbers already fall back to interning the full text. A flat array of chunk pointers for 32GB of arena would be 16MB, so find chunks through a two-level directory whose small top level stays in cache.
|
TypeScript Bot (@typescript-bot) perf test this faster |
|
Hey Jake Bailey (@jakebailey), this PR is in an unmergable state, so is missing a merge commit to run against; please resolve conflicts and try again. |
# Conflicts: # Herebyfile.mjs
|
TypeScript Bot (@typescript-bot) perf test this faster |
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
So, to get the parse time down from the interning cost requires nasty stuff, but, it does seem like it works. This still leaks and it's still 32-bit's worth of user provided names (which has nice benefits for the hashing stuff over using more bits), but that is something like 2 billion names? |
The read-mostly and written halves of the table were kept apart by padding inside one struct, which relied on the table starting on a cache line. With the two-level directory the table is small enough to get a size-class allocation, and since it holds pointers the runtime puts an 8-byte header before it, so the shards were 8 bytes off. Move the shards and chunk counter into a separate pointer-free allocation, which has no header and a size class that's a multiple of 64 bytes.
|
TypeScript Bot (@typescript-bot) perf test this faster |
|
Jake Bailey (@jakebailey) Here they are:
tscComparison Report - baseline..pr
System info unknown
Hosts
Scenarios
Developer Information: |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Symbol names are currently plain
strings. Every identifier, symbol, symbol table key, and name-keyed cache holds its own 16-byte string header, every lookup hashes and compares the full text, and all of those pointers have to be scanned by the GC. On large projects that adds up to a lot of memory and GC work for what is effectively a small set of repeated names.This PR interns symbol names instead:
ast.SymbolNameis now a pointer-free 64-bit value holding an ID into a process-wide intern table, so names cost one word instead of a string header, compare and hash as integers, and are invisible to the GC. The zero value is the empty name.(auto@N)/(generated@N)) as a kind, a number, and the ID of the base text, so they never enter the table. Internal\xFE-prefixed names are tagged too, soIsInternal,IsPrivateName,IsLateBoundName, and friends are bit tests. Storing names in 64 bits rather than 32 is free: every struct and map slot that holds a name aligns its neighbors to 8 bytes anyway.InternalSymbolName*and friends) are compile-time constants generated bynpx hereby generate:astfrom one list intools/scripts/tsc/symbolNames.ts, which also drives the JSInternalSymbolNameenum. The generator computes their arena offsets, and the table checks them at startup.Identifier, so the binder, checker, and language service use it directly rather than reinterning node text on each lookup. The AST schema gains aSymbolNametype; the JS API still sees strings.Latest perf run: 1.3–3.4% less memory on every project but one tiny one, check up to 9% faster, total time 1–7% faster on most projects (mui-docs 7%, sentry 4%, tanstack-router 3.6%), and no total-time regressions. Parse is 3–9% slower (one 0.014s outlier at 24%), since every identifier is now interned up front; that's down from 5–14% before the arena.
Interned names are never freed. That's bounded per program for
tsc, and since synthesized names no longer enter the table, small for the language server too: 50K keystrokes of typing with completions in the compiler project leaves ~4K dead names (~100 KB). Closing large projects leaves their names behind (~17 MB after opening and closing vscode).