Skip to content

perf(tsc): optimize checker/AST memory layout, conditional flow memoization, and Wasm target - #1

Draft
teamchong wants to merge 27 commits into
mainfrom
perf/checker-memory-and-wasm-speedups
Draft

teamchong wants to merge 27 commits into
mainfrom
perf/checker-memory-and-wasm-speedups

Conversation

@teamchong

@teamchong teamchong commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Goal

Explore how far Go (tsgo) can be pushed in binary size, peak memory, and typechecking speed across both WebAssembly (wasm32-wasip1) and native CLI (darwin-arm64), and measure what gap remains against a native Rust port (tsrs, commit e906c9e4) after matching checker algorithms and data layouts.


Benchmark Results

1. Native CLI (darwin-arm64, Apple Silicon, 3 Interleaved Runs)

Project Mode tsgo Baseline (main) tsgo-opt (This PR) tsrs (Default / --fast) tsgo-opt vs main tsgo-opt vs tsrs
doom-cold (67 MB .d.ts) --singleThreaded 3.51s (71.35B inst, 1,556 MiB) 1.69s (38.9B inst, 744 MiB) 1.13s (27.38B inst, 560 MiB) -51.9% wall, -45.5% inst, -52.2% RSS 1.50x wall, 1.42x inst, 1.33x RSS
doom-cold (67 MB .d.ts) Multi-threaded 2.15s (67.07B inst, 1,732 MiB) 1.11s (33.7B inst, 858 MiB) 1.13s (27.38B inst, 560 MiB) -48.4% wall, -49.8% inst, -50.5% RSS 0.98x wall, 1.23x inst, 1.53x RSS
doom-warm (--api, fuel 12288) Warm chunks 4..8 ~1.00s/chunk ~0.61s/chunk (20,100 units/s) ~0.48s/chunk ~2.9x faster warm per-chunk 1.27x wall
doom-full (150 files, 1.58M LoC) --singleThreaded 7.30s (143.11B inst, 2,025 MiB) 2.30s (36.73B inst, 728 MiB) 4.25s (84.18B inst, 865 MiB) -68.5% wall (3.17x), -74.3% inst (3.90x), -64.0% RSS (-1,297 MiB) 0.54x wall (1.85x faster), 0.44x inst (2.29x fewer), 0.84x RSS (-137 MiB)
doom-full (150 files, 1.58M LoC) Multi-threaded 6.26s (153.67B inst, 2,378 MiB) 1.94s (39.34B inst, 909 MiB) 3.64s (85.85B inst, 965 MiB) -69.0% wall (3.23x), -74.4% inst (3.91x), -61.8% RSS (-1,469 MiB) 0.53x wall (1.88x faster), 0.46x inst (2.18x fewer), 0.94x RSS (-56 MiB)
openclaw-full (11,505 files) --singleThreaded 9.45s (115.92B inst, 3,076 MiB) 6.45s (71.8B inst, 2,385 MiB) 4.96s (52.49B inst, 1,325 MiB) / 4.89s (--fast, 50.16B, 1,276 MiB) -31.7% wall, -38.1% inst, -22.5% RSS (-691 MiB) 1.30x wall, 1.37x inst, 1.80x RSS
openclaw-full (11,505 files) Multi-threaded 3.65s (145.59B inst, 3,284 MiB) 2.14s (82.5B inst, 2,630 MiB) 2.50s (55.39B, 1,469 MiB) / 1.95s (--fast, 56.35B, 1,511 MiB) -41.4% wall (1.71x), -43.3% inst (1.76x), -19.9% RSS (-654 MiB) 0.86x wall vs default (14% faster) / 1.10x vs --fast
openclaw-core (5,010 files) --singleThreaded 3.43s (51.82B inst, 1,051 MiB) 2.05s (24.7B inst, 802 MiB) 1.75s (18.80B, 458 MiB) / 1.71s (--fast, 18.09B, 438 MiB) -40.2% wall, -52.3% inst (2.10x), -249 MiB RSS 1.20x wall, 1.31x inst, 1.75x RSS
openclaw-core (5,010 files) Multi-threaded 1.25s (68.58B inst, 1,229 MiB) 0.66s (28.6B inst, 932 MiB) 0.83s (20.86B, 544 MiB) / 0.63s (--fast, 21.34B, 579 MiB) -47.2% wall (1.89x), -58.3% inst (2.40x), -297 MiB RSS 0.80x-1.05x wall, 1.37x inst, 1.71x RSS
capnweb (120 files, 3.4M inst) --singleThreaded 2.21s (42.12B inst, 568 MiB) 1.00s (14.0B inst, 332 MiB) 0.80s (12.93B, 263 MiB) / 0.80s (--fast, 12.84B, 231 MiB) -54.8% wall (2.21x), -66.8% inst (3.01x), -236 MiB RSS 1.25x wall, 1.08x inst, 1.26x RSS
capnweb (120 files, 3.4M inst) Multi-threaded 1.30s (49.41B inst, 689 MiB) 0.58s (16.0B inst, 388 MiB) 0.49s (15.33B, 322 MiB) / 0.49s (--fast, 15.32B, 287 MiB) -55.4% wall (2.24x), -67.6% inst (3.09x), -301 MiB RSS 1.18x wall, 1.04x inst, 1.20x RSS
strada-compiler (428 files, 344K LoC) --singleThreaded 1.73s (21.15B inst, 521 MiB) 0.80s (10.7B inst, 326 MiB) 0.60s (8.68B, 171 MiB) / 0.59s (--fast, 8.44B, 167 MiB) -53.8% wall (2.16x), -49.4% inst (1.98x), -195 MiB RSS 1.33x wall, 1.23x inst, 1.91x RSS
strada-compiler (428 files, 344K LoC) Multi-threaded (4c) 0.60s (25.89B inst, 602 MiB) 0.24s (12.1B inst, 404 MiB) 0.21s (9.75B, 216 MiB) / 0.17s (--fast, 10.15B, 244 MiB) -60.0% wall (2.50x), -53.3% inst (2.14x), -198 MiB RSS 1.14x wall vs default, 1.24x inst, 1.87x RSS
strada-all (751 files, 447K LoC) --singleThreaded 1.37s (24.91B inst, 523 MiB) 0.93s (11.9B inst, 380 MiB) 0.68s (9.08B, 212 MiB) / 0.66s (--fast, 8.77B, 205 MiB) -32.1% wall (1.47x), -52.2% inst (2.09x), -143 MiB RSS 1.37x wall, 1.31x inst, 1.79x RSS
strada-all (751 files, 447K LoC) Multi-threaded (4c) 0.51s (30.05B inst, 605 MiB) 0.28s (13.7B inst, 470 MiB) 0.25s (10.25B, 261 MiB) / 0.19s (--fast, 12.10B, 336 MiB) -45.1% wall (1.82x), -54.4% inst (2.19x), -135 MiB RSS 1.12x wall vs default, 1.13x inst vs --fast, 1.40x RSS

2. WebAssembly (wasm32-wasip1, Node 24 WASI, 3 Interleaved Runs via tools/wasm/bench.mjs)

Metric / Project tsgo-go127.wasm (Go 1.27) tsgo-tinygo-fast.wasm (This PR, TinyGo + LLVM) tsrs.wasm (Rust e906c9e4) tsgo-opt vs Go 1.27 tsgo-opt vs tsrs.wasm
Wasm binary size 34.9 MiB (38.0 MiB raw) 10.6 MiB 13.8 MiB (14.2 MiB) -70% -24% smaller
strada (428 files, 344K LoC) 4.73s cold / 4.40s warm (336 MiB) 1.26s cold / 1.09s warm (246 MiB) 1.37s cold / 1.17s warm (259 MiB) 4.0x faster, -27% RAM 0.92x cold / 0.93x warm (7% faster), -5% RAM
capnweb (120 files, 75K LoC) 6.07s cold / 5.78s warm (420 MiB) 1.54s cold / 1.41s warm (358 MiB) 1.64s cold / 1.48s warm (355 MiB) 4.1x faster, -15% RAM 0.94x cold / 0.95x warm (5% faster), 1.01x RAM
openclaw-core (5,010 files, 1.04M LoC) 11.43s cold / 11.04s warm (841 MiB) 2.95s cold / 2.77s warm (606 MiB) 3.23s cold / 3.00s warm (503 MiB) 4.0x faster, -28% RAM 0.91x cold / 0.92x warm (8% faster), 1.20x RAM
openclaw-full (13,899 files, 2.98M LoC) OOM (>4 GiB) 8.42s cold / 8.22s warm (1,814 MiB) 10.24s cold / 9.86s warm (1,371 MiB) OOM -> 8.22s 0.82x cold / 0.83x warm (17% faster), 1.32x RAM
3-Project Geomean (Warm) 6.55s 1.62s 1.73s 4.0x faster 0.94x overall (6% faster)
4-Project Geomean (Warm) — 2.43s 2.67s — 0.91x overall (9% faster)

3. Doom Type-Level Wasm Runtime (12 Chunks, Node.js V8, Persistent WebAssembly.Instance)

Checker 12 Chunks Total Median Chunk vs tsgo-opt (This PR)
tsgo-opt (This PR, TinyGo wasm32-wasip1, 9.89 MiB / 3.51 MiB gz) 16.90s 929ms 1.00x
tsrs (wasm32-wasip1, 13.8 MiB) 41.81s 2,540ms 2.47x slower (2.73x by median)
tsgo Baseline (GOOS=js GOARCH=wasm, Go 1.27) 361.67s 13,463ms 21.40x slower (14.49x by median)

Toolchain & Runtime Analysis (tsgo vs tsrs)

Where tsgo beats tsrs

  • WebAssembly (wasm32-wasip1): tsgo-tinygo-fast.wasm (9.89 MiB (3.51 MiB gzipped)) beats tsrs.wasm (13.8 MiB) across all 4 benchmarks (0.91x 4-project warm geomean, 9% faster, 24% smaller), and completes openclaw-full (2.98M LoC) in 8.22s (1.81 GiB) where tsrs.wasm hits the 4 GiB Wasm32 address-space limit. Because TinyGo uses 32-bit pointers on wasm32 and compiles through LLVM, Go's pointer-size and codegen disadvantages disappear on Wasm.
  • Multi-threaded Native CLI on large file-count repos: tsgo-opt beats tsrs default on openclaw-full (2.14s vs 2.50s, 14% faster), openclaw-core (0.66s vs 0.83s, 20% faster), and doom-cold (1.11s vs 1.13s, 2% faster).

Why stock TinyGo failed on tsgo before, and what we changed to make it work

Earlier attempts to compile tsgo with TinyGo (e.g. upstream typescript-go TinyGo experiments) ran into three layers of blockers: Go 1.26 wasip1 stdlib incompatibilities, 32-bit pointer/int assumptions in tsc, and pathological runtime/allocator behavior in stock TinyGo 0.43.0 on compiler workloads. Commit 8 (aefc84a48a) fixes all of them via tools/scripts/testdata/tinygo-overlay and tools/scripts/build-wasm-tinygo.mjs:

# Blocker with stock TinyGo 0.43.0 Symptom on tsgo Fix in this PR (aefc84a48a)
1. Go 1.26 wasip1 stdlib & os/exec Fails to compile ((*poll.FD).WaitWrite missing in TinyGo's fd_wasip1.go for Go 1.26+ net/fd_unix.go; fd_wasip1.go calls time.AfterFunc / runtime_netpoll_* under -scheduler=none; os/exec unsupported on wasm; testing.Testing() panics during bundled.go init) Added WaitWrite and guarded wait/parkUntil with task.OnSystemStack() in tinygo-overlay/src/internal/poll/fd_wasip1.go, split contentmapper/hostimpl_{wasm,notwasm}.go, and guarded bundled.go init
2. -scheduler=asyncify & 64 KiB stack default Stock wasip1.json defaults to "scheduler": "asyncify" and 64 KiB wasm-ld stack; Asyncify instruments every call frame (2-3x slowdown & binary bloat), and a 64 KiB stack overflows on deep AST/type recursion Built with -scheduler=none -ldflags="-extldflags '-z stack-size=16777216'" (16 MiB linear stack), and gated goroutine spawns in orchestrator.go, program.go, and fswatch/debounce.go on GOOS == "wasip1" || GOARCH == "wasm"
3. 32-bit uintptr / int overflows in tsc lazySentinelCap = 1 << 30 overflows 32-bit byte slice length in unsafe.Slice; (uintptr(unsafe.Pointer(flow)) * 0x9e3779b97f4a7c15) >> 53 overflows 32-bit uintptr in flow.go Lowered lazySentinelCap to 1 << 28 in tsc/internal/ast/lazylist.go and widened pointer hashes to uint64(uintptr(...)) before shifting in tsc/internal/checker/flow.go
4. growHeap() int32 overflow & doubling OOM at >2 GiB Stock growHeap() in arch_tinygowasm.go multiplies wasm_memory_size(0) * wasmPageSize in signed int32 (overflowing at 2 GiB) and doubles memorySize in one wasm_memory_grow call (failing once the heap passes 2 GiB on openclaw-full) Patched growHeap() in tinygo-overlay/src/runtime/arch_tinygowasm.go to cast wasm_memory_size to uintptr before multiplying and cap growth steps at 64 MiB (1,024 pages) with halving backoff up to 65,535 pages
5. Pure bump allocator in -gc=leaking Stock gc_leaking.go never reuses freed slices/maps and leaves HeapAlloc = 0 in ReadMemStats, so every resized slice and map bucket leaks permanently and checkerpool.go's --maxMemory recycling never triggers Replaced tinygo-overlay/src/runtime/gc_leaking.go with a size-classed free-list allocator (freeLists[257] up to 2 KiB + 256-entry large-block ring buffer + 4 KiB tiny slab) and wired gcLiveAlloc/gcTotalAlloc into runtime.ReadMemStats
6. Power-of-2 slice growth & leaked backing arrays Stock sliceGrow in slice.go rounds every slice growth up to the next power of 2 (1 << bits.Len(newCap)), wasting up to 50% of slice memory Patched tinygo-overlay/src/runtime/slice.go to use 1.5x growth for oldCap >= 256
7. hashmap rehash leaks & indirect-value churn Stock hashmap.go heap-allocates a hashmapIterator + key/value buffers on every hashmapGrow, never frees oldBuckets or overflow buckets, and allocates a new indirect value buffer on every map overwrite when valueSize > 128 Patched tinygo-overlay/src/runtime/hashmap.go to walk and free oldBuckets + overflow chains in-place on rehash, reuse existing indirect value slots on map overwrite, and skip hashmapClear when m.count == 0
8. Post-link codegen & host FS ABI WASI preview1 path resolution and unoptimized LLVM Wasm output add I/O and dispatch overhead Added tsc/cmd/tsc/wasm_wasip1.go (matching tsrs.wasm's zero-preopen tsrs_host.fs / tsrs_host.fs_take batch VFS ABI) and post-linked with wasm-opt -O3 --enable-simd --enable-bulk-memory

Why a single-threaded native CLI (1.20x-1.37x wall, 1.08x-1.37x inst) and native RSS (1.26x-1.91x) gap remains

Both upstream tsgo and tsrs use unsafe extensively (tsgo uses unsafe.Pointer header downcasts policed by tools/customlint/unsafelayout.go; tsrs has 498 unsafe sites across 83 files for 'static lifetime erasure, manual unsafe arena slab deallocation, repr(C) tagged-header casts, 31 unsafe impl Send/Sync declarations, and a 32 GiB mmap pointer-compression region). After matching checker algorithms and struct layouts, the remaining gap on 64-bit native CLI comes down to three structural differences between Go (cmd/compile + 64-bit Go runtime) and Rust + LLVM:

  1. 32-bit P<T> compressed pointers + 12B ThinSlice/PackedStr vs 64-bit Go pointers (8B) and 24B slice headers:
    In tsrs, P<T> is a 4-byte NonZeroU32 handle storing (addr - BASE) >> 3 into a process-wide 32 GiB mmap reservation, transparently dereferenced via unsafe pointer arithmetic (*(BASE + (handle << 3))) inside impl Deref for P<T> (cutting peak RSS by 14-15% at the cost of +6.5% instructions), alongside 12-byte ThinSlice/PackedStr headers. In 64-bit Go, every *Type, *Symbol, and *Node is 8 bytes, string is 16 bytes, and []*T is 24 bytes (ptr, len, cap).
  2. Go map runtime calls vs inlined SIMD hashbrown + FxHash:
    While this PR replaced the checker's hottest internal maps (Relation.results, typeCacheTable, StructuredType.MemberIndex, FlowMemo, inferMemoEntries, unionFrontSlots) with custom flat open-addressed tables, SymbolTable (type SymbolTable map[string]*Symbol) and cachedTypes still call out-of-line into runtime.mapaccess1_faststr / runtime.mapaccess2_fast64 (~3-4% of total CPU), whereas Rust's hashbrown inlines 16-byte NEON group probes with a single-multiply hash.
  3. cmd/compile codegen, PGO/BOLT, & GC write barriers vs LLVM:
    Go's compiler (cmd/compile) emits GC write barriers (runtime.gcWriteBarrier2) on every pointer store into arena structs, inserts stack-split checks in non-leaf function prologues, and enforces an 80-node AST inline budget (where any non-inlined slow-path call consumes 57 of the 80 points), whereas tsrs compiles through LLVM with fat LTO, 1 codegen unit, PGO, and mimalloc.

Can this remaining native gap be closed in Go?

  • Not automatically via compiler flags or LLVM passes: Standard Go on 64-bit targets (darwin-arm64, linux-amd64) has no compressed-pointer mode (amd64p32 was removed in Go 1.15), and even an LLVM backend on a 64-bit target cannot automatically rewrite 64-bit *Type / *Symbol / *Node pointers and 24-byte []*T slice headers into 32-bit mmap-relative offsets or inline Go runtime map calls.
  • Achievable only via a whole-codebase handle & table refactor: Unlike Rust, where impl Deref for P<T> lets 4-byte P<T> handles keep t.flags / s.decl_header field syntax, Go lacks Deref, ->, and [] operator overloading (as well as macros). Closing the remaining gap in 64-bit Go therefore requires a source-wide refactor across ~150K LoC:
    1. Replace *Type, *Symbol, and *Node pointers with 4-byte uint32 handles (P[T] or arena IDs) and rewrite every field access (t.flags, s.declHeader, n.Kind) into explicit .Get() calls. Making arena structs pointer-free (noscan) also eliminates GC pointer scanning and runtime.gcWriteBarrier2 write barriers on all arena mutations.
    2. Replace type SymbolTable map[string]*Symbol (table[name], table[name] = sym, for k, v := range table across ~400 call sites in ast, binder, checker, emit, and ls) with a custom flat open-addressed struct (table.Get(name), table.Set(name, sym)).

What Changed

1. Conditional Flow, Relation, & Inference Memoization

  • Conditional flow cleanliness (NodeFlagsCondFlowClean + TypeNodeLinks.resolvedFlowType + filterPossiblyReferencedTypeParameters):
    • Nodes with no conditional TrueType, unnamed MappedType, or ParameterDeclaration ancestor up to the enclosing statement set ast.NodeFlagsCondFlowClean (1 << 29 on node.Flags) and return getTypeFromTypeNodeWorker(node) directly without touching c.typeNodeLinks.
    • Nodes inside conditional branches cache resolvedFlowType in TypeNodeLinks once len(c.typeResolutions) == c.resolutionStart.
    • getTypeFromConditionalTypeNode and getObjectTypeInstantiation batch isTypeParameterPossiblyReferenced via filterPossiblyReferencedTypeParameters, walking the subtree once instead of once per outer type parameter.
  • Lazy conditional simplification & fast paths (getSimplifiedConditionalTypeWorker, getConditionalTypeInstantiationEx, instantiateMappedType):
    • getSimplifiedConditionalTypeWorker checks whether rawTrue (d.root.node.TrueType) is a non-reverse-mapped ObjectType (ObjectFlagsAnonymous | ObjectFlagsClassOrInterface | ObjectFlagsMapped) when actualCheck is not an ObjectType, returning t immediately without allocating conditionalTypeExtra or instantiating TrueType / FalseType. When falseType is not never, it checks getActualTypeVariable(falseType) == actualCheck before instantiating TrueType (cutting 504K instantiations, 126K symbols, and 84K types on capnweb vs tsrs).
    • Added 1-parameter distributive conditional mapper fast path in getConditionalTypeInstantiationEx, reused already-instantiated checkType/extendsType in newConditionalType (with packedSingleTypeKey caching on root.instantiations for 1-parameter conditional roots), and fast-pathed getPermissiveInstantiation/getRestrictiveInstantiation when !c.couldContainTypeVariables(t).
    • Added packedSingleTypeKey caching in instantiateMappedType for 1-parameter homomorphic mapped types (t.AsObjectType().instantiations), and cached firstParamType inline on Signature alongside extra.paramTypes in signatureExtra to avoid transient ast.Symbol allocations in getTypeOfSignatureParameterSymbol.
  • Cross-walk flow & inference memoization:
    • flowMemo (tsc/internal/checker/flow.go, tsc/internal/checker/flowmemo.go) caches pure getTypeAtFlowNode sub-walks across references, using a 64-bit pointer hash ((uint64(uintptr(unsafe.Pointer(flow)))*0x9e37_79b9_7f4a_7c15)>>62 == 0) to sample 1/4 of flow nodes without alignment-bit collisions.
    • Direct-mapped 2,048-slot generation-tagged flowReachableSlots table (probed only on FlowFlagsShared nodes) replaces per-file map[uintptr]bool allocations and clear() loops in isReachableFlowNodeWorker / isPostSuperFlowNodeWorker.
    • someGenericTypeWithUnionConstraint skips walking union constituents in getNarrowableTypeForReference when ObjectFlagsCouldContainTypeVariablesComputed is set with ObjectFlagsCouldContainTypeVariables == 0.
    • inferFromTypes (tsc/internal/checker/inference.go) packs InferenceKey into a uint64 and memoizes pure multi-step inference walks (inferMemoSteps >= 16). InferenceState.visited uses an epoch-cleared flat open-addressed probe table with inline 16-slot storage (visitedInline).
    • Conditional and overload inference contexts (acquireConditionalInferenceContext / releaseConditionalInferenceContext) and InferenceInfo structs are pooled and recycled whenever their mapper does not escape (!context.escaped).
    • inferFromProperties, inferFromSignatures, inferFromIndexTypes, and applyToParameterTypes check couldContainTypeVariables on target property/parameter/this/rest types and non-empty target signatures/indexInfos before resolving or instantiating source members/signatures.
    • Stack-allocated sourceBuf [1]*Type and matchedSourcesBuf/matchedTargetsBuf [8]*Type in inferFromTypes / inferFromMatchingTypes, and typeCacheIsReducibleIntersectionComputed caching on Type.cacheFlags in isReducibleIntersection, eliminating uniqueLiteralFilledInstantiation on IntersectionType (96B -> 88B).
  • Relation fast paths, sorted union subset scan, & intersection recursion Bloom filter (tsc/internal/checker/relater.go, tsc/internal/checker/checker.go):
    • Compacted simpleRelationMap slots from 12 bytes to 8 bytes (uint64: 54-bit (source.id, target.id) key + five 2-bit relation slots, upgrading to genericResults only when variance/overflow reporting bits are set), cutting simpleRelationMap memory by 33% and using a single 64-bit multiply hash.
    • Unrolled the first probe slot of simpleRelationMap.get so 1st-probe hits/misses inline directly into Relation.get.
    • Added linear pointer-identity subset scanning in isTypeSubsetOfUnion, getSortOrderFlags bounds checks in containsType, and $O(1)$ neighbor checks in containsRegularLiteralType, eliminating $O(N \log M)$ CompareTypes binary searches when relating unions.
    • Split isSimpleTypeRelatedTo into an inline fast path for enum-literal equality and TypeFlagsStructuredOrInstantiable pairs, and inlined getRecursionIdentityTarget by breaking mutual recursion with getRecursionIdentityTargetSlow.
    • Added a lazy 64-bit Bloom filter (relaterStackEntry.bloom) and recursionIdIntersection sentinel for intersection types on r.sourceStack / r.targetStack in isDeeplyNestedOnStack, avoiding e.t / e.target pointer dereferences on stack scan misses.

2. Lazy Anonymous & Mapped Member Queries & Compact AST / Symbol / Type Layouts

  • Lazy signature parameters, MemberLayout / LazyState, & anonymous/mapped queries (tsc/internal/checker/checker.go, tsc/internal/checker/types.go, tsc/internal/checker/relater.go):
    • SignatureFlagsUninstantiatedParameters and SignatureFlagsUninstantiatedThisParameter defer symbol slice and thisParameter instantiation in instantiateSignatureEx until getSignatureParameters / getSignatureThisParameter is queried, eliminating 97.6% of signature parameter symbol slice allocations across capnweb, strada, and openclaw-core.
    • Integrated upstream MemberLayout / LazyState (MemberLayout / LazyState) and mapped-type member resolution with symbolListArena, memberInfoListArena, pointer-free memberInfoMap map[string]int32 (skipped for $\le 3$ members), direct InterfaceType.declaredMemberLayout caching for 0-baseType generics, and 128-bit packed CacheHashKey for 1-baseType generics.
    • Extended getPropertiesOfTypeLazily / getLayoutPropertiesOfType, getMemberOfStructuredTypeEx, everyPropertyOfStructuredType, isEmptyObjectType, getSignaturesOfStructuredType, and getIndexInfosOfStructuredType to answer queries on unresolved instantiated anonymous types (ObjectFlagsAnonymous, d.target != nil) and homomorphic mapped types (getLazyMappedTable(t)) alongside MemberLayout / LazyState without eagerly resolving all members or instantiating signatures.
    • Added arena-backed slice allocations (signatureListArena, indexInfoListArena, typeListArena, valueSymbolExtraArena, symbolListArena, memberInfoListArena) on Checker, stack-buffered TupleNormalizer (typesBuf/infosBuf [8]), and fast findMixins in resolveIntersectionTypeMembers.
  • Byte-offset lookup tables replacing multi-arm Kind / typeKind switches (tools/scripts/generate-go-ast.ts, tsc/internal/ast/ast.go, tsc/internal/checker/types.go):
    • Replaced generated multi-case switch n.Kind accessors (Node.Data, DeclarationData, ExportableData, LocalsContainerData, FunctionLikeData, ClassLikeData, BodyData, LiteralLikeData, TemplateLiteralLikeData, Name, Modifiers, SetModifiers, Text, Expression, TypeArguments, TypeParameterList, MemberList, StatementList, Type, SetType, Initializer, SetInitializer, Type.Target, Type.Mapper) with [kindFlowReduceLabelData + 1]uint8 byte-offset tables indexing directly into the concrete node struct, and kept (*Node).Text, (*Node).Expression, (*Node).MemberList, (*Node).StatementList, and (*Node).Statements within Go's 80-node inline budget.
  • 64-byte SymbolWithData, checkFlagsMerged, & NameResolver fast paths (tsc/internal/ast/symbol.go, tsc/internal/binder/nameresolver.go, tsc/internal/checker/checker.go):
    • Packed declLen (15 bits), declCap (15 bits), and LazyState (2 bits) into the 4-byte alignment padding of Symbol (declHeader uint32), added Symbol.FirstDeclaration(), and merged Symbol.data and SymbolData.Parent into Symbol.parentOrData (with CheckFlagsSharedData and declHeader = lazyStateMask for shared/instantiated symbols) so (*Symbol).Members() and (*Symbol).Exports() inline within Go's 80-node budget, shrinking SymbolWithData from 80B to 64B (1 cache line) and removing an interior pointer per symbol.
    • Added checkFlagsMerged (1 << 27) on ast.Symbol so getMergedSymbol inlines and skips c.mergedSymbols[symbol] map lookups for the 99.9% of symbols that are never keys in c.mergedSymbols.
    • Added a unified cached valueSymbolLinks.Get(symbol).resolvedType fast path at the entry of getTypeOfSymbol, and inlined getReducedType, couldContainTypeVariables (pre-setting ObjectFlagsCouldContainTypeVariablesComputed on non-TypeFlagsStructuredOrInstantiable types in newType), resolveStructuredTypeMembers, getSingleBaseForNonAugmentingSubtype (setting ObjectFlagsIdenticalBaseTypeExists to skip c.cachedTypes map probes when no equivalent base exists), and isTypeReferenceWithGenericArguments.
    • Folded MappedSymbolLinks (keyType and syntheticOrigin) into ValueSymbolLinks (extra.writeType and target), eliminating Checker.mappedSymbolLinks (symbolLinkStore[MappedSymbolLinks]).
    • Added a tight inner ancestor loop for non-active scopes and KindBlock in NameResolver.Resolve, skipped getIsDeferredContext on non-value lookups, skipped redundant ClassDeclaration / ClassExpression / InterfaceDeclaration Members() lookups when meaning&ast.SymbolFlagsTypeParameter == 0 || len(location.TypeParameters()) == 0, and deduplicated moduleExports[name] lookups on KindSourceFile / KindModuleDeclaration.
    • Pre-sized SymbolTable maps in getMembersSlow, getExportsSlow, and GetLocals from AST member/property/parameter/statement counts so large interfaces, classes, object literals, enums, and functions do not repeatedly rehash during binding.
  • Zero-sized CompositeBase, compact FlowNode, & 0-collision keyword scanner (tsc/internal/ast/ast.go, tsc/internal/ast/flow.go, tsc/internal/scanner/scanner.go):
    • CompositeBase is a zero-sized struct{} (0B on all nodes), moving SubtreeFacts to a 64-shard side map (subtreeFactsShards) and shrinking 46 AST node types by 8B on 64-bit Go; 28 hot AST node kinds are marked arena: true.
    • Merged mutually exclusive FlowNode.Antecedent (*FlowNode on non-labels) and FlowNode.Antecedents (*FlowList on FlowFlagsLabel nodes) into a single pointer field (Antecedent *FlowNode), shrinking FlowNode from 32B to 24B.
    • Replaced textToKeyword map[string]ast.Kind lookups in GetIdentifierToken and IdentifierToKeywordKind with a 0-collision 512-entry direct-mapped table (keywordTable) and replaced the per-byte scanASCIIWhile closure in scanIdentifier with a 256-byte isASCIIIdentPart table.
  • Compact ObjectType, ConditionalType, SignatureLinks, Type, & MemberIndex (tsc/internal/checker/types.go, tsc/internal/checker/checker.go, tsc/internal/checker/links.go, tsc/internal/core/arena.go, tsc/internal/core/linkstore.go):
    • Split cold StructuredType fields (members, properties, callSignatures, constructSignatures, indexInfos, objectTypeWithoutAbstractConstructSignatures) into an on-demand structuredTypeExtra side struct, keeping StructuredType at 40B and shrinking every ObjectType, TypeReference, InterfaceType, TupleType, and MappedType by 32B.
    • Split cold ConditionalType fields into conditionalTypeExtra, shrinking every ConditionalType from 72B to 32B.
    • Merged non-overlapping effectsSignature and decoratorSignature into effectsOrDecoratorSignature, shrinking SignatureLinks from 24B to 16B.
    • Packed Type.objectFlags and Type.cacheFlags inline into the 4-byte padding slot on TypeBase (24B TypeBase header) and packed 16-bit hash tags into the upper 16 bits of MemberIndex.slots (24B to 16B).
    • Shared Checker.nodeIdGen across signatureLinks, symbolNodeLinks and typeNodeLinks and Checker.symbolIdGen across valueSymbolLinks with 1,024-ID block allocation (BlockIdSize = 1024 with BlockIdOffset folded into block allocation so NodeIdGenerator.GetNodeId and SymbolIdGenerator.GetSymbolId inline), indexedArenaPageShift = 10 (32 KiB blocks <= _MaxSmallSize), direct-page PagedValueLinkStore for symbolNodeLinks, core.LinkStore for sparse symbol link stores, and split page-allocation slow paths out of PagedLinkStore, PagedValueLinkStore, nodeLinkStore, directNodeLinkStore, and symbolLinkStore.
  • Shared-data symbol reuse, global-augment Bloom filter, & fast scope/property lookups (tsc/internal/checker/checker.go, tsc/internal/checker/jsx.go):
    • checkObjectLiteral, createJsxAttributesTypeFromAttributesProperty, and createSymbolWithType now allocate 24-byte Symbol headers via c.newSharedDataSymbol(member) in c.symbolArena instead of 64-byte SymbolWithData copies in c.symbolWithDataArena.
    • instantiateSymbol skips allocating transient Symbol and ValueSymbolLinks records for uninstantiated parameter symbols (SymbolFlagsFunctionScopedVariable) whose type node is NodeFlagsCondFlowClean and structurally free of type variables (isTypeVariableFreeParameter), checks links.target's resolved type before combineTypeMappers.
    • Added a 2-hash 256-bit Bloom filter (globalObjectAugmentFilter) over the members of Object, Function, CallableFunction, and NewableFunction so property misses in getPropertyOfTypeWorker and getUnionOrIntersectionPropertySources skip fallback probes and signature checks on misses; added getResolvedPropertyOfPlainObjectType and getMemberWithHash fast paths for resolved plain objects.
    • Checked len(indexInfos) == 0 before materializing string-literal key types in getApplicableIndexInfoForName, getTypeFromIndexInfosOfContextualType, and getContextualTypeForObjectLiteralElement; cached constituent TypeFlags for unions/intersections with $\ge 8$ constituents in maybeTypeOfKind.
  • Lazy .d.ts member lists, one-shot CLI SuspendGC, & union/instantiation table pooling (tsc/internal/ast/lazylist.go, tsc/internal/parser/parser.go, tsc/internal/binder/binder.go, tsc/internal/checker/checker.go, tsc/internal/execute/tsc.go):
    • Enabled lazy .d.ts interface/class/type-literal member list parsing (parser.EnableLazyDts) and core.SuspendGC() (debug.SetGCPercent(-1) when testing == nil && MaxMemory == nil) in one-shot CLI performCompilation and performIncrementalCompilation (never in LSP, watch mode, or API), matching tsrs leaking per-thread arenas in one-shot CLI mode so Go does not repeatedly mark-scan live arenas that live until process exit.
    • Added direct-mapped 2,048-slot unionFrontSlots cache (getUnionTypeEx), 128-bit packedTypeListKeyWithAlias fast paths for $\le 3$ type arguments (and $\le 1$ type argument with $\le 1$ alias type argument), and split 216-byte keyBuilder stack frames out of instantiateTypeWithAlias, getRelationKey, getIndexedAccessKey, getTypeListKey, getUnionKey, getIntersectionKey, getTypeInstantiationKey, getConditionalTypeKey, getAliasKey, and getUnionFrontKey.
    • Added an inline 1-entry fast path (firstTypeCacheSlot) on typeCacheTable, allocated grown small tables from c.typeCacheSlotsArena, pooled grown slot slices across c.unionTypes/c.intersectionTypes/c.indexedAccessTypes via c.freeTypeCacheSlots, and skipped self-instantiation insertions on InterfaceType, TupleType, and ObjectType.
    • Pooled temporary ast.SymbolTable maps up to 4,096 entries across resolveAnonymousTypeMembers, resolveMappedTypeMembers, createInstantiatedSymbolTable, instantiateSymbolTable, getSpreadType, tryMergeUnionOfObjectTypeAndEmptyObject, createEmptyObjectTypeFromStringLiteral, and createJsxAttributesTypeFromAttributesProperty.
  • 32 KiB Arena[T] chunks, GOMAXPROCS cap, & Wasm tinygo build (tsc/internal/core/arena.go, tsc/internal/compiler/checkerpool.go, tsc/cmd/tsc/wasm_wasip1.go, tools/scripts/build-wasm-tinygo.mjs):
    • Fixed Arena.New and Arena.NewSlice passing len(a.data) instead of cap(a.data) to nextArenaSize, raised Arena[T] steady-state chunk ceiling to 32 KiB (1024 elements), and capped runtime.GOMAXPROCS to checkerCount during forEachCheckerGroupDo.
    • Added tsc/cmd/tsc/wasm_wasip1.go, tools/scripts/testdata/tinygo-overlay, and tools/scripts/build-wasm-tinygo.mjs to compile tsc with TinyGo (-opt=2 -gc=leaking -scheduler=none) + wasm-opt -O3 --enable-simd --enable-bulk-memory, producing a 9.90 MiB .wasm binary
  • Persistent Wasm .d.ts AST cache, watermark heap reset, lazy TypeAliasDeclaration RHS, & tuple/conditional fast paths (tsc/cmd/tsc/wasm_wasip1.go, tsc/internal/compiler/host.go, tsc/internal/ast/lazylist.go, tsc/internal/parser/parser.go, tsc/internal/checker/checker.go, tsc/internal/checker/mapper.go, tools/scripts/testdata/tinygo-overlay):
    • Cached parsed and bound static .d.ts SourceFiles across tsrs_run calls and rewound the bump-allocator heap to a post-init watermark (runtime.MarkHeap / runtime.ResetHeap), scoping sync.Pool, sync.Once, and sync.Map to runtime.PoolEpoch() and restoring forced lazy member/type-alias state so one Wasm instance serves repeated runs with zero static .d.ts re-parsing and flat memory.
    • Deferred .d.ts TypeAliasDeclaration RHS parsing/binding under --skipLibCheck / --noCheck (raising lazyListMaxText to 256 MiB) and added binary-searched single-member lazy lookup (LookupLazyMember) + deferred keyof for large binary-keyed .d.ts type literal tables.
    • Added direct literal-index tuple access (tryFastTupleIndexedAccess), small Set/BSet mapped-tuple fast paths (tryFastMappedTupleSet), non-generic fast paths in getConditionalType / getPermissiveInstantiation / getRestrictiveInstantiation / instantiateTypeWithAlias / CompositeTypeMapper.Map, 1-parameter SimpleTypeMapper in getTailRecursionRoot, and last-hit index hints in ArrayTypeMapper and InferenceTypeMapper. that beats tsrs.wasm on all 4 of 4 cold and warm benchmarks and on both 3-project and 4-project warm geomeans.

@teamchong
teamchong force-pushed the perf/checker-memory-and-wasm-speedups branch 2 times, most recently from 1badbc1 to 1a1a9a2 Compare October 26, 2026 12:02
@teamchong
teamchong force-pushed the perf/checker-memory-and-wasm-speedups branch 6 times, most recently from b7b9f4b to fbd936c Compare October 27, 2026 15:47
jakebailey and others added 16 commits October 27, 2026 16:18
Co-authored-by: Dimitri Mitropoulos <dimitrimitropoulos@gmail.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
Co-authored-by: typescript-automation[bot] <290192711+typescript-automation[bot]@users.noreply.github.com>
Signed-off-by: MaheshDesu <mahesh.desu3@gmail.com>
…pper records

Enforces checked construction, immutable kind tags, and checked header casts across ast.Node, checker.Type, and checker.TypeMapper records.
…e PagedLinkStore

- Fix Arena.New and Arena.NewSlice passing len(a.data) instead of cap(a.data) to nextArenaSize, and raise steady-state chunk ceiling to 32 KiB (1,024 elements).
- Add IndexedArena (1,024-element blocks) and PagedValueLinkStore so PagedLinkStore pages store 32-bit uint32 indices instead of 64-bit pointers, halving page size and eliminating GC pointer scanning of link pages.
- Split page-allocation slow paths out of PagedLinkStore.Get, PagedLinkStore.TryGet, and PagedValueLinkStore.Get so fast-path lookups inline.
- Add core.SuspendGC and --maxMemory compiler option.
… lookup tables

- Replace textToKeyword map[string]ast.Kind lookups in GetIdentifierToken and IdentifierToKeywordKind with a 0-collision 512-entry direct-mapped keywordTable keyed by (len, c0, c1, cLast).
- Replace the per-byte scanASCIIWhile closure in scanIdentifier with a 256-byte isASCIIIdentPart lookup table and inline space/tab skipping in Scan.
…ields

- Store diagnosticNode and diagnosticForNodeName directly on SymbolTrackerSharedState and evaluate accessibility diagnostics lazily via evalSymbolAccessibilityDiagnostic only when an error is reported, eliminating per-node closure allocations during declaration emit.
- Construct a fresh ImportDeclaration via NewImportDeclaration instead of mutating Kind on a cloned node.
…r lists, and fast NameResolver

- Replace multi-case Kind switches in generated and handwritten Node accessors (Data, DeclarationData, ExportableData, LocalsContainerData, FunctionLikeData, ClassLikeData, BodyData, LiteralLikeData, TemplateLiteralLikeData, Name, Modifiers, SetModifiers, Text, Expression, TypeArgumentList, TypeParameterList, MemberList, StatementList, Type, SetType, Initializer, SetInitializer) with [kindFlowReduceLabelData + 1]uint8 byte-offset tables, keeping hot accessors under Go's 80-node inline budget.
- Make CompositeBase a zero-sized struct{} with a 64-shard subtreeFactsShards table (shrinking 46 AST node types by 8B), mark 28 hot AST node kinds arena: true, and merge FlowNode.Antecedent and FlowNode.Antecedents into a single pointer field (shrinking FlowNode from 32B to 24B).
- Pack declLen (15 bits), declCap (15 bits), and LazyState (2 bits) into Symbol.declHeader, merge Symbol.data and SymbolData.Parent into Symbol.parentOrData (shrinking SymbolWithData from 80B to 64B), add Symbol.FirstDeclaration() and checkFlagsMerged, and inline (*Symbol).Members() and (*Symbol).Exports().
- Add LazyNodeList for on-demand .d.ts interface, class, and type-literal member reparsing/binding.
- Add a tight inner ancestor loop for non-active scopes in NameResolver.Resolve, skip getIsDeferredContext on non-value lookups, and pre-size SymbolTable maps in getMembersSlow, getExportsSlow, and GetLocals.
…hot paths

- Replace interface-dispatched Type, Signature, IndexInfo, and TypeMapper variants with concrete struct hierarchies and lazy extra allocations in Checker arenas.
- Replace pointer-keyed NodeLinks/SymbolLinks stores with 32-bit node/symbol ID indexed PagedLinkStore and PagedValueLinkStore tables.
- Precompute ObjectFlagsHasGenericArgumentsComputed, ObjectFlagsCouldContainTypeVariablesComputed, and ObjectFlagsIdenticalBaseTypeExists, and add unified ValueSymbolLinks.resolvedType and merged-symbol lookup fast paths.
…caches

- Add bounded flowMemo and inferMemo caches in getTypeAtFlowNode and inferFromTypes to skip repeated control-flow walks and structural inference pairs.
- Pool temporary conditional-inference contexts via acquireConditionalInferenceContext and releaseConditionalInferenceContext when contexts do not escape.
- Pack relation comparison results into pointer-free simpleRelationMap and genericRelationMap slots, cache union discriminant constituent maps, and add a recursionId bloom filter in isDeeplyNestedOnStack.
…ip1 target

- Recycle checker instances across files when heap usage exceeds --maxMemory and deduplicate parseTask fileIncludeReasons across root and sub-tasks.
- Enable lazy .d.ts member reparsing and one-shot CLI GC suspension in performCompilation and performIncrementalCompilation.
- Add build-wasm-tinygo.mjs, tinygo-overlay runtime/poll/hashmap/slice patches, and single-threaded wasm orchestrator/syncmap paths.
…lazy RHS/tuples

- Cache parsed and bound static .d.ts SourceFiles across tsrs_run invocations in wasm_wasip1 and rewind the bump-allocator heap to a post-init watermark (MarkHeap/ResetHeap) after each run.
- Scope TinyGo sync.Pool, sync.Once, and sync.Map state to the heap watermark epoch and restore forced lazy member/type-alias state so cached .d.ts ASTs hold no dangling post-watermark pointers across runs.
- Defer TypeAliasDeclaration RHS parsing/binding in .d.ts files under skipLibCheck/noCheck (raising lazyListMaxText to 256 MiB so large generated .d.ts files like Doom's 53.5 MB module.d.ts skip unused type alias bodies).
- Add a direct literal-index fast path for tuple indexed-access types and small Set/BSet mapped-tuple fast paths in the checker.
- Raise TS2589 tailCount and instantiationDepth caps on wasm to match recursive type-level workloads.
- Run wasm-opt -O3 with bulk-memory and SIMD enabled in build-wasm-tinygo.mjs when available.
@teamchong
teamchong force-pushed the perf/checker-memory-and-wasm-speedups branch from 23c7823 to 4eb3a60 Compare October 29, 2026 01:10
…hints, and lazy table lookup

- Skip permissive/restrictive instantiation and duplicate assignability checks in getConditionalType when checkType and extendsType contain no type variables.
- Fast-return non-generic types in instantiateTypeWithAlias and primitive results in CompositeTypeMapper.Map, and use SimpleTypeMapper for single-parameter tail-recursive conditionals.
- Cache the last-matched parameter index in ArrayTypeMapper.Map and InferenceTypeMapper.Map.
- Reuse a singleton emptyTypeLiteralType for empty anonymous type literals.
- Support binary-searched single-member lazy parsing/binding (LookupLazyMember) and deferred keyof for large binary-keyed .d.ts type literal tables.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants