Skip to content

Optimize subtyping cache and type object interning - #2247

Draft
soutaro wants to merge 8 commits into
masterfrom
claude/steep-type-checking-perf-2owf10
Draft

Optimize subtyping cache and type object interning#2247
soutaro wants to merge 8 commits into
masterfrom
claude/steep-type-checking-perf-2owf10

Conversation

@soutaro

@soutaro soutaro commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

This PR significantly improves performance of Steep's type checking by optimizing the subtyping cache structure and implementing type object interning for commonly-used types. The changes reduce memory allocations and improve cache lookup efficiency in hot paths.

Key Changes

Subtyping Cache Restructuring

  • Partitioned cache by context: Refactored Cache to partition results by context (self/instance/class types) into ContextBucket objects, which change far less frequently than individual type relations
  • Optimized hot path lookup: Each check_type call now looks up relations in a pre-fetched bucket, hashing a Relation object (with memoized hash) instead of building and hashing a 5-element key array
  • Reusable assumptions set: The @assumptions Set is now pooled and reused between contexts via @assumptions_pool to avoid allocations
  • Improved cache bounds handling: Introduced EMPTY_BOUNDS constant and optimized cache_bounds to avoid creating empty hashes

Type Object Interning

  • Name::Instance.intern: Caches instances by name and args, sharing types without free variables
  • Name::Singleton.intern: Caches singleton types by name
  • Literal.intern: Caches literal types by value, with string freezing for safety
  • Union.intern: Caches union types when all component types lack free variables
  • Identity-based equality fast path: Added equal? checks in == methods to make identity tests the fast path for shared instances

Interface Builder Optimizations

  • Ground shape caching: Added ground_shape_cache to cache shapes of types without free variables, sharing lazily-resolved methods between callers
  • Lazy tag rendering: Updated Steep.logger.tagged calls to use Procs for tag computation, avoiding string construction when log level filters output
  • Method overload optimization: Improved MethodOverload sorting and added method_decls_set memoization for frozen, reusable declaration sets

Additional Improvements

  • Relation hash memoization: Added @hash field to Relation to cache computed hash values
  • Substitution domain checking: Refactored Substitution#apply? to use new domain? method for cleaner variable checking
  • Type construction optimizations: Updated type_method_call to skip speculative typing when only one overload exists
  • GC batch mode: Added --gc=batch flag to steep-check.rb for manual GC control during profiling
  • RBS API updates: Updated to use RBS::Source::RBS API and added implicitly_returns_nil parameter propagation

Notable Implementation Details

  • Type interning is best-effort: types constructed with .new remain equal to shared instances via structural comparison
  • Shared instances are only cached when all component types lack free variables (self, instance, class, or type variables)
  • The EMPTY_BOUNDS constant is frozen and reused to avoid repeated empty hash allocations
  • Logger tags can now be Strings, Procs, or any object with #to_s, enabling lazy evaluation
  • All changes maintain backward compatibility while improving performance in the type checking hot path

https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo

claude added 8 commits July 6, 2026 19:08
Profiling steep-check on the app target showed GC at 32% of wall time
and allocation (Class#new) at another ~6%, with the rest spread thinly
across the type checker -- the "no single bottleneck" profile.
The largest allocation sites were Shape::MethodOverload/Entry churn in
Shape::Methods#[] and the subtyping cache key construction.

- Shape::MethodOverload#subst returns self when the substitution does
  not change the method type (MethodType#subst already short-circuits)
- Shape::Methods#[] returns the original Entry when the merged
  substitution is empty or a no-op for all overloads, instead of
  rebuilding Entry/MethodOverload/Array objects per method lookup
- Subtyping::Check#check_type computes free_variables once (it was
  computed twice per check, allocating a merged Set each time), and
  cache_bounds reuses a frozen empty Hash when there are no bounds
- Relation#hash is memoized (recomputed on every assumption-set and
  cache-key operation)
- Literal#hash no longer collides for all instances (was class-level
  constant, putting every literal type in one hash bucket)
- Union#== avoids allocating two Sets when arrays already match

Also updates bin/steep-check.rb to the current RBS/Steep APIs
(RBS::Source, implicitly_returns_nil), hoists the ConstantResolver out
of the per-file loop to match the production code path, reads files as
UTF-8, and adds a --gc=batch experiment mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo
- Rewrite cache_bounds to type check cleanly with an annotated empty
  Hash, still returning the shared frozen EMPTY_BOUNDS when no type
  variable has an upper bound
- steep-check.rb: stop disabling GC in the default (:none) mode so
  timings reflect production behavior, and print a wall/GC/allocation/
  peak-RSS summary after the run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo
Profiling with the WB-protected RBS::Location extension showed the
remaining time spread across log-tag string building, substitution
applicability checks, and subtype cache key handling.  Together these
cut a single-process check of the app target from 56s to 39s (GC time
11s to 6.5s, allocations 57M to 44M) in local measurements.

- TaggedLogging renders tags lazily: a tag can now be a Proc or any
  object, stringified only when a message is actually logged.  The hot
  call sites (per-node synthesize, per-relation check_type, per-shape,
  per-overload, per-method-entry) were building interpolated strings --
  including recursive type#to_s -- that were thrown away at the default
  log level.
- Substitution#apply? uses the memoized free_variables set instead of
  recursively walking the type with Enumerator allocations.  The new
  Substitution#domain? tells whether a single free variable is
  substituted, and MethodType#subst uses it with its own memoized
  free_variables for the no-op short-circuit.
- Subtyping::Cache is partitioned by the (self, instance, class) type
  context, fetched once per with_context.  The per-check lookup now
  hashes just the Relation (memoized hash) when there are no variable
  bounds, instead of building and hashing a 5-element key array on
  every check_type call.
- Shape::MethodOverload#initialize skips sorting for the common
  single-def case and sorts by [buffer name, position] tuples instead
  of concatenated strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo
Guided by stackprof (Class#new callers) and majo (allocation sites),
this cuts allocations of the app-target check from 44M to 33M objects
and wall time from 39s to 32s in local single-process measurements.

- Interface::Builder caches the resolved shapes of ground types (types
  without free variables) keyed by the type.  Their shapes are fully
  determined by the type itself regardless of the config, so sharing
  them also shares the lazily resolved method entries between files,
  eliminating repeated Shape/Methods/MethodOverload/MethodType/Function
  substitution chains for common types.
- type_method_call skips the speculative child typing when the method
  has a single overload -- the result is committed either way.
- Shape::MethodOverload#method_decls_set memoizes the MethodDecl set
  per method name, replacing per-call-site method_decls(...).to_set
  reconstruction; the set is frozen because overloads are shared
  through the shape caches.
- TypeEnv shares the pure-node descendants cache between TypeEnvs
  derived with #update/#merge -- descendants of a node never change,
  and the cache was previously discarded on every immutable update.
- Helper::NoFreeVariables shares a single frozen empty Set, and
  with_context reuses the assumption Set between contexts instead of
  allocating a new one per check.
- Name::Applying#subst tests applicability with Substitution#domain?
  instead of allocating a domain Set on every call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo
Measurement first: a 10-file check constructed 96,915 Name/Union/Literal
type objects of which only 4,531 were unique (95.3% duplication;
Name::Instance alone was 97.8%).  Steep types carry no location and
nothing depends on their object identity, so structurally equal types
can be shared.

- Name::Applying (Instance/Interface/Alias) and Name::Singleton gain
  `.intern` constructors backed by per-class tables.  Types with no
  type arguments are keyed by the type name alone; applied types are
  cached only when the args have no free variables, so fresh type
  variables cannot pollute the tables.
- Literal.intern shares instances per value, freezing String values to
  make the sharing safe.  Union.build routes through Union.intern for
  ground unions.
- The interning is best-effort by design: types built with `.new`
  remain equal to shared instances through the structural `#==`, which
  now short-circuits with an identity test (also added to Relation#==).
- Construction sites in Factory, Builtin, Subtyping::Check,
  Interface::Builder, TypeConstruction, SendArgs, and
  LogicTypeInterpreter are migrated to the interning constructors.

Type object constructions in the 10-file benchmark drop from 96,915 to
10,266 (-89%); the app-target check allocates 30.9M objects (from
32.7M) with peak RSS 391MB (from 427MB).  Wall time is neutral to
slightly better; the shared instances also keep their memoized
free_variables/hash across files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo
The mapped args were computed and then discarded, returning a copy
with the original args.  No diagnostics change in the test suite,
the self-type-check, or the smoke tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo
Steep's performance work depends on the RBS::Location write-barrier fix
that landed on rbs master (RUBY_TYPED_WB_PROTECTED), which is not yet in
a released rbs gem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo
The master assigned files to workers by MD5(target::path) % worker_count,
which balances file *counts* but not cost: a worker that draws several
large files becomes the long-tail that determines wall time.

PathAssignment.by_size distributes files by total file size using a
Longest-Processing-Time greedy (largest files to the least-loaded
worker). The master computes the partition once and shares it across
workers. Filtering still happens master-side in Request#as_json, so
workers are unaffected.

On a 3-way split of Steep's own lib, this cut the slowest group from
17.6s to ~13.7s versus MD5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo
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.

2 participants