Optimize subtyping cache and type object interning - #2247
Draft
soutaro wants to merge 8 commits into
Draft
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Cacheto partition results by context (self/instance/class types) intoContextBucketobjects, which change far less frequently than individual type relationscheck_typecall now looks up relations in a pre-fetched bucket, hashing aRelationobject (with memoized hash) instead of building and hashing a 5-element key array@assumptionsSet is now pooled and reused between contexts via@assumptions_poolto avoid allocationsEMPTY_BOUNDSconstant and optimizedcache_boundsto avoid creating empty hashesType Object Interning
Name::Instance.intern: Caches instances by name and args, sharing types without free variablesName::Singleton.intern: Caches singleton types by nameLiteral.intern: Caches literal types by value, with string freezing for safetyUnion.intern: Caches union types when all component types lack free variablesequal?checks in==methods to make identity tests the fast path for shared instancesInterface Builder Optimizations
ground_shape_cacheto cache shapes of types without free variables, sharing lazily-resolved methods between callersSteep.logger.taggedcalls to use Procs for tag computation, avoiding string construction when log level filters outputMethodOverloadsorting and addedmethod_decls_setmemoization for frozen, reusable declaration setsAdditional Improvements
@hashfield toRelationto cache computed hash valuesSubstitution#apply?to use newdomain?method for cleaner variable checkingtype_method_callto skip speculative typing when only one overload exists--gc=batchflag tosteep-check.rbfor manual GC control during profilingRBS::Source::RBSAPI and addedimplicitly_returns_nilparameter propagationNotable Implementation Details
.newremain equal to shared instances via structural comparisonEMPTY_BOUNDSconstant is frozen and reused to avoid repeated empty hash allocations#to_s, enabling lazy evaluationhttps://claude.ai/code/session_011WWMEZWwe2481yi4BZNKLo