Skip to content

Commit 6cb0a4c

Browse files
authored
Fix debug info attribution and module instance numbering (#61)
Operations and module instance names were attributed to the wrong source of origin in four ways: instance counts collided across separately converted hierarchies, counts were never reset between them, the new-operation walk escaped the graph and handed one node's debug info to the entire graph body, and operations reachable only through operand edges received no debug info at all.
1 parent 351e5d2 commit 6cb0a4c

4 files changed

Lines changed: 117 additions & 5 deletions

File tree

coreai_torch/_debug_locations.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,22 @@ def update_output_maps(
10711071
)
10721072
target_debug_info.output_maps.append(output_map)
10731073

1074+
def reset_module_registry(self: Self) -> None:
1075+
"""Start instance numbering again from one.
1076+
1077+
Instance counts are meant to number the instances of a class *within one
1078+
module hierarchy* -- ``Block$1``, ``Block$2``, ``Block$3`` for a model with
1079+
three of them. A submodule converted standalone is a different hierarchy:
1080+
its root is ``L__self__`` of that submodule's own type, not a path within
1081+
the model. Counting both in one sequence consumed numbers that never
1082+
appear in the emitted asset, so a three-block model reported
1083+
``Block$2..Block$4`` and had no ``Block$1`` at all.
1084+
1085+
Called between hierarchies rather than reaching into the registry, so what
1086+
the numbering promises stays stated in one place.
1087+
"""
1088+
self.module_registry = _ModuleInstanceRegistry()
1089+
10741090
@contextmanager
10751091
def record_module(self: Self, module: Module):
10761092
"""Context manager for module-level debug recording.
@@ -1152,7 +1168,9 @@ def _create_location_for_node(
11521168
def _find_new_operations(self: Self) -> list[Operation]:
11531169
"""Find operations that were added during the current operation context.
11541170
1155-
Uses op_results to find candidate operations, then checks all nested operations.
1171+
Uses op_results to find candidate operations, then walks operands to
1172+
reach the rest of the ops the lowering emitted, then checks all nested
1173+
operations.
11561174
11571175
Returns:
11581176
List of newly added operations that don't have debug info yet
@@ -1167,6 +1185,18 @@ def should_process_op(op) -> bool:
11671185
and isinstance(op, Operation)
11681186
and op not in seen_ops
11691187
and op not in self._debug_info_map
1188+
# Never step out of the graph. The walk below goes *up* through
1189+
# parents, so without this it reaches the graph op itself, and
1190+
# the nested-operation scan a few lines down then yields every
1191+
# operation in the graph -- handing the whole body the debug info
1192+
# of whichever node was being lowered at the time.
1193+
#
1194+
# The first node lowered is the one that pays: it collects every
1195+
# parameter constant materialized during graph setup. In a
1196+
# 100-layer model that was 701 of 712 constants, all reporting
1197+
# the module of the first node (`Model$1/Block$1/RMSNorm$1`), so
1198+
# every Linear's weight claimed to belong to the first norm.
1199+
and op != self._current_graph
11701200
)
11711201

11721202
operations = []
@@ -1178,6 +1208,25 @@ def should_process_op(op) -> bool:
11781208
operations.append(op)
11791209
op = op.parent
11801210

1211+
# Lowering one FX node can emit a chain of operations, of which only the
1212+
# last produces a returned result. The rest are reachable only through
1213+
# operand edges, so without this they never receive the node's debug info
1214+
# and fall through to _ensure_all_operations_have_debug_locations, which
1215+
# can give them nothing but an operation ID. aten.addmm is one such case:
1216+
# it lowers to a transpose feeding a batch matmul feeding an add, and
1217+
# only the add would keep its file, line and module hierarchy.
1218+
#
1219+
# Ops already carrying debug info belong to an earlier node and end the
1220+
# walk there, so attribution is never reassigned from one node to another.
1221+
queue = list(operations)
1222+
while queue:
1223+
for operand in queue.pop().operands:
1224+
producer = operand.owner # a Block, for a block argument
1225+
if should_process_op(producer):
1226+
seen_ops.add(producer)
1227+
operations.append(producer)
1228+
queue.append(producer)
1229+
11811230
added_operations = []
11821231
for op in operations:
11831232
added_operations.append(op)

coreai_torch/_utils.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,7 +1122,7 @@ class _ModuleInstanceRegistry:
11221122
def __init__(self) -> None:
11231123
"""Initialize empty module bookkeeping state."""
11241124
self.module_type_to_next_count: dict[str, int] = {}
1125-
self.module_instance_to_count: dict[str, int] = {}
1125+
self.module_instance_to_count: dict[tuple[str, str], int] = {}
11261126

11271127
def get_instance_count(
11281128
self,
@@ -1134,20 +1134,27 @@ def get_instance_count(
11341134
If the instance was already seen, return its existing count. Otherwise,
11351135
assign the next count for the given module type, store it, and return it.
11361136
1137+
Keyed on the instance name *and* its type. Keying on the name alone meant
1138+
one name reused for a different type inherited the other type's count:
1139+
when a submodule is converted standalone its root is ``L__self__`` of that
1140+
submodule's type, and the whole model's root is ``L__self__`` too, so the
1141+
model's root silently took the number assigned to the submodule.
1142+
11371143
Args:
11381144
module_instance_name: Unique module instance identifier.
11391145
module_type: Module type name, for example "Linear".
11401146
11411147
Returns:
11421148
The stable per-type instance count for this module instance.
11431149
"""
1144-
existing_count = self.module_instance_to_count.get(module_instance_name)
1150+
key = (module_instance_name, module_type)
1151+
existing_count = self.module_instance_to_count.get(key)
11451152
if existing_count is not None:
11461153
return existing_count
11471154

11481155
next_count = self.module_type_to_next_count.get(module_type, 0) + 1
11491156
self.module_type_to_next_count[module_type] = next_count
1150-
self.module_instance_to_count[module_instance_name] = next_count
1157+
self.module_instance_to_count[key] = next_count
11511158
return next_count
11521159

11531160

coreai_torch/converter.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,13 @@ def _perform_externalization(self, context) -> None:
409409

410410
self.exported_program = whole_program
411411

412+
# Each submodule above was converted as its own hierarchy, rooted at the
413+
# submodule, so it consumed instance numbers from a different namespace
414+
# than the whole model's -- and those numbers reach nothing in the emitted
415+
# asset. Left in, a model with three blocks reported Block$2, Block$3 and
416+
# Block$4, with no Block$1 anywhere.
417+
self._debug_info_recorder.reset_module_registry()
418+
412419
def _clean(self) -> None:
413420
"""Reset all internal state dictionaries to empty.
414421

tests/test_debug_locations.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77

88
import torch
99
import torch.nn as nn
10+
from coreai._compiler._mlir_libs._coreaiIR._bindings import mlir as _mlir
1011
from coreai._compiler.ir import Location
1112
from torch.export.exported_program import ExportedProgram
1213

13-
from coreai_torch._debug_locations import _DebugInfoRecorder
14+
from coreai_torch import get_decomp_table
15+
from coreai_torch._debug_locations import _DebugInfoRecorder, _get_nested_operations
1416
from coreai_torch.converter import TorchConverter
1517

18+
from .debugging.test_model import HierarchicalModel
19+
1620

1721
class SimpleModel(nn.Module):
1822
"""Simple test model."""
@@ -124,3 +128,48 @@ def test_debug_locations_multiple_programs() -> None:
124128
converter.add_exported_program(exported_program2, entrypoint_name="model_2")
125129
# Verification happens automatically during conversion via _verify_debuginfo_locations
126130
_ = converter.to_coreai()
131+
132+
133+
def test_intermediate_ops_of_a_lowering_keep_their_attribution() -> None:
134+
"""Test that every op a multi-op lowering emits keeps its debug info.
135+
136+
Lowering one FX node can emit a chain of operations, of which only the last
137+
produces a returned result. aten.addmm is such a case: it becomes a transpose
138+
feeding a batch matmul feeding an add. The ops that only feed another op are
139+
reachable from the returned result solely through operand edges, and when
140+
those were not followed they received no file, no line and no module
141+
hierarchy -- just an operation ID.
142+
143+
Uses HierarchicalModel because its Linear layers sit at two different depths,
144+
so the recovered hierarchy has to be the right one rather than merely present.
145+
"""
146+
model: HierarchicalModel = HierarchicalModel()
147+
example_input: torch.Tensor = torch.randn(2, 4)
148+
149+
exported_program: ExportedProgram = torch.export.export(model, (example_input,))
150+
exported_program = exported_program.run_decompositions(get_decomp_table())
151+
152+
converter: TorchConverter = TorchConverter()
153+
converter.add_exported_program(exported_program)
154+
program = converter.to_coreai()
155+
156+
matmuls = [
157+
operation
158+
for operation in _get_nested_operations(program._mlir_module.operation)
159+
if "batch_matmul" in operation.name
160+
]
161+
assert matmuls, "expected the linear layers to lower to batch matmuls"
162+
163+
for operation in matmuls:
164+
stack_trace = _mlir.get_stack_trace(operation.location) # type: ignore[attr-defined]
165+
assert stack_trace, f"{operation.name} has no module hierarchy"
166+
# The matmul belongs to the Linear that produced its weight, not to an
167+
# enclosing module and not to nothing at all.
168+
assert stack_trace[-1].startswith("Linear"), stack_trace
169+
170+
locations = _mlir.get_file_line_col_locations(operation.location) # type: ignore[attr-defined]
171+
assert locations, f"{operation.name} has no source location"
172+
assert any(
173+
location.filename.endswith(".py") and location.line >= 1
174+
for location in locations
175+
), locations

0 commit comments

Comments
 (0)