Summary
kirin.analysis.callgraph.CallGraph does not treat ilist.map (and the other higher-order ilist statements) as calls, even though the function they apply is logically part of the call graph. As a result, any analysis or rewrite that walks the call graph silently skips the function body referenced by an ilist.map's fn operand.
Current behavior
CallGraph.__build only recognizes func.Invoke and hardcodes the isinstance check:
# kirin/analysis/callgraph.py
def __build(self, mt: ir.Method):
for stmt in mt.callable_region.walk():
if isinstance(stmt, func.Invoke): # <-- only func.Invoke
edges = self.edges.setdefault(stmt.callee, set())
edges.add(mt)
self.__build(stmt.callee)
So a statement like ilist.Map (kirin/dialects/ilist/stmts.py), which references the applied function through an SSA operand:
class Map(ir.Statement):
traits = frozenset({ir.MaybePure(), lowering.FromPythonCall()})
fn: ir.SSAValue = info.argument(types.MethodType[[ElemT], OutElemT])
...
is never added as an edge. The same applies to Foldl, Foldr, Scan, and ForEach, all of which take a fn: ir.SSAValue of MethodType.
Why it matters
Anything built on the call graph (e.g. CallGraphPass, and more generally rewrites that expect to descend into every reachable method) does not reach code that only runs inside an ilist.map closure. This forces awkward workarounds downstream — for example, a dialect statement that must be resolved at compile time cannot be marked ir.Pure if it might appear inside an ilist.map, because constant folding hoists the pure closure into an opaque constant Method and the call graph never descends into it, so the statement is never resolved.
(Concrete downstream impact: QuEraComputing/bloqade-circuit#830.)
Proposed direction
Make CallGraph trait-driven instead of isinstance(func.Invoke)-driven.
kirin already has the StaticCall trait (kirin/ir/traits/callable.py):
class StaticCall(StmtTrait, ABC, Generic[StmtType]):
@classmethod
@abstractmethod
def get_callee(cls, stmt: StmtType) -> "Method": ...
and func.Invoke carries InvokeCall(ir.StaticCall["Invoke"]). The call graph should query this trait rather than the concrete type:
for stmt in mt.callable_region.walk():
if (trait := stmt.get_trait(ir.StaticCall)) is not None:
callee = trait.get_callee(stmt)
...
That alone fixes the trait-vs-isinstance coupling, but ilist.map's callee is not a static attribute — it comes from an SSA fn operand whose value may be a constant Method (or need const-prop / resolution to find one). StaticCall.get_callee returning a single Method may not be sufficient for these higher-order statements.
So the proposal is to introduce a trait dedicated to "this statement invokes a function as part of its semantics, and here is how to find the callee(s)" that higher-order statements like ilist.map can implement — e.g. by pointing at the operand that holds the Method, and tolerating the case where the callee is only known after constant propagation. CallGraph would then enumerate edges via this trait, covering both func.Invoke and the ilist higher-order ops uniformly.
Acceptance criteria
CallGraph discovers calls via a trait, not isinstance(func.Invoke).
- A method that applies a function via
ilist.map (and Foldl/Foldr/Scan/ForEach) has an edge to that function in the call graph.
- Existing
func.Invoke behavior is unchanged.
- Regression test covering an
ilist.map over a method that itself invokes another method (transitive reachability through the closure).
Summary
kirin.analysis.callgraph.CallGraphdoes not treatilist.map(and the other higher-orderiliststatements) as calls, even though the function they apply is logically part of the call graph. As a result, any analysis or rewrite that walks the call graph silently skips the function body referenced by anilist.map'sfnoperand.Current behavior
CallGraph.__buildonly recognizesfunc.Invokeand hardcodes theisinstancecheck:So a statement like
ilist.Map(kirin/dialects/ilist/stmts.py), which references the applied function through an SSA operand:is never added as an edge. The same applies to
Foldl,Foldr,Scan, andForEach, all of which take afn: ir.SSAValueofMethodType.Why it matters
Anything built on the call graph (e.g.
CallGraphPass, and more generally rewrites that expect to descend into every reachable method) does not reach code that only runs inside anilist.mapclosure. This forces awkward workarounds downstream — for example, a dialect statement that must be resolved at compile time cannot be markedir.Pureif it might appear inside anilist.map, because constant folding hoists the pure closure into an opaque constantMethodand the call graph never descends into it, so the statement is never resolved.(Concrete downstream impact: QuEraComputing/bloqade-circuit#830.)
Proposed direction
Make
CallGraphtrait-driven instead ofisinstance(func.Invoke)-driven.kirin already has the
StaticCalltrait (kirin/ir/traits/callable.py):and
func.InvokecarriesInvokeCall(ir.StaticCall["Invoke"]). The call graph should query this trait rather than the concrete type:That alone fixes the trait-vs-isinstance coupling, but
ilist.map's callee is not a static attribute — it comes from an SSAfnoperand whose value may be a constantMethod(or need const-prop / resolution to find one).StaticCall.get_calleereturning a singleMethodmay not be sufficient for these higher-order statements.So the proposal is to introduce a trait dedicated to "this statement invokes a function as part of its semantics, and here is how to find the callee(s)" that higher-order statements like
ilist.mapcan implement — e.g. by pointing at the operand that holds theMethod, and tolerating the case where the callee is only known after constant propagation.CallGraphwould then enumerate edges via this trait, covering bothfunc.Invokeand theilisthigher-order ops uniformly.Acceptance criteria
CallGraphdiscovers calls via a trait, notisinstance(func.Invoke).ilist.map(andFoldl/Foldr/Scan/ForEach) has an edge to that function in the call graph.func.Invokebehavior is unchanged.ilist.mapover a method that itself invokes another method (transitive reachability through the closure).