Skip to content

Use small slices for union and intersection property sources - #64816

Closed
Jake Bailey (jakebailey) wants to merge 17 commits into
jakebailey/mem/never-reduction-probesfrom
jakebailey/mem/small-property-source-sets
Closed

Jake Bailey (jakebailey) wants to merge 17 commits into
jakebailey/mem/never-reduction-probesfrom
jakebailey/mem/small-property-source-sets

Conversation

@jakebailey

@jakebailey Jake Bailey (jakebailey) commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Synthesizing or probing a union or intersection property collects the distinct constituent properties in a collections.OrderedSet, which always allocates a map and a key slice even though it rarely holds more than a handful of symbols.

  • Keeps the properties in a plain slice, and only indexes them with a map past the same size threshold orderedSet uses.
  • MUI measurements showed 7.41% fewer allocations and 3.66% fewer allocated bytes.
  • Retained heap did not materially change.

Part of the memory optimization stack summarized in #64734.

@typescript-automation typescript-automation Bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Oct 14, 2026
@jakebailey
Jake Bailey (jakebailey) added this pull request to stack #64806 October 14, 2026 14:20
@jakebailey Jake Bailey (jakebailey) changed the title Avoid map-backed sets for union and intersection property sources Use small slices for union and intersection property sources Oct 14, 2026
The compact compiler layouts depend on every header belonging to the
concrete allocation identified by its tag. Unchecked kinds, retagging,
and detached headers can otherwise turn a routine mistake into an
invalid memory access.

Keep that invariant mechanically enforced without adding layout tags
or checks to hot accessors.
The owning packages may allocate records directly, but a fresh record
carries a zero tag until newType, newNode, or its mapper tag
assignment runs. If it escaped before then, the mistake surfaced only
as a later cast panic. Fresh allocations must now be upcast straight
into their constructor, or held in a local used only through its own
fields until that upcast; mappers must be tagged immediately.
A zero-value node or type record has never been tagged, and returning
its address yields a compiler record that skipped checked construction.
Named or aliased unsafe.Pointer types also hid reinterpreting
conversions from the cast boundary. Both left holes in the invariant
the analyzer exists to enforce.
Enumerating copy and declaration forms one at a time left gaps: range
assignments could retag records, named results and parameters could
carry untagged zero values, and index, receive, and call expressions
could copy a record off its header. Checking every value-producing
expression and signature for records held by value closes the class
rather than individual forms.
A record copied into a range variable or a value receiver detaches
from the arena slot that owns its tagged header, so later writes are
lost and reads can observe a stale kind. Those declarations never
appear as typed expressions, so the copy check could not see them.
Checking every defined variable also covers parameters and results,
which replaces the narrower function signature rule.
Mapper storage held by value starts with a zero tag. The lint exempted
mappers from its zero-value rule so coallocated inference mappers could
live inside their owning context, but that also let a standalone mapper,
or the address of untagged coallocated storage, escape and later panic
in Map on kind 0. The exemption now applies only to mappers stored as
struct fields, and their storage may only be addressed, explicitly or
via a pointer-receiver method call, as an argument to an
init*TypeMapper function that tags it.
A local that shadows the constructor's kind parameter passed the
spelling-only check, so newType or newNode could tag a header with an
unrelated value while the lint reported checked construction. Bulk
copies through copy or variadic append also duplicated records by
value without any diagnostic, because their arguments are slices.
Mapper kind constants are now public TypeMapperKind values, so the
lint's pairing between concrete mappers and their kind constants has
to look them up by the new names.
Dense checker link stores still keep a pointer for every occupied slot.
Those tables cost memory and require GC scanning even though values
already live in arenas.

Use four-byte indexes in the page tables. Retain bounded arena blocks
so references stay stable without geometrically over-allocating large
stores. Keep map-backed stores unchanged: compressing those did not
improve allocation usage.
A presence query needs only the page entry, not its value. Resolving
an occupied index adds arena lookup work to a boolean check.

Keep presence checks on the compressed page table, and apply the
repository linter fixes to the link-store tests.
The checker's link-store wrappers answered Has by calling TryGet, so
presence checks still resolved arena indexes even though the paged
store can answer from the page entry alone. Route them to the store
presence checks so checker queries get the cheaper path.
Instantiated and inherited object types retain a name-to-symbol map
beside their ordered property list. Those maps duplicate symbol pointers
and string headers across millions of types, increasing both live heap
and the amount of memory the collector must scan.

Use the existing property list as the source of truth for immutable
named members. Small types need no separate index; larger types need
only pointer-free integer slots. Keep map semantics for mutable module
exports, reserved members, mismatched names, and recursive member
resolution.

VS Code src, six samples versus cold value-symbol links:

goos: linux
goarch: amd64
pkg: github.com/microsoft/TypeScript/tsc/internal/checker
cpu: Intel(R) Core(TM) i9-10900K CPU @ 3.70GHz
                   │ vscode-cold-links.txt │   vscode-member-index-owned.txt    │
                   │      retained-B       │  retained-B   vs base              │
ExternalProgram-20            4.874Gi ± 0%   4.503Gi ± 0%  -7.61% (p=0.002 n=6)

                   │ vscode-cold-links.txt │   vscode-member-index-owned.txt    │
                   │        scan-B         │    scan-B     vs base              │
ExternalProgram-20            4.508Gi ± 0%   4.062Gi ± 0%  -9.89% (p=0.002 n=6)

                   │ vscode-cold-links.txt │   vscode-member-index-owned.txt    │
                   │         B/op          │     B/op      vs base              │
ExternalProgram-20            6.043Gi ± 0%   5.982Gi ± 0%  -1.00% (p=0.002 n=6)

                   │ vscode-cold-links.txt │   vscode-member-index-owned.txt   │
                   │       allocs/op       │  allocs/op   vs base              │
ExternalProgram-20             26.23M ± 0%   26.04M ± 0%  -0.71% (p=0.002 n=6)

All 321 semantic diagnostics and 10284 files are identical. CLI
diagnostic text and compiler counts match exactly. Instruction
measurements remain noisy; this change does not claim an established CPU
improvement. No GC tuning is used.
The name index and the instantiated fast path both assumed a table
whose properties are found under their own names has no other keys.
A symbol stored under its own name and an alias satisfies every
reverse lookup, so the alias was silently dropped. Checking each
(key, symbol) pair directly keeps such tables on the map path.
Ordinary type comparisons need only two 32-bit IDs and intersection
context, but retained a 128-bit hash for every cached result. Hashing
and storing twice the necessary key width wastes allocations and memory.

Keep exact 64-bit pairs in separate maps for the four intersection
states. Generic-reference normalization still uses its original
full-width hash. An explicit key tag separates both namespaces,
including recursion guards, without sacrificing hash bits or changing
constrained generic behavior.

Six fresh-process interleaved VS Code comparisons against the parent
commit, using normal GC, reduce retained heap 0.94% (roughly 33 MiB) and
total allocated bytes 1.29%. GC scanning, allocation events, and
instruction counts are not statistically different.

Tests cover directional and identity comparisons, all intersection
contexts, result replacement, exact high-bit IDs, namespace isolation,
generic parameter normalization, and constrained versus unconstrained
recursion keys.
Production diagnostics
and all semantic counts match exactly.

goos: linux
goarch: amd64
pkg: github.com/microsoft/TypeScript/tsc/internal/checker
cpu: Intel(R) Core(TM) i9-10900K CPU @ 3.70GHz
                   │ vscode-relation-before-6.txt │       vscode-relation-6.txt        │
                   │          retained-B          │  retained-B   vs base              │
ExternalProgram-20                   3.456Gi ± 0%   3.424Gi ± 0%  -0.94% (p=0.002 n=6)

                   │ vscode-relation-before-6.txt │     vscode-relation-6.txt     │
                   │            scan-B            │    scan-B     vs base         │
ExternalProgram-20                   2.998Gi ± 0%   2.997Gi ± 0%  ~ (p=0.310 n=6)

                   │ vscode-relation-before-6.txt │       vscode-relation-6.txt        │
                   │             B/op             │     B/op      vs base              │
ExternalProgram-20                   5.021Gi ± 0%   4.956Gi ± 0%  -1.29% (p=0.002 n=6)

                   │ vscode-relation-before-6.txt │    vscode-relation-6.txt     │
                   │          allocs/op           │  allocs/op   vs base         │
ExternalProgram-20                    31.08M ± 0%   31.07M ± 0%  ~ (p=0.240 n=6)

                        │ relation-before-instructions.txt │    relation-instructions.txt     │
                        │         instructions/op          │ instructions/op  vs base         │
ExternalInstructions-20                        269.3G ± 2%       270.0G ± 3%  ~ (p=0.937 n=6)
Complete non-augmented properties are valid for augmented lookup, but
copying each result into both caches retains duplicate name tables.
MUI keeps 2,734,919 identical entries this way, nearly doubling its
union/intersection cache storage without adding semantic information.

Preserve independent augmented results and partial-property behavior
while sharing complete entries. A failed lookup needs no empty table.
The MUI census falls from 230242 tables and 5628201 entries to 116635
tables and 2893282 entries; all distinct cached identities remain.

Six fresh-process interleaved single-checker comparisons against the
parent commit, with ordinary GC, save 135.63 MiB of retained MUI heap
(-8.15%) and 132.00 MiB of GC scanning (-9.22%). Total allocated bytes
fall 4.45% and allocation events fall 1.46%. VS Code's corresponding
reductions are small: retained -0.15%, scan -0.17%, allocated -0.16%.
Instruction differences in these single-checker comparisons are
inconclusive; elapsed time is noisy on the shared machine.

Six default-threaded production CLI pairs confirm MUI's reported heap
falls 9.29% and instructions fall 2.60%. VS Code and Webpack have small
heap reductions and inconclusive instruction differences. These are
incremental savings on top of the earlier branch optimizations.
CLI-reported heap is not the forced-GC retained-heap measure above.

MUI uses the CI-pinned source and restored scenario dependencies at
6780195595c12252a130aa5ae5c5bd461229c6b7. VS Code uses
8bf178e1e1c2be3723f14683f7b1e5fdb3f780e4. Webpack uses an isolated
2289141381f2c1c965cb65118fa0ffe2e9e19834 with restored dependencies.
Production diagnostics and all semantic counts match exactly.

Tests cover both query orders, both composite kinds, cache growth and
GC reachability, missing entries, read/write partial flags, differing
mode-specific identities, and real function-property augmentation.
The storage regressions fail before this change.

Single-checker retained heap and allocation measurements:

                  │ mui-shared-union-before.txt │    mui-shared-union-shared.txt     │
                  │         retained-B          │  retained-B   vs base              │
ExternalMemory-20                  1.624Gi ± 0%   1.492Gi ± 0%  -8.15% (p=0.002 n=6)

                  │ mui-shared-union-before.txt │    mui-shared-union-shared.txt     │
                  │           scan-B            │    scan-B     vs base              │
ExternalMemory-20                  1.398Gi ± 0%   1.270Gi ± 0%  -9.22% (p=0.002 n=6)

                  │ mui-shared-union-before.txt │    mui-shared-union-shared.txt     │
                  │            B/op             │     B/op      vs base              │
ExternalMemory-20                  5.364Gi ± 0%   5.125Gi ± 0%  -4.45% (p=0.002 n=6)

                  │ mui-shared-union-before.txt │    mui-shared-union-shared.txt    │
                  │          allocs/op          │  allocs/op   vs base              │
ExternalMemory-20                   25.17M ± 0%   24.80M ± 1%  -1.46% (p=0.002 n=6)
                  │ vscode-shared-union-before.txt │   vscode-shared-union-shared.txt   │
                  │           retained-B           │  retained-B   vs base              │
ExternalMemory-20                     2.619Gi ± 0%   2.615Gi ± 0%  -0.15% (p=0.002 n=6)

                  │ vscode-shared-union-before.txt │   vscode-shared-union-shared.txt   │
                  │             scan-B             │    scan-B     vs base              │
ExternalMemory-20                     2.291Gi ± 0%   2.287Gi ± 0%  -0.17% (p=0.002 n=6)

                  │ vscode-shared-union-before.txt │   vscode-shared-union-shared.txt   │
                  │              B/op              │     B/op      vs base              │
ExternalMemory-20                     3.754Gi ± 0%   3.748Gi ± 0%  -0.16% (p=0.002 n=6)

                  │ vscode-shared-union-before.txt │  vscode-shared-union-shared.txt   │
                  │           allocs/op            │  allocs/op   vs base              │
ExternalMemory-20                      23.61M ± 0%   23.59M ± 0%  -0.08% (p=0.002 n=6)

Single-checker instructions:

                        │ mui-shared-union-before.instructions │ mui-shared-union-shared.instructions │
                        │           instructions/op            │   instructions/op    vs base         │
ExternalInstructions-20                            218.9G ± 3%           217.3G ± 2%  ~ (p=0.310 n=6)
                        │ vscode-shared-union-before.instructions │ vscode-shared-union-shared.instructions │
                        │             instructions/op             │    instructions/op      vs base         │
ExternalInstructions-20                               188.8G ± 3%              192.1G ± 2%  ~ (p=0.394 n=6)

Default-threaded production CLI:

           │ shared-union-cli-before.txt │      shared-union-cli-after.txt       │
           │       instructions/op       │ instructions/op  vs base              │
mui-20                       283.3G ± 1%       275.9G ± 2%  -2.60% (p=0.015 n=6)
vscode-20                    308.2G ± 7%       293.0G ± 6%       ~ (p=0.818 n=6)
webpack-20                   51.96G ± 5%       51.82G ± 4%       ~ (p=1.000 n=6)
geomean                      165.6G            161.2G       -2.62%

           │ shared-union-cli-before.txt │     shared-union-cli-after.txt     │
           │          heap-B/op          │  heap-B/op    vs base              │
mui-20                      2.059Gi ± 0%   1.868Gi ± 0%  -9.29% (p=0.002 n=6)
vscode-20                   2.835Gi ± 0%   2.829Gi ± 0%  -0.21% (p=0.004 n=6)
webpack-20                  666.9Mi ± 0%   663.0Mi ± 0%  -0.57% (p=0.002 n=6)
geomean                     1.561Gi        1.507Gi       -3.45%

           │ shared-union-cli-before.txt │    shared-union-cli-after.txt     │
           │          allocs/op          │  allocs/op   vs base              │
mui-20                       29.84M ± 0%   29.55M ± 0%  -0.97% (p=0.002 n=6)
vscode-20                    36.67M ± 2%   36.64M ± 0%       ~ (p=0.132 n=6)
webpack-20                   6.235M ± 2%   6.208M ± 0%  -0.43% (p=0.002 n=6)
geomean                      18.97M        18.87M       -0.49%

           │ shared-union-cli-before.txt │     shared-union-cli-after.txt     │
           │        check-sec/op         │ check-sec/op  vs base              │
mui-20                        8.986 ± 6%    8.559 ±  4%  -4.75% (p=0.026 n=6)
vscode-20                     8.345 ± 4%    8.900 ± 10%       ~ (p=0.132 n=6)
webpack-20                    1.851 ± 8%    1.818 ±  8%       ~ (p=0.310 n=6)
geomean                       5.178         5.173        -0.08%
getReducedType runs somePropertyReducesToNever on intersections. It
used to create and cache a synthetic property for every property name
shared by more than one constituent, only to inspect its flags. With
large JSX prop intersections, as in MUI, most of those properties were
never looked up again, yet they stayed in the intersection's property
cache with their symbols and links.

Collect the constituent properties without building the combined
symbol, and only create the real property in the rare merged
instantiation case.

mui-docs:

          │    before     │                after                │
          │ memory-used-B │ memory-used-B  vs base              │
  MUIDocs    1.857Gi ± 0%    1.717Gi ± 0%  -7.55% (p=0.002 n=6)

          │   before    │               after               │
          │   allocs    │   allocs     vs base              │
  MUIDocs   27.29M ± 0%   25.19M ± 0%  -7.72% (p=0.002 n=6)

          │    before    │               after                │
          │ allocated-B  │ allocated-B   vs base              │
  MUIDocs   4.730Gi ± 0%   4.556Gi ± 0%  -3.68% (p=0.002 n=6)

          │   before    │               after                │
          │   symbols   │   symbols    vs base               │
  MUIDocs   4.332M ± 0%   3.663M ± 0%  -15.43% (p=0.002 n=6)
Synthesizing or probing a union or intersection property gathers the
distinct constituent properties into a set. That set rarely holds more
than a handful of symbols, but collections.OrderedSet always allocates
both a map and a key slice, and this runs for every property of every
combined type that is enumerated during relation checks.

Keep the distinct properties in a plain slice, and index them with a
map only once the slice passes the same threshold used by orderedSet.

mui-docs:

          │   before    │               after               │
          │   allocs    │   allocs     vs base              │
  MUIDocs   25.19M ± 0%   23.32M ± 1%  -7.41% (p=0.002 n=6)

          │    before    │               after                │
          │ allocated-B  │ allocated-B   vs base              │
  MUIDocs   4.556Gi ± 0%   4.389Gi ± 1%  -3.66% (p=0.002 n=6)
@jakebailey
Jake Bailey (jakebailey) force-pushed the jakebailey/mem/small-property-source-sets branch from 3839c69 to e8b256d Compare October 14, 2026 16:02
@jakebailey
Jake Bailey (jakebailey) removed this pull request from stack #64806 October 14, 2026 16:55
@jakebailey
Jake Bailey (jakebailey) force-pushed the jakebailey/mem/never-reduction-probes branch from 2473949 to e2fcf0f Compare October 14, 2026 16:56
@jakebailey

Copy link
Copy Markdown
Member Author

Folded into #64815.

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

Labels

Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant