Skip to content

perf: cache interpreter registry and statement trait lookups - #719

Merged
kaihsin merged 3 commits into
mainfrom
perf/cache-interpreter-registry-and-traits
Sep 2, 2026
Merged

perf: cache interpreter registry and statement trait lookups#719
kaihsin merged 3 commits into
mainfrom
perf/cache-interpreter-registry-and-traits

Conversation

@kaihsin

@kaihsin kaihsin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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.interpreter builds its Signature -> BoundedDef table by walking every method table of every dialect with inspect.getmembers. Interpreter.__post_init__ requests that table for every interpreter instance, and Method.__call__ constructs a fresh Interpreter on every call:

def __call__(self, *args, **kwargs):
    interp = Interpreter(self.dialects)   # rebuilds the whole registry
    _, ret = interp.run(self, *args, **kwargs)
    return ret

So calling a kernel from Python costs O(size of the dialect group) per call, independent of what the kernel does. With the basic prelude (26 dialects) that dominates: in a profile of 20k calls, inspect.getmembers alone 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.register can 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. Interpreter only reads it (in, .get, []). I first returned a defensive copy, and copying per call is itself expensive enough to erase the win entirely, so interpreter() now documents the mapping as read-only.

2. Memoize Statement trait lookups

has_trait / get_trait / get_present_trait each scan cls.traits with an ABC isinstance per entry. Rewrite passes hammer these — every purity check in DeadCodeElimination goes through is_pure — and a single Fold over a 56-method call graph performs ~305k lookups.

traits is an immutable ClassVar[frozenset] fixed at class creation, so the result depends only on (cls, trait). has_trait and get_present_trait now route through get_trait so all three share one cache and one definition of matching.

Benchmark (MWE)

import gc, time, statistics
from kirin.ir import Method
from kirin.passes import Fold
from kirin.prelude import basic, basic_no_opt

@basic
def kernel(x: int) -> int:
    y = x + 1
    z = y * 2
    return z - 1

def _leaf() -> Method:
    @basic_no_opt
    def leaf(x: int) -> int:
        y = x + 1
        z = y * 2
        return z - 1
    return leaf

def _node(a: Method, b: Method, c: Method) -> Method:
    @basic_no_opt
    def node(x: int) -> int:
        p = a(x)
        q = b(p)
        r = c(q)
        return p + q + r
    return node

def build(width=8, layers=7) -> Method:
    """`layers` layers of `width` kernels; each calls three below it."""
    level = [_leaf() for _ in range(width)]
    for _ in range(layers):
        level = [
            _node(level[i % len(level)],
                  level[(i + 1) % len(level)],
                  level[(i + 2) % len(level)])
            for i in range(width)
        ]
    return _node(level[0], level[1], level[2])

def bench(label, make, run, repeats=7):
    samples = []
    for _ in range(repeats):
        payload = make()
        gc.collect()
        t0 = time.perf_counter()
        run(payload)
        samples.append(time.perf_counter() - t0)
    print(f"  {label:24s} best={min(samples):7.3f}s median={statistics.median(samples):7.3f}s")

bench("20000 kernel calls", lambda: None,
      lambda _: [kernel(3) for _ in range(20000)])
bench("Fold (56 methods)", lambda: build(),
      lambda mt: Fold(mt.dialects)(mt))
main this PR
20,000 Method.__call__ 5.155s 0.456s (11.3x)
Fold over 56-method call graph 1.237s 1.194s (~3%)

The Fold row 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

  • traits is an immutable ClassVar[frozenset] and is never reassigned anywhere in src/; the cache is keyed per (class, trait) so sibling classes cannot share entries, and a cached None is distinguished from "not cached" by a sentinel.
  • DialectGroup.data is a frozenset assigned 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.
  • 11 new tests; full suite passes (715 passed, 13 xfailed, 1 xpassed), pyright clean, pre-commit clean.

🤖 Generated with Claude Code

kaihsin and others added 2 commits September 1, 2026 18:19
`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

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

☂️ Code Coverage

current status: ✅

Overall Coverage

Statements Covered Coverage Threshold Status
11915 10753 90% 0% 🟢

New Files

No new covered files...

Modified Files

File Coverage Status
src/kirin/ir/dialect.py 92% 🟢
src/kirin/ir/group.py 91% 🟢
src/kirin/ir/nodes/stmt.py 87% 🟢
src/kirin/registry.py 93% 🟢
TOTAL 91% 🟢

updated for commit: 6b92f82 by action🐍

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-02 15:08 UTC

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kaihsin kaihsin added backport 0.22 performance: optimization Performance: issues and PRs related to runtime performance optimizations. labels Sep 1, 2026

@zhenrongliew zhenrongliew left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kaihsin
kaihsin merged commit 2481110 into main Sep 2, 2026
11 checks passed
@kaihsin
kaihsin deleted the perf/cache-interpreter-registry-and-traits branch September 2, 2026 15:08
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Backport results for 2481110

Succeeded:

kaihsin pushed a commit that referenced this pull request Sep 2, 2026
Automated backport of PR #719 (2481110)
to `release-0-22`.

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport 0.22 performance: optimization Performance: issues and PRs related to runtime performance optimizations.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants