perf: cache interpreter registry and statement trait lookups - #719
Merged
Conversation
`Registry.interpreter` builds its signature -> implementation table by walking every method table of every dialect in the group with `inspect.getmembers`. `Interpreter.__post_init__` asks for that table on every interpreter instance, and `Method.__call__` constructs a fresh interpreter on every call, so calling a kernel from Python in a loop rebuilds the identical table each time. The cost scales with the size of the dialect group, not the work being done. The table depends only on the dialect group and the interpreter keys, so cache it on the group. `Dialect.register` can add method tables after a group exists, so it now bumps a module-level epoch that invalidates every cached table. The cached table is shared rather than copied. `Interpreter` only reads it (`in`, `.get`, `[]`); copying it per call is itself slow enough to erase the win, so `interpreter()` documents the mapping as read-only. Calling a kernel 20000 times with the `basic` prelude (26 dialects): before 5.155s, after 0.456s (11.3x). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`has_trait` / `get_trait` / `get_present_trait` each scan `cls.traits` doing an ABC `isinstance` per entry. Rewrite passes call them constantly -- every purity check in `DeadCodeElimination` and friends goes through `is_pure` -- so a single `Fold` over a moderate call graph performs ~300k of these lookups. `traits` is an immutable `ClassVar[frozenset]` fixed at class creation, so the answer depends only on `(cls, trait)`. Memoize it, and route `has_trait` and `get_present_trait` through `get_trait` so all three share one cache and one definition of matching. This is a modest win on its own: folding a 56-method call graph goes from 1.237s to 1.194s median (~3%). It is separated from the registry cache so it can be dropped independently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Contributor
☂️ Code Coverage
Overall Coverage
New FilesNo new covered files... Modified Files
|
Contributor
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Backport results for 2481110Succeeded:
|
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
Two independent caches for values that are invariant but recomputed constantly. Both were found by profiling a compile-heavy workload where ~19s of a 38s run was
inspect.getmembers.1. Cache the interpreter registry on the dialect group
Registry.interpreterbuilds itsSignature -> BoundedDeftable by walking every method table of every dialect withinspect.getmembers.Interpreter.__post_init__requests that table for every interpreter instance, andMethod.__call__constructs a freshInterpreteron every call:So calling a kernel from Python costs O(size of the dialect group) per call, independent of what the kernel does. With the
basicprelude (26 dialects) that dominates: in a profile of 20k calls,inspect.getmembersalone accounted for 429,903 calls and ~4.8s of self time.The table depends only on
(dialect group, interpreter keys), so it is now cached on the group.Dialect.registercan add method tables after a group exists, so it bumps a module-level epoch that invalidates every cached table.One subtlety worth flagging for review: the cached table is shared, not copied.
Interpreteronly reads it (in,.get,[]). I first returned a defensive copy, and copying per call is itself expensive enough to erase the win entirely, sointerpreter()now documents the mapping as read-only.2. Memoize
Statementtrait lookupshas_trait/get_trait/get_present_traiteach scancls.traitswith an ABCisinstanceper entry. Rewrite passes hammer these — every purity check inDeadCodeEliminationgoes throughis_pure— and a singleFoldover a 56-method call graph performs ~305k lookups.traitsis an immutableClassVar[frozenset]fixed at class creation, so the result depends only on(cls, trait).has_traitandget_present_traitnow route throughget_traitso all three share one cache and one definition of matching.Benchmark (MWE)
Method.__call__Foldover 56-method call graphThe
Foldrow is the trait cache measured on its own; it is a modest win and is a separate commit so it can be dropped independently. The registry cache is the headline.As an incidental datapoint, kirin's own test suite goes from 7.40s to 2.69s.
Correctness
traitsis an immutableClassVar[frozenset]and is never reassigned anywhere insrc/; the cache is keyed per(class, trait)so sibling classes cannot share entries, and a cachedNoneis distinguished from "not cached" by a sentinel.DialectGroup.datais afrozensetassigned once in__init__, so a per-group cache cannot go stale from group mutation. The one real staleness path,Dialect.register, is handled by the epoch counter and covered by a test.pyrightclean,pre-commitclean.🤖 Generated with Claude Code