-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathcapture_hook.py
More file actions
1800 lines (1609 loc) · 78.3 KB
/
Copy pathcapture_hook.py
File metadata and controls
1800 lines (1609 loc) · 78.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Standalone post-grad capture hook.
Register this hook before running ANY model with torch.compile. It captures
all fusible regions from the post-grad graph and writes standalone repro files
to a configurable output directory.
Usage:
# In your script or PYTHONSTARTUP:
from capture_hook import install_capture_hook
install_capture_hook(output_dir="/tmp/captures/my_model", label="my_model")
# Then just run your model normally:
model = ...
compiled = torch.compile(model)
compiled(inputs)
# Or as an environment variable (auto-installs on import):
REPRO_CAPTURE_DIR=/tmp/captures/my_model python my_training_script.py
The captured repros are standalone .py files with class Repro + make_inputs().
After capture, merge them into the canonical set with:
python merge_captures.py /tmp/captures/my_model --canonical-dir repros/
"""
import collections
import copy
import hashlib
import json
import os
import sys
from pathlib import Path
from typing import Any
import torch
import torch.fx as fx
import torch._inductor.config as inductor_config
from full_graph_harness import (
_is_fake_or_meta_tensor as _is_fake_or_meta,
infer_index_bounds_from_gm,
infer_permutation_indices_from_gm,
placeholder_info_from_gm,
)
# Single source of truth for the emitted version marker: the generated repro
# template stamps CURRENT_REPRO_VERSION rather than a hardcoded literal, so a
# future format bump (changing only the constant) can never leave this writer
# stamping a stale version -- the exact bug the v3 migration hit when it
# bumped the template but not repro_harness.CURRENT_REPRO_VERSION.
from repro_harness import CURRENT_REPRO_VERSION
# ============================================================================
# Shared partitioning + pattern hashing (single source of truth)
#
# These module-level helpers define EXACTLY how the capture pipeline cuts a
# post-grad graph into fusible partitions and how each partition is
# content-addressed (pattern_hash / shape_hash). They are used both by the
# capture path (_CaptureState.process_graph) and by offline accounting tools
# (scripts/model_graph_accounting.py), guaranteeing that accounting partitions
# are identical to how canonical repros were originally cut.
# ============================================================================
import operator as _operator
# Pure view/metadata ops the partitioner treats as transparent: they may be
# absorbed into a fusible partition but never constitute compute on their own.
TRANSPARENT_OPS = {
_operator.getitem,
torch.ops.aten.view.default,
torch.ops.aten.reshape.default,
torch.ops.aten.permute.default,
torch.ops.aten.slice.Tensor,
torch.ops.aten.unsqueeze.default,
torch.ops.aten.squeeze.default,
torch.ops.aten.squeeze.dim,
torch.ops.aten.expand.default,
torch.ops.aten.t.default,
torch.ops.aten.transpose.int,
torch.ops.aten.select.int,
torch.ops.aten.as_strided.default,
}
def partition_node_is_supported(node: fx.Node) -> bool:
"""Capture-pipeline fusibility test for a single node.
A node belongs in a fusible partition if it is a transparent view op or
Inductor's is_fusible_node says it has a fusible lowering (no BLAS/cuDNN
flop-counter ops, no fallbacks, no collectives).
"""
from torch._inductor.fx_passes.fusion_regions import is_fusible_node
if node.op == "call_function" and node.target in TRANSPARENT_OPS:
return True
return is_fusible_node(node)
def graph_node_accounting(gm: fx.GraphModule, components=None) -> dict:
"""Exhaustive fusibility classification of every call_function node.
Serialized into each full_graph_*.meta.json so the capture-time
fusible/non-fusible decision is an auditable artifact, not a transient:
if Inductor's is_fusible_node changes between pytorch versions, the
manifest diff shows exactly which ops moved buckets. Every node lands
in exactly ONE bucket and the counts must sum to total_call_functions
(enforced by tests/test_canonical_invariants.py).
fusible_in_partition: in a compute partition -> covered by a
canonical repro's pattern hash
fusible_unpartitioned: fusible/transparent but placed in no
partition (dangling views etc.) -> not a kernel, claimed by
neither side of the attribution identity
non_fusible: extern/fallback (BLAS, cuDNN, sdpa, ...) -> the
extern side of model_attribution
All three buckets log the aggregate op-target -> count set (graph
level, never per-partition — partition contents live in the canonical
repros). A misclassification in EITHER direction is then visible in
the artifact: an extern op wrongly marked fusible shows up in the
fusible set, and vice versa.
"""
if components is None:
components = get_fusion_partitions(gm)
partitioned = set()
for comp in components:
partitioned.update(comp)
buckets = {
"fusible_in_partition": collections.Counter(),
"fusible_unpartitioned": collections.Counter(),
"non_fusible": collections.Counter(),
}
total = 0
for node in gm.graph.nodes:
if node.op != "call_function":
continue
total += 1
# builtins str() as "<built-in function getitem>" — spell them
# module.name (operator.getitem) like every other op.
if isinstance(node.target, torch._ops.OpOverload):
name = str(node.target)
else:
name = (f"{getattr(node.target, '__module__', '')}."
f"{getattr(node.target, '__name__', node.target)}").lstrip(".")
if node in partitioned:
buckets["fusible_in_partition"][name] += 1
elif partition_node_is_supported(node):
buckets["fusible_unpartitioned"][name] += 1
else:
buckets["non_fusible"][name] += 1
return {
"total_call_functions": total,
"counts": {k: sum(v.values()) for k, v in buckets.items()},
"ops": {k: dict(sorted(v.items())) for k, v in buckets.items()},
}
def partition_has_reduction(nodes) -> bool:
"""True if any node in the partition is a reduction op."""
for n in nodes:
if n.op == "call_function" and isinstance(n.target, torch._ops.OpOverload):
if torch.Tag.reduction in n.target.tags:
return True
return False
def partition_has_real_compute(nodes) -> bool:
"""Check if a partition has at least one non-transparent compute op."""
for n in nodes:
if n.op != "call_function":
continue
if n.target in TRANSPARENT_OPS:
continue
# It's a real compute op (pointwise, reduction, etc.)
return True
return False
def _split_connected_components(nodes):
"""Split a list of nodes into connected components by data flow."""
from collections import deque
node_set = set(nodes)
# Build adjacency: two nodes in the partition are connected if one
# feeds directly into another (producer -> consumer).
adjacency = {n: set() for n in nodes}
for n in nodes:
# Check all args: if any arg is a node in our partition, link them
def _link_arg(x):
if isinstance(x, fx.Node) and x in node_set and x is not n:
adjacency[n].add(x)
adjacency[x].add(n)
fx.map_arg(n.args, _link_arg)
fx.map_arg(n.kwargs, _link_arg)
# BFS to find connected components
visited = set()
result_components = []
for start in nodes:
if start in visited:
continue
component = []
queue = deque([start])
visited.add(start)
while queue:
cur = queue.popleft()
component.append(cur)
for neighbor in adjacency[cur]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
result_components.append(component)
return result_components
def get_fusion_partitions(gm: fx.GraphModule) -> list:
"""Partition a post-grad GraphModule into fusible regions, EXACTLY as the
capture pipeline does.
Uses CapabilityBasedPartitioner + is_fusible_node (from
torch._inductor.fx_passes.fusion_regions) + create_op_support, with
transparent view ops allowed inside partitions and horizontal fusion
disabled (via skip_horizontal_fusion when available, otherwise a
connected-component split). Partitions without real compute are dropped —
the returned list corresponds 1:1 to the canonical repros process_graph()
would capture.
Returns: list of partitions, each a list of fx.Node from gm.graph.
"""
import inspect
from torch.fx.passes.infra.partitioner import CapabilityBasedPartitioner
from torch.fx.passes.operator_support import create_op_support
def _is_supported(_submodules, node):
return partition_node_is_supported(node)
support = create_op_support(_is_supported)
_part_kwargs = dict(allows_single_node_partition=True)
has_skip_horizontal_fusion = (
'skip_horizontal_fusion'
in inspect.signature(CapabilityBasedPartitioner.__init__).parameters
)
if has_skip_horizontal_fusion:
_part_kwargs['skip_horizontal_fusion'] = True
partitioner = CapabilityBasedPartitioner(
gm, support, **_part_kwargs,
)
partitions = partitioner.propose_partitions()
components = [list(p.nodes.keys()) for p in partitions]
if not has_skip_horizontal_fusion:
split_components = []
for comp in components:
split_components.extend(_split_connected_components(comp))
components = split_components
return [comp for comp in components if partition_has_real_compute(comp)]
def extract_partition_subgraph(origin_nodes: list, gm: fx.GraphModule):
"""Extract a standalone sub-GraphModule for one fusible partition.
Returns (sub_gm, placeholder_info, shape_params) or None.
"""
seen = set()
unique_origins = []
for n in origin_nodes:
if id(n) not in seen:
seen.add(id(n))
unique_origins.append(n)
origin_nodes = unique_origins
needed_nodes: set[fx.Node] = set(origin_nodes)
# A node is a partition output iff it has ANY use outside the partition
# (including being a graph output). Internal users are irrelevant: a node
# consumed both inside (e.g. by a sum) and outside (e.g. by a
# convolution_backward the partitioner cut away) MUST remain an output,
# or the repro under-constrains the computation — elimination passes get
# freedom the real model never grants and measured gaps don't compose
# (see investigation_results/squeezenet_scatter_e2e_validation.md).
# The escaping set: any use outside the partition (including the graph
# output node, which is never in needed_nodes) makes a node an output.
# Nodes with only internal users are internal; nodes with NO users are
# dead (upstream DCE makes this near-impossible) and are simply dropped —
# EXCEPT mutating ops (copy_, index_put_, ...): their effect is the
# in-place mutation of an input buffer, not their return value, so they
# can legitimately have zero users and must never be dropped.
def _is_mutating(n):
target = getattr(n, "target", None)
schema = getattr(target, "_schema", None)
if schema is not None:
return schema.is_mutable
name = getattr(target, "__name__", "") or str(target)
return name.rstrip(".default").endswith("_")
output_nodes = [
n for n in origin_nodes
if any(user not in needed_nodes for user in n.users) or _is_mutating(n)
]
if not output_nodes:
output_nodes = origin_nodes
# Output order must never carry information: emit outputs in DEFINITION
# order (the node's position in the source graph). origin_nodes arrives
# in whatever order the partitioner enumerated; two captures of the same
# partition could otherwise serialize different output-tuple orders and
# mint different DAG-signature hashes for identical computations.
graph_pos = {n: i for i, n in enumerate(gm.graph.nodes)}
output_nodes = sorted(output_nodes, key=lambda n: graph_pos.get(n, 0))
new_graph = fx.Graph()
env: dict[fx.Node, fx.Node] = {}
placeholder_info: dict[str, dict] = {}
def _resolve_sym(x):
"""Resolve SymInt/SymFloat to concrete int/float."""
if isinstance(x, (torch.SymInt, torch.SymFloat)):
return x.node.hint if hasattr(x, 'node') and hasattr(x.node, 'hint') else int(x)
return int(x)
def _storage_key(val):
try:
return id(val.untyped_storage())
except Exception:
return None
def _record_placeholder(name: str, meta: dict) -> None:
val = meta.get("val", None)
if val is not None and isinstance(val, torch.Tensor):
placeholder_info[name] = {
"shape": [_resolve_sym(s) for s in val.shape],
"stride": [_resolve_sym(s) for s in val.stride()] if not val.is_contiguous() else [],
"dtype": str(val.dtype),
"device": str(val.device),
}
off = _resolve_sym(val.storage_offset()) if val.storage_offset() else 0
if off:
placeholder_info[name]["storage_offset"] = off
# Alias tag: inputs whose fake vals share one untyped storage
# (packed-qkv saved views). Live-capture-only signal — any
# retrace re-fabricates inputs and the identity is gone. Tag
# is the storage key; serialization rewrites it to a small
# group index ("alias_group") so replay can allocate ONE
# buffer per group and as_strided the members. Storage SIZE
# captured here too (the true allocation) so consumers never
# re-derive it by scanning members.
sk = _storage_key(val)
if sk is not None:
placeholder_info[name]["_storage_key"] = sk
try:
placeholder_info[name]["_storage_nbytes"] = int(
val.untyped_storage().size())
except Exception:
pass
elif val is not None and isinstance(val, (torch.SymInt, torch.SymFloat)):
hint = val.node.hint if hasattr(val, 'node') and hasattr(val.node, 'hint') else int(val)
placeholder_info[name] = {
"shape": [],
"stride": [],
"dtype": "symint",
"device": "cpu",
"hint": hint,
}
_ph_names_used: set[str] = set()
def _unique_ph_name(base: str) -> str:
"""Ensure placeholder names don't collide with internal node names."""
# Internal call_function nodes get names like full_default, clone_default, etc.
# Prefix external inputs to avoid collision.
name = base
if name in _ph_names_used:
i = 1
while f"{name}_{i}" in _ph_names_used:
i += 1
name = f"{name}_{i}"
_ph_names_used.add(name)
return name
def _ensure_in_env(x: Any) -> Any:
if isinstance(x, fx.Node):
if x in env:
return env[x]
name = _unique_ph_name(x.name)
ph = new_graph.placeholder(name)
ph.meta = copy.copy(x.meta) if x.meta else {}
env[x] = ph
_record_placeholder(name, x.meta or {})
return ph
return x
_VIEW_LIKE_OPS = {
torch.ops.aten.reshape.default,
torch.ops.aten.view.default,
torch.ops.aten.expand.default,
}
shape_params: dict[str, list[int]] = {}
_shape_counter = [0]
def _should_lift_shape(shape_list):
"""Don't lift trivial shapes that are always the same."""
if not shape_list:
return False
# All dims are 0, 1, or -1 — not shape-dependent
if all(d in (0, 1, -1) for d in shape_list):
return False
return True
def _lift_shape_arg(node, args):
"""For reshape/view ops, lift the shape literal to a parameter."""
if node.target not in _VIEW_LIKE_OPS:
return args
if len(args) < 2 or not isinstance(args[1], (list, tuple)):
return args
shape_list = list(args[1])
if not _should_lift_shape(shape_list):
return args
param_name = f"_shape_param_{_shape_counter[0]}"
_shape_counter[0] += 1
ph = new_graph.placeholder(param_name)
ph.meta = {"val": shape_list}
shape_params[param_name] = shape_list
return (args[0], ph) + tuple(args[2:]) if len(args) > 2 else (args[0], ph)
all_graph_nodes = list(gm.graph.nodes)
node_order = {n: i for i, n in enumerate(all_graph_nodes)}
sorted_needed = sorted(needed_nodes, key=lambda n: node_order.get(n, 0))
# Create placeholders for ALL external dependencies first,
# so they claim clean names before internal nodes are added.
for node in sorted_needed:
if node.op in ("call_function", "call_method"):
def _pre_register(x):
if isinstance(x, fx.Node) and x not in needed_nodes and x not in env:
_ensure_in_env(x)
fx.map_arg(node.args, _pre_register)
fx.map_arg(node.kwargs, _pre_register)
for node in sorted_needed:
if node.op == "placeholder":
new_node = new_graph.placeholder(node.name)
new_node.meta = copy.copy(node.meta) if node.meta else {}
env[node] = new_node
_record_placeholder(node.name, node.meta or {})
elif node.op == "get_attr":
new_node = new_graph.get_attr(node.target)
new_node.meta = copy.copy(node.meta) if node.meta else {}
env[node] = new_node
elif node.op in ("call_function", "call_method"):
new_args = fx.map_arg(node.args, _ensure_in_env)
new_kwargs = fx.map_arg(node.kwargs, _ensure_in_env)
if node.op == "call_function":
new_args = _lift_shape_arg(node, new_args)
new_node = new_graph.call_function(
node.target, args=new_args, kwargs=new_kwargs
)
else:
new_node = new_graph.call_method(
node.target, args=new_args, kwargs=new_kwargs
)
new_node.meta = copy.copy(node.meta) if node.meta else {}
env[node] = new_node
mapped_outputs = [env[n] for n in output_nodes if n in env]
if not mapped_outputs:
return None
if len(mapped_outputs) == 1:
new_graph.output(mapped_outputs[0])
else:
new_graph.output(tuple(mapped_outputs))
new_graph.lint()
new_gm = fx.GraphModule(gm, new_graph)
return new_gm, placeholder_info, shape_params
# ----------------------------------------------------------------------------
# Hash-time canonicalization (closes the reshape/view canonical-hash fork)
# ----------------------------------------------------------------------------
#
# The same partition used to hash two different ways depending on which
# pipeline produced the graph being hashed:
#
# * Capture time: Inductor's compile_fx runs view_to_reshape(gm) BEFORE
# post_grad_passes (see torch/_inductor/compile_fx.py), so the
# post_grad_custom_pre_pass capture hook sees every view spelled as
# aten.reshape.default.
# * Retrace time: any offline path that re-traces a saved full_graph_*.py
# through make_fx (scripts/model_graph_accounting.py,
# scripts/repartition_from_graphs.py, recapture runs) sees the reshape
# decompose back to aten.view.default.
#
# Result: 167 reshape-only vs 887 view-only canonical repro dirs, with
# confirmed duplicate pairs (e.g. convnextv2 sum_sum_sum_26d1711c064d ==
# sum_sum_sum_f68c9f1fa09b — origin_ops differ ONLY by 3x reshape vs 3x view).
#
# Fix: LIGHT spelling normalization at hash time. NO retracing — retracing is
# slow, fails on device-literal mismatches for CPU-loaded graphs, and couples
# the canonical hash to the pytorch version's decomposition behavior.
_HASH_OP_SPELLING_ALIASES = {
# WHY: every aten.reshape.default in a saved post-grad graph was created
# by Inductor's view_to_reshape pass FROM a valid aten.view, so it is
# always view-able on its input and make_fx provably retraces it back to
# aten.view.default. (reshape's clone+_unsafe_view decomposition only
# fires for non-view-able inputs, which cannot occur for reshapes that
# view_to_reshape produced.) Verified on convnextv2 saved full graphs:
# make_fx retrace op-count diff is exactly -44 reshape / +44 view
# (train) and -29/+29 (infer), nothing else.
"aten.reshape.default": "aten.view.default",
}
# Ops elided at hash time (op name -> arg index whose value flows through).
# CURRENTLY EMPTY, deliberately:
#
# WHY no clone/detach elision: empirically, make_fx fake-mode retracing
# (the exact path model_graph_accounting.trace_full_graph uses) PRESERVES
# aten.clone.default and aten.detach.default. Measured on the convnextv2
# train full graph: all 14 clones survive the retrace, and both members of
# every confirmed duplicate pair still contain clone_default. Eliding
# clones here would therefore CREATE a new fork (capture-hash without
# clone vs retrace-hash with clone) instead of closing one. If a provably
# elided pattern is found later (the Milestone-2 fixed-point test reports
# residual divergences), add it here with the same level of evidence.
_HASH_ELIDED_OPS: dict[str, int] = {}
def canonicalize_for_hash(gm):
"""LIGHT graph-level normalization for pattern hashing (NO retracing).
Returns (nodes, resolve, op_name):
* nodes: node list to encode, in original order, with hash-elided ops
removed (none today; see _HASH_ELIDED_OPS).
* resolve(node): maps a node through any elided ops to the node the
signature should reference instead.
* op_name(node): canonical op spelling for the signature
(reshape -> view; see _HASH_OP_SPELLING_ALIASES).
"""
elided: dict = {}
for n in gm.graph.nodes:
if n.op == "call_function" and str(n.target) in _HASH_ELIDED_OPS:
src = n.args[_HASH_ELIDED_OPS[str(n.target)]]
if isinstance(src, fx.Node):
elided[n] = src
nodes = [n for n in gm.graph.nodes if n not in elided]
def resolve(n):
while n in elided:
n = elided[n]
return n
def op_name(n):
name = str(n.target)
return _HASH_OP_SPELLING_ALIASES.get(name, name)
return nodes, resolve, op_name
def compute_dag_signature(gm) -> list:
"""Compute a DAG-structure signature for the graph.
Encodes: for each node in topological order, its op name, which
predecessor nodes feed each argument (by index), and structural
literal args (list/tuple values like reduction dims, permute orders).
Does NOT encode scalar int/float constants (e.g., mul by 3.0,
eps=1e-6) — those don't affect kernel structure.
Op spellings are canonicalized via canonicalize_for_hash so that
trace-equivalent graphs (saved post-grad reshape-spelling vs make_fx
retrace view-spelling) hash identically.
Node ORDER is canonicalized too: nodes are renumbered by a
deterministic topological order (Kahn's algorithm, ready set
tie-broken by each node's structural encoding), so the textual
interleaving of INDEPENDENT ops (relu defined before vs after a
sibling sigmoid) never forks the hash. Placeholders keep their
original order — input position is semantics, not spelling.
"""
nodes, _resolve, _op_name = canonicalize_for_hash(gm)
# --- canonical node order: deterministic Kahn topological sort -------
node_set = set(nodes)
def _preds(n):
out = []
def visit(a):
if isinstance(a, fx.Node):
r = _resolve(a)
if r in node_set:
out.append(r)
fx.map_arg((n.args, n.kwargs), visit)
return out
def _static_key(n):
"""Order-independent structural key for tie-breaking: op spelling +
literal args (Node refs masked)."""
def mask(a):
if isinstance(a, fx.Node):
return "·"
if isinstance(a, (list, tuple)):
return [mask(x) for x in a]
return repr(a)
name = _op_name(n) if n.op == "call_function" else str(n.target)
return (n.op, name, repr(mask(list(n.args))), repr(mask(sorted(
n.kwargs.items(), key=lambda kv: kv[0]) if n.kwargs else [])))
node_to_idx: dict = {}
order: list = []
pending = []
for n in nodes:
if n.op == "placeholder":
# Input position is semantics, not spelling — keep original order.
node_to_idx[n] = len(order)
order.append(n)
else:
pending.append(n)
remaining = list(pending)
while remaining:
ready = [n for n in remaining
if all(p in node_to_idx for p in _preds(n))]
if not ready: # cycle cannot happen in fx; defensive fallback
ready = remaining[:]
ready.sort(key=lambda n: (
sorted(node_to_idx.get(p, -1) for p in _preds(n)),
_static_key(n),
))
chosen = ready[0]
node_to_idx[chosen] = len(order)
order.append(chosen)
remaining.remove(chosen)
nodes = order
# ---------------------------------------------------------------------
def _int_slots(target):
"""(positional indices, kwarg names) whose schema type is bare
'int'/'int?' (dims: cat, gather, cumsum, argmax...). Structural —
changes the kernel — so hashed. 'Scalar'-typed slots (add.Scalar
other, clamp bounds) are baked constants and stay unhashed. Same
schema-typed discrimination as the SymInt[] shape lift: the op's
own schema declares which ints are structure, no op list to
maintain. Both invocation forms covered (the same arg may arrive
positionally or as a kwarg depending on how the model called it)."""
schema = getattr(target, "_schema", None)
if schema is None:
return (), frozenset()
positions = []
names = []
for i, a in enumerate(schema.arguments):
# arg.type str-spells optional as Optional[int] (schema text
# says int?) — accept both spellings.
if str(a.type) in ("int", "Optional[int]"):
names.append(a.name)
if not a.kwarg_only:
positions.append(i)
return tuple(positions), frozenset(names)
def _encode_arg(arg, arg_idx, int_slots=()):
"""Encode an argument for the signature."""
if isinstance(arg, torch.fx.Node):
arg = _resolve(arg)
if isinstance(arg, torch.fx.Node) and arg in node_to_idx:
return ("node", node_to_idx[arg], arg_idx)
elif isinstance(arg, (list, tuple)):
# Structural args: reduction dims, permute orders, reshape targets
# Encode the structure (length + which are ints) but not concrete values
# EXCEPT for small int lists (dims) where the values matter
if all(isinstance(x, int) for x in arg):
# This is a dim list like [0, 2, 1, 3] or [-1] — encode values
return ("dims", list(arg), arg_idx)
return ("list", len(arg), arg_idx)
elif isinstance(arg, bool):
# Booleans like keepdim=True matter for output shape
return ("bool", arg, arg_idx)
elif isinstance(arg, torch.dtype):
# convert_element_type/to.dtype targets: different output dtype
# = different bytes written = different kernel. Collision found
# by adversarial review 2026-06-11 (f32 vs f16 convert hashed
# identically); pinned by tests/test_canonical_invariants.py.
return ("dtype", str(arg), arg_idx)
elif isinstance(arg, str):
# String modes (div rounding_mode, gelu approximate): select
# genuinely different computations.
return ("str", arg, arg_idx)
elif isinstance(arg, torch.memory_format):
return ("memfmt", str(arg), arg_idx)
elif isinstance(arg, int) and arg_idx in int_slots:
# Bare 'int'-typed schema slot: a dim (cat/gather/cumsum) —
# structural. 'Scalar'-typed int constants fall through below.
return ("int", arg, arg_idx)
# Scalar int/float constants — don't encode (same kernel regardless)
return None
signature = []
for node in nodes:
if node.op == "placeholder":
signature.append(("input", node_to_idx[node]))
elif node.op == "call_function":
encoded_args = []
int_slots, int_kwarg_names = _int_slots(node.target)
for arg_idx, arg in enumerate(node.args):
enc = _encode_arg(arg, arg_idx, int_slots)
if enc is not None:
encoded_args.append(enc)
# Also encode relevant kwargs (like correction, keepdim)
for kw, val in (node.kwargs or {}).items():
if isinstance(val, bool):
encoded_args.append(("kw_bool", kw, val))
elif isinstance(val, (list, tuple)) and all(isinstance(x, int) for x in val):
encoded_args.append(("kw_dims", kw, list(val)))
elif isinstance(val, torch.dtype):
encoded_args.append(("kw_dtype", kw, str(val)))
elif isinstance(val, str):
# rounding_mode="floor"/"trunc", approximate="tanh", ...
encoded_args.append(("kw_str", kw, val))
elif isinstance(val, torch.memory_format):
encoded_args.append(("kw_memfmt", kw, str(val)))
elif isinstance(val, int) and kw in int_kwarg_names:
# dim passed as kwarg (argmax(x, dim=1)) — same
# structural slot as the positional form.
encoded_args.append(("kw_int", kw, val))
signature.append((_op_name(node), encoded_args))
elif node.op == "output":
def _collect_output_indices(x):
if isinstance(x, torch.fx.Node):
x = _resolve(x)
if isinstance(x, torch.fx.Node) and x in node_to_idx:
return node_to_idx[x]
elif isinstance(x, (tuple, list)):
return [_collect_output_indices(item) for item in x]
return None
out_indices = _collect_output_indices(node.args[0])
# Output tuple order is consumption spelling, not kernel
# structure (extraction orders outputs by the ORIGINAL graph's
# def positions, which the canonical renumbering replaces) —
# sort top-level indices so output permutations of the same
# DAG hash identically.
if isinstance(out_indices, list):
out_indices = sorted(
out_indices,
key=lambda x: (x is None, x if isinstance(x, int) else repr(x)),
)
signature.append(("output", out_indices))
return signature
def pattern_hash_for_subgraph(sub_gm) -> str:
"""Content-addressed pattern hash: ops + wiring, ignoring shapes.
This is the 12-hex hash in repros/canonical/<family>_<hash> dir names.
"""
dag_signature = compute_dag_signature(sub_gm)
return hashlib.md5(json.dumps(dag_signature).encode()).hexdigest()[:12]
def shape_hash_for_placeholders(placeholder_info: dict) -> str:
"""8-hex hash of the partition's input shapes+strides+dtypes (shape config id).
Stride is part of the identity: occurrences of the same pattern at the
same shapes but different layouts (contiguous vs channels-last) are
distinct benchmark points and must not collapse to one. Contiguous
inputs record stride=[] (placeholder_info construction), which is the
canonical spelling for "contiguous", so the hash is stable across
captures of the same layout.
"""
input_shapes = sorted(
f"{info.get('shape', '?')}:{info.get('stride', [])}:{info.get('dtype', '?')}"
for info in placeholder_info.values()
)
return hashlib.md5(json.dumps(input_shapes).encode()).hexdigest()[:8]
def lift_shape_params(gm):
"""Lift concrete shape literals into _shape_param_N placeholders.
THE single lifting implementation, run on the canonical (retraced)
graph. Discriminator is SCHEMA-TYPED, not an op allowlist: any argument
occupying a SymInt[]-typed parameter slot (per the op's own schema) is a
shape; int[]-typed slots (reduction dims, permute orders) are structural
and never lifted. The op's schema declares which of its lists are
shapes — no list of ops to maintain.
Returns (gm, shape_params) — gm mutated in place and recompiled.
"""
def _symint_list_positions(target):
schema = getattr(target, "_schema", None)
if schema is None:
return ()
out = []
for i, a in enumerate(schema.arguments):
if a.kwarg_only:
continue
if "SymInt[]" in str(a.type_with_alias()) if hasattr(a, "type_with_alias") else False:
out.append(i)
if not out:
# str(schema) spells SymInt[] even where arg.type collapses to
# List[int]; parse positions from the schema string.
sig = str(schema)
args_str = sig[sig.index("(") + 1: sig.rindex("->")].rsplit(")", 1)[0]
for i, part in enumerate(args_str.split(",")):
if "SymInt[]" in part:
out.append(i)
return tuple(out)
def _should_lift(shape_list):
if not shape_list:
return False
if all(d in (0, 1, -1) for d in shape_list):
return False
return True
g = gm.graph
shape_params: dict[str, list[int]] = {}
counter = 0
last_ph = None
for n in g.nodes:
if n.op == "placeholder":
last_ph = n
for node in list(g.nodes):
if node.op != "call_function":
continue
# SymInt[] slots on NON-fusible ops (conv stride/padding/dilation,
# convolution_backward) are op configuration, not output shapes —
# lifting them would conflate different convs into one pattern.
# Partition subgraphs never contain such ops (the partitioner
# excludes them), so this guard is defense-in-depth for any caller
# handing us a full graph. Pinned by test_canonical_invariants.
if not partition_node_is_supported(node):
continue
for pos in _symint_list_positions(node.target):
if pos >= len(node.args):
continue
arg = node.args[pos]
if not isinstance(arg, (list, tuple)):
continue
if any(isinstance(x, torch.fx.Node) for x in arg):
continue # already parametric/dynamic
shape_list = list(arg)
if not _should_lift(shape_list):
continue
name = f"_shape_param_{counter}"
counter += 1
if last_ph is None:
with g.inserting_before(next(iter(g.nodes))):
ph = g.placeholder(name)
else:
with g.inserting_after(last_ph):
ph = g.placeholder(name)
ph.meta["val"] = list(shape_list)
shape_params[name] = list(shape_list)
last_ph = ph
args = list(node.args)
args[pos] = ph
node.args = tuple(args)
if shape_params:
gm.recompile()
return gm, shape_params
def canonicalize_subgraph(sub_gm, placeholder_info, shape_params=None):
"""ONE make_fx retrace of an extracted partition: the canonical form.
The settled identity invariant (CORPUS_MIGRATION_PLAN §1): one retrace is
allowed, BEFORE serialization; thereafter every trace and hash must be
identical. make_fx is deterministic given identical inputs, so the
retraced graph is a fixed point: retrace(canonical) == canonical. The
serialized artifact (repro.py) is generated FROM the canonical form, and
the pattern hash is computed FROM it — so live-cut hash, artifact hash,
and any later re-derivation cannot diverge (no spelling alias tables
needed: reshape->view, dead multi-output getitems, clone placement all
normalize in the retrace itself).
Input fidelity is the one care point: fake inputs are built EXACTLY from
placeholder_info (shape, stride, dtype, device) — wrong strides (e.g.
assuming contiguous for a channels-last tensor) would change which view
ops the retrace emits. SymInt inputs (dynamic shapes) are not yet
handled here: partitions with symint placeholders return the original
sub_gm unchanged (documented limitation; revisit with the dynamic-shapes
serialization work).
Mutation preservation: zero-user mutating ops (copy_) survive make_fx
because they are outputs of the extracted sub_gm (the 77a691d80 rule) —
make_fx keeps everything reachable from outputs. Verified by
scripts/test_partition_outputs.py.
Returns (canonical sub_gm, remapped placeholder_info), or the originals
on any retrace failure
(loudly, on stderr — a non-canonical artifact is better than no artifact,
and the roundtrip gate will flag it downstream).
"""
from torch._subclasses.fake_tensor import FakeTensorMode
from torch.fx.experimental.proxy_tensor import make_fx
_DTYPES = {
"torch.float32": torch.float32, "torch.float16": torch.float16,
"torch.bfloat16": torch.bfloat16, "torch.float64": torch.float64,
"torch.int64": torch.int64, "torch.int32": torch.int32,
"torch.int16": torch.int16, "torch.int8": torch.int8,
"torch.uint8": torch.uint8, "torch.bool": torch.bool,
}
# The sub_gm's node metas carry FakeTensors created under the ORIGINAL
# trace's FakeTensorMode. make_fx requires our fabricated inputs to share
# that mode (mixing modes is rejected) — so fish the mode out of any
# placeholder's meta val and fabricate under it.
_graph_mode = None
for _n in sub_gm.graph.nodes:
_v = _n.meta.get("val") if hasattr(_n, "meta") else None
_m = getattr(_v, "fake_mode", None)
if _m is not None:
_graph_mode = _m
break
_ph_order = [n for n in sub_gm.graph.nodes if n.op == "placeholder"]
fake_inputs = []
for name, info in placeholder_info.items():
if info.get("dtype") == "symint":
print(
f"[canonicalize] symint placeholder {name!r}: retrace skipped "
f"(dynamic-shape partitions not yet canonicalized)",
file=sys.stderr,
)
return sub_gm, placeholder_info, shape_params
dtype = _DTYPES.get(info.get("dtype"), torch.float32)
shape = info.get("shape", [])
stride = info.get("stride") or None
try:
if _graph_mode is not None:
mode = _graph_mode
else:
from torch._guards import detect_fake_mode
mode = detect_fake_mode() or FakeTensorMode(
allow_non_fake_inputs=True)
dev = info.get("device") or "meta"
with mode:
if stride:
t = torch.empty_strided(shape, stride, dtype=dtype,
device=dev)
else:
t = torch.empty(shape, dtype=dtype, device=dev)
fake_inputs.append(t)
except Exception as exc:
print(f"[canonicalize] could not fabricate input {name!r}: {exc}",
file=sys.stderr)
return sub_gm, placeholder_info, shape_params
# Lifted shape params (_shape_param_N, list-valued placeholders) must
# NOT be traced as inputs — make_fx explodes a list into one scalar
# placeholder per element, breaking the serialized signature. Instead,
# PARTIALLY APPLY them: a closure bakes the concrete values in during
# the retrace (tensor-only trace), and they are RE-LIFTED into
# placeholders afterward so the canonical graph keeps the exact original
# signature (and _shapes_config keeps its S(...) entries).
_shape_param_pos = {} # position in _ph_order -> (name, concrete value)
for _i, _ph in enumerate(_ph_order):
_v = _ph.meta.get("val") if hasattr(_ph, "meta") else None
if not (hasattr(_v, "shape") and hasattr(_v, "dtype")):
_val = list(_v) if isinstance(_v, (list, tuple)) else _v
if _val is None and shape_params and _ph.name in shape_params:
_val = shape_params[_ph.name]
if _val is None:
print(f"[canonicalize] no concrete value for shape param "
f"{_ph.name!r} — skipping retrace", file=sys.stderr)
return sub_gm, placeholder_info, shape_params
_shape_param_pos[_i] = (_ph.name, _val)
try:
with torch.no_grad():
if _shape_param_pos:
_names_in_order = [n.name for n in _ph_order]
def _bound(*tensor_args):
full, ti = [], iter(tensor_args)
for _i, _nm in enumerate(_names_in_order):
if _i in _shape_param_pos:
full.append(_shape_param_pos[_i][1])
else:
full.append(next(ti))
return sub_gm(*full)
canonical = make_fx(_bound, tracing_mode="fake")(*fake_inputs)
else:
canonical = make_fx(sub_gm, tracing_mode="fake")(*fake_inputs)
# make_fx wraps the module callable with pytree flatten/unflatten
# codegen and names the class after the callable ("<lambda>") — both
# break standalone serialization (class <lambda> is a SyntaxError;
# tree_flatten_spec needs the in/out specs). Reset to plain codegen:
# the graph body is identical, the wrapper disappears.
canonical.graph.set_codegen(fx.graph.CodeGen())
canonical.recompile()
# Re-lift shape params with THE single lifting implementation: the
# bake+retrace concretized all shape literals; lifting the canonical
# graph assigns _shape_param_N names/positions by one set of rules