-
Notifications
You must be signed in to change notification settings - Fork 201
Expand file tree
/
Copy path_pygraph.py
More file actions
2047 lines (1821 loc) · 90.9 KB
/
Copy path_pygraph.py
File metadata and controls
2047 lines (1821 loc) · 90.9 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
"""Pure Python graph representation for cuDNN Frontend.
All graph structure and attributes are kept in Python. Graph construction is
backend-agnostic; a backend is chosen at create_execution_plans() time by the
Router, and the backend-specific representation (e.g. the C++ cuDNN graph) is
generated lazily only then.
Execution flow (unification proposal):
build ops -> create_execution_plans() -> Router -> selected backend
(a registered native engine, or the cuDNN Graph backend by lazy lowering)
Example with a native backend (pass torch tensors directly):
>>> graph = pygraph()
>>> graph.register_backend(MyDslEngine()) # any BaseEngine
>>> C = graph.matmul(a_tensor, b_tensor) # auto-creates descriptors
>>> graph.execute({C: c_tensor}) # routes to a supporting backend, else cuDNN
"""
from dataclasses import dataclass
import weakref
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from .graph_types import NodeType, Tensor
from .nodes import Node, _row_major_stride
if TYPE_CHECKING:
from .engines.base import BaseEngine
@dataclass
class GraphContext:
"""Graph-level configuration defaults."""
io_data_type: Any = None
intermediate_data_type: Any = None
compute_data_type: Any = None
def __setattr__(self, name, value):
if getattr(self, "_frozen", False) and name != "_frozen":
raise RuntimeError("the graph is frozen after lowering/planning — build a new graph to change its configuration")
object.__setattr__(self, name, value)
class pygraph:
"""Pure Python graph representation.
All graph structure and attributes are kept in Python. C++ is only
used for execution via lazy lowering.
Example:
>>> graph = pygraph(io_data_type=cudnn.data_type.HALF)
>>> A = graph.tensor(dim=[8, 64, 128], name="A")
>>> B = graph.tensor(dim=[8, 128, 256], name="B")
>>> C = graph.matmul(A, B, name="mm1")
>>> C.set_output(True) # outputs are explicit, like the classic API
>>>
>>> # Inspect graph
>>> print(graph.nodes) # [Node('mm1', MATMUL)]
>>> print(graph.nodes[0].inputs) # {"A": ..., "B": ...}
>>> print(graph.nodes[0].params) # {"padding": 0.0}
"""
def __init__(
self,
# ---- classic pybind constructor, POSITIONALLY IDENTICAL (existing
# callers pass name/handle/sm_count/... by position; guarded by
# test_api_signature_parity) --------------------------------------
name: str = "test_graph",
io_data_type: Any = None,
intermediate_data_type: Any = None,
compute_data_type: Any = None,
handle: Any = None,
sm_count: Any = None,
sm_version: Any = None,
kernel_cache: Any = None,
device_property: Any = None,
is_dynamic_shape_enabled: bool = False,
is_override_shape_enabled: bool = False,
*,
# ---- new (keyword-only: never shifts the classic positional order) --
backends: Optional[List["BaseEngine"]] = None,
router: Any = None,
**kwargs,
):
self._context = GraphContext(
io_data_type=io_data_type,
intermediate_data_type=intermediate_data_type or io_data_type,
compute_data_type=compute_data_type or io_data_type,
)
self._handle = handle # cuDNN handle for the cuDNN lowering path
# Classic graph-level configuration, forwarded verbatim to the C++
# graph at lowering (**kwargs covers future binding args).
self._cpp_graph_kwargs = {k: v for k, v in kwargs.items() if v is not None}
self._cpp_graph_kwargs["name"] = name
for _k, _v in (
("sm_count", sm_count),
("sm_version", sm_version),
("kernel_cache", kernel_cache),
("device_property", device_property),
):
if _v is not None:
self._cpp_graph_kwargs[_k] = _v
if is_dynamic_shape_enabled:
self._cpp_graph_kwargs["is_dynamic_shape_enabled"] = True
if is_override_shape_enabled:
self._cpp_graph_kwargs["is_override_shape_enabled"] = True
self._nodes: List[Node] = []
self._tensors: Dict[str, Tensor] = {}
self._tensor_by_uid: Dict[int, Tensor] = {}
self._next_uid: int = 1
self._node_count: Dict[str, int] = {}
self._lowered_graph: Any = None
self._is_validated: bool = False
self._is_built: bool = False
self._data_bindings: Dict[int, Any] = {} # uid -> tensor data for auto-bound inputs
# Backend routing (see engines/router.py). Graph construction is
# backend-agnostic. At create_execution_plans() the Router builds a flat
# ranked plan list (python engines + cuDNN) in one shared engine-id
# space; each plan is dispatched by its id (is_python_engine -> python
# registry, else lower to cuDNN). ``_plan_index`` selects the plan to run.
self._backends: List["BaseEngine"] = []
self._router = router # None => engines.router.default_router at route time
self._plans: List[Any] = [] # list[PlanConfig], populated by create_execution_plans()
self._planning_done: bool = False # create_execution_plans() ran (one-shot)
self._frozen: bool = False # whole-surface freeze (set by _freeze())
self._plan_index: int = 0
self._backend_heuristics: Optional[List] = None # heur modes for a backend plan
self._cpp_plans_created: bool = False # C++ create_execution_plans ran
self._compiled_plans: Dict[int, Any] = {} # plan_index -> CompiledPlan (python plans)
self._cpp_bog_done: bool = False # C++ build_operation_graph ran
self._cpp_tensors: Dict[int, Any] = {} # IR uid -> lowered C++ tensor
self._reserved_uids: set = set() # user-specified uids _alloc_uid must skip
self._ambiguous_names: set = set() # duplicate labels: excluded from the name index
for _e in backends or (): # constructor path uses the SAME validation
self.register_backend(_e)
# =========================================================================
# Backend registration & routing
# =========================================================================
def register_backend(self, engine: "BaseEngine") -> "pygraph":
"""Add a candidate python execution engine. It joins the plan list at
create_execution_plans() time when its check_support() accepts the graph.
Validated at registration (not at failure time): the engine must declare
a stable engine_id in the reserved python region, ids must be unique per
graph, and registration after planning is rejected (planning is
one-shot — build a new graph)."""
from .engines.engine_ids import is_python_engine
eid = getattr(engine, "engine_id", None)
if not isinstance(eid, int) or not is_python_engine(eid):
raise ValueError(f"engine {engine!r} must declare a stable integer engine_id >= PYTHON_ENGINE_ID_BASE (got {eid!r})")
if any(e.engine_id == eid for e in self._backends):
raise ValueError(f"engine_id {eid} is already registered on this graph")
if self._planning_done:
raise RuntimeError("cannot register a backend after create_execution_plans(); planning is one-shot — build a new graph")
self._backends.append(engine)
return self
def set_router(self, router: Any) -> "pygraph":
"""Override the plan-list / ranking policy for this graph. Must be set
before create_execution_plans() (a later router cannot affect the
already-planned list)."""
if self._planning_done:
raise RuntimeError("cannot set a router after create_execution_plans(); planning is one-shot — build a new graph")
self._router = router
return self
def _engine_by_id(self, engine_id: int) -> "BaseEngine":
for e in self._backends:
if e.engine_id == engine_id:
return e
raise KeyError(f"no registered python engine with id {engine_id}")
@property
def backends(self) -> List["BaseEngine"]:
"""Registered candidate python engines."""
return list(self._backends)
@property
def plans(self) -> List[Any]:
"""The ranked plan list (list[PlanConfig]) from create_execution_plans()."""
return list(self._plans)
@property
def _selected_plan_config(self) -> Optional[Any]:
from .engines.engine_ids import is_python_engine
if not self._plans or not 0 <= self._plan_index < len(self._plans):
return None
cfg = self._plans[self._plan_index]
return cfg if is_python_engine(cfg.engine_id) else None
@property
def selected_engine(self) -> Optional["BaseEngine"]:
"""The python engine for the currently selected top-level plan entry,
or None for the backend path. Populated after create_execution_plans()."""
cfg = self._selected_plan_config
return self._engine_by_id(cfg.engine_id) if cfg is not None else None
# =========================================================================
# Tensor Creation
# =========================================================================
def tensor(
self,
# classic public tensor() signature, POSITIONALLY IDENTICAL (guarded by
# test_api_signature_parity); classic unset sentinels (NOT_SET, -1,
# reordering NONE) are normalized to None below
dim: List[int],
stride: Optional[List[int]] = None,
data_type: Any = None,
is_virtual: bool = False,
is_pass_by_value: bool = False,
ragged_offset: Optional[Tensor] = None,
reordering_type: Any = None,
name: str = "",
uid: Optional[int] = None,
ragged_offset_multiplier: int = 1,
**kwargs,
) -> Tensor:
"""Create a tensor."""
if not name:
name = f"tensor_{len(self._tensors)}"
if data_type is not None and getattr(data_type, "name", None) == "NOT_SET":
data_type = None
if reordering_type is not None and getattr(reordering_type, "name", None) == "NONE":
reordering_type = None
if uid == -1: # classic unset sentinel
uid = None
if uid is not None:
# User-owned uid, same rule as set_uid (_reuid_tensor): classic
# tensors have no uid until assigned, so a user uid may land on an
# eagerly auto-assigned one — the user wins, the auto holder is
# renumbered; colliding with another USER uid is an error.
holder = self._tensor_by_uid.get(uid)
if holder is not None:
if holder.uid_assigned:
raise ValueError(f"uid {uid} is already user-assigned to tensor {holder.name!r}")
fresh = self._alloc_uid()
self._tensor_by_uid[fresh] = holder
if holder.uid in self._data_bindings:
self._data_bindings[fresh] = self._data_bindings.pop(holder.uid)
holder.uid = fresh
self._reserved_uids.add(uid)
dim = list(dim) # classic API accepts torch.Size / tuples
t = Tensor(
name=name,
dim=dim,
stride=list(stride) if stride else _row_major_stride(dim),
data_type=data_type or (self._context.intermediate_data_type if is_virtual else self._context.io_data_type),
is_virtual=is_virtual,
is_pass_by_value=is_pass_by_value,
ragged_offset=ragged_offset,
reordering_type=reordering_type,
ragged_offset_multiplier=ragged_offset_multiplier,
uid=uid if uid is not None else self._alloc_uid(),
uid_assigned=uid is not None,
dim_assigned=True, # graph inputs: the user specified the layout
stride_assigned=stride is not None,
**kwargs,
)
self._register_tensor(t)
return t
def tensor_like(self, template: Any, name: str = "", is_virtual: bool = False) -> Tensor:
"""Create tensor from another IR tensor or a DLPack object (e.g. torch).
Classic parity: CPU (host) framework tensors become pass-by-value, like
the C++ tensor_like (is_pass_by_value = device == CPU)."""
if isinstance(template, Tensor):
return self.tensor(
dim=list(template.dim),
stride=list(template.stride),
data_type=template.data_type,
is_virtual=is_virtual,
is_pass_by_value=template.is_pass_by_value,
name=name,
)
# Element strides via the DLPack protocol (classic tensor_like reads the
# DLPack capsule). torch's .stride() is element units, but e.g. CuPy
# exposes byte-unit .strides — so normalize any non-torch DLPack object
# through torch.from_dlpack first.
if hasattr(template, "stride") and callable(getattr(template, "stride", None)):
dim = list(template.shape)
stride = list(template.stride())
else:
try:
import torch as _torch
_view = _torch.from_dlpack(template)
dim = list(_view.shape)
stride = list(_view.stride())
except Exception: # noqa: BLE001 — no torch / exotic dlpack: assume dense
dim = list(template.shape)
stride = _row_major_stride(dim)
data_type = None
try:
import cudnn.datatypes
data_type = cudnn.datatypes._torch_to_cudnn_data_type(template.dtype)
except Exception:
pass
is_pbv = bool(getattr(getattr(template, "device", None), "type", None) == "cpu")
return self.tensor(dim=dim, stride=stride, data_type=data_type, is_virtual=is_virtual, is_pass_by_value=is_pbv, name=name)
def tensor_scalar(self, value: Any, scalar_type: Any, name: str = "") -> Tensor:
"""Create a pass-by-value scalar tensor (classic tensor_scalar parity)."""
if not name:
name = f"scalar_{len(self._tensors)}"
t = Tensor(
name=name,
dim=[1, 1, 1, 1],
stride=[1, 1, 1, 1],
is_pass_by_value=True,
pass_by_value=value,
scalar_type=scalar_type,
uid=self._alloc_uid(),
)
self._register_tensor(t)
return t
def _check_mutable(self, what: str) -> None:
if self._frozen:
raise RuntimeError(f"cannot {what} after lowering/planning — the graph is frozen (planning is one-shot; build a new graph)")
# a mutation while merely validated (python-engine graphs stay mutable
# until planning) must re-validate later — never run on stale inference
self._is_validated = False
def _freeze(self) -> None:
"""Freeze the ENTIRE public graph surface (not just the fluent API).
Called at lowering and at planning, whichever happens first. After
this, every mutation path raises: fluent setters and op builders (via
_check_mutable), attribute writes on Tensor/Node/GraphContext (their
__setattr__ guards), dict writes on node.inputs/outputs/params
(MappingProxy), and in-place list mutation of dim/stride (tuples).
The inspection surface stays fully readable for engines."""
if self._frozen:
return
from types import MappingProxyType
for node in self._nodes:
node.inputs = MappingProxyType(dict(node.inputs))
node.outputs = MappingProxyType(dict(node.outputs))
node.params = MappingProxyType(dict(node.params))
node._frozen = True
for t in self._tensor_by_uid.values():
t.dim = tuple(t.dim) if t.dim else t.dim
t.stride = tuple(t.stride) if t.stride else t.stride
t._frozen = True
self._context._frozen = True
self._frozen = True
def _rename_tensor(self, t: Tensor, name: str) -> None:
"""Atomic rename keeping the name index coherent. Classic parity: names
are labels, so renaming ONTO an existing label is legal — the name just
becomes ambiguous and leaves the unique-name index."""
if name == t.name:
return
# NOT freeze-guarded: names are labels (classic allows renaming after
# build — the lowered graph already carries the old label, and labels
# have no execution semantics).
if self._tensors.get(t.name) is t:
del self._tensors[t.name]
object.__setattr__(t, "name", name) # label write is exempt from the freeze
if name in self._tensors or name in self._ambiguous_names:
self._tensors.pop(name, None)
self._ambiguous_names.add(name)
else:
self._tensors[name] = t
def _reuid_tensor(self, t: Tensor, uid: int) -> None:
"""Atomic re-uid keeping indexes/bindings coherent.
Classic parity: classic tensors have NO uid until set_uid, while the IR
assigns eagerly — so a user set_uid may land on an auto-assigned uid.
The user wins: the auto holder is silently renumbered (auto uids are
internal until lowering). Two USER-assigned uids colliding is an error.
"""
if uid == t.uid:
if not self._frozen: # same-value set_uid is a no-op (classic allows it anytime)
t.uid_assigned = True
return
self._check_mutable("re-uid a tensor")
holder = self._tensor_by_uid.get(uid)
if holder is not None:
if holder.uid_assigned:
raise ValueError(f"uid {uid} is already user-assigned to tensor {holder.name!r}")
fresh = self._alloc_uid() # renumber the auto holder
self._tensor_by_uid[fresh] = holder
if holder.uid in self._data_bindings:
self._data_bindings[fresh] = self._data_bindings.pop(holder.uid)
holder.uid = fresh
self._tensor_by_uid.pop(t.uid, None)
if t.uid in self._data_bindings:
self._data_bindings[uid] = self._data_bindings.pop(t.uid)
t.uid = uid
t.uid_assigned = True
self._reserved_uids.add(uid)
self._tensor_by_uid[uid] = t
def _alloc_uid(self) -> int:
# Skip uids the user reserved via tensor(uid=...) — the Python IR owns
# the whole uid namespace (see the uid-ownership note in _lower_to_cpp).
while self._next_uid in self._reserved_uids:
self._next_uid += 1
uid = self._next_uid
self._next_uid += 1
return uid
# Classic C++ auto-names op outputs "<node>::<OUTPUT_ENUM>" (graph_interface.h
# output_tensor calls). Ports whose name differs from the classic enum are
# mapped here so canonical names (wrapper.Graph lookups, JSON dumps) match.
_CLASSIC_OUT_SUFFIX = {
"inv_var": "INV_VARIANCE",
"mean": "MEAN",
"next_running_mean": "NEXT_RUNNING_MEAN",
"next_running_var": "NEXT_RUNNING_VAR",
"DScale": "DSCALE",
"DBias": "DBIAS",
}
def _get_name(self, op: str, name: str) -> str:
self._check_mutable(f"add a {op} op")
if name:
return name
count = self._node_count.get(op, 0)
self._node_count[op] = count + 1
return f"{op}.{count}"
def _make_output(self, name: str) -> Tensor:
"""Create a virtual output tensor. data_type is left unset: validate()'s
inference assigns io/intermediate by the FINAL virtual state (classic
semantics — a user set_output(True) without set_data_type gets io)."""
return Tensor(
name=name,
is_virtual=True,
uid=self._alloc_uid(),
)
def _register_tensor(self, t: Tensor) -> None:
t.owner = weakref.ref(self)
# Classic parity: names are debug LABELS — duplicates are legal
# (pycudnnTest builds two 'weight' tensors). uid is the identity; the
# name index serves only names that remain unique, and name-keyed
# lookups on an ambiguous name raise instead of guessing.
if t.name in self._tensors or t.name in self._ambiguous_names:
self._tensors.pop(t.name, None)
self._ambiguous_names.add(t.name)
else:
self._tensors[t.name] = t
self._tensor_by_uid[t.uid] = t
def _ensure_tensor(self, arg: Any, name: str = "") -> Tensor:
"""Convert arg to a Tensor descriptor if it isn't one already.
If arg is a framework tensor (torch, jax, cupy, etc.), creates a
descriptor via tensor_like() and stores the data binding for execute().
"""
if isinstance(arg, Tensor):
return arg
desc = self.tensor_like(arg, name=name)
self._data_bindings[desc.uid] = arg
return desc
# =========================================================================
# Operations
# =========================================================================
def matmul(
self,
A: Any,
B: Any,
compute_data_type: Any = None,
padding: float = 0.0,
name: str = "",
) -> Tensor:
"""Matrix multiplication: C = A @ B.
A and B can be Tensor descriptors or framework tensors (torch, jax, etc.).
"""
name = self._get_name("matmul", name)
A = self._ensure_tensor(A, name=f"{name}::A")
B = self._ensure_tensor(B, name=f"{name}::B")
node = Node(name, NodeType.MATMUL, compute_data_type or self._context.compute_data_type)
node.inputs["A"] = A
node.inputs["B"] = B
node.params["padding"] = padding
C = self._make_output(f"{name}::C")
node.outputs["C"] = C
self._register_tensor(C)
self._nodes.append(node)
return C
# ---- Pointwise ops ------------------------------------------------------
# ``params["mode"]`` is the op kind == the C++ pygraph method name (the
# pointwise_mode enum is not exposed to Python, and the method name IS the
# canonical semantic name), so lowering is a direct getattr dispatch — no
# mode<->method mapping table to maintain. Extra scalar attributes
# (negative_slope / clips / swish_beta / axis) live in params and are
# forwarded at lowering; ops that take them get explicit builders below,
# the uniform rest are generated from _POINTWISE_TENSOR_ARGS (the table
# mirrors the pybind signatures — tensor-argument names per op — so both
# positional and the classic keyword call styles work).
_POINTWISE_TENSOR_ARGS: "dict[str, tuple]" = {
# unary
**{
op: ("input",)
for op in (
"abs",
"ceil",
"cos",
"elu",
"erf",
"exp",
"floor",
"gelu",
"gelu_approx_tanh",
"identity",
"log",
"logical_not",
"neg",
"reciprocal",
"rsqrt",
"sigmoid",
"sin",
"softplus",
"sqrt",
"tan",
"tanh",
)
},
# binary
**{op: ("a", "b") for op in ("add", "add_square", "div", "logical_and", "logical_or", "mul", "sub")},
**{op: ("input0", "input1") for op in ("max", "min", "mod", "pow")},
**{op: ("input", "comparison") for op in ("cmp_eq", "cmp_ge", "cmp_gt", "cmp_le", "cmp_lt", "cmp_neq")},
"bias": ("input", "bias"),
"scale": ("input", "scale"),
# backward (loss, input) -> dinput
**{
op: ("loss", "input")
for op in (
"elu_backward",
"gelu_approx_tanh_backward",
"gelu_backward",
"sigmoid_backward",
"softplus_backward",
"tanh_backward",
)
},
# ternary
"binary_select": ("input0", "input1", "mask"),
}
# scalar attributes forwarded from params to the C++ call at lowering
_POINTWISE_EXTRA_PARAMS = ("negative_slope", "lower_clip", "upper_clip", "swish_beta", "axis")
def _pointwise(self, mode: str, inputs: list, name: str, compute_data_type: Any = None, extra_params: Optional[dict] = None) -> Tensor:
"""Internal helper for pointwise ops. ``mode`` == C++ pygraph method name."""
inputs = [self._ensure_tensor(t, name=f"{name}::IN_{i}") for i, t in enumerate(inputs)]
node = Node(name, NodeType.POINTWISE, compute_data_type or self._context.compute_data_type)
node.params["mode"] = mode
if extra_params:
node.params.update({k: v for k, v in extra_params.items() if v is not None})
for i, t in enumerate(inputs):
node.inputs[f"IN_{i}"] = t
out = self._make_output(f"{name}::OUT_0")
node.outputs["OUT_0"] = out
self._register_tensor(out)
self._nodes.append(node)
return out
# Pointwise ops with extra scalar attributes: explicit builders.
def relu(
self, input: Any, negative_slope: Any = None, lower_clip: Any = None, upper_clip: Any = None, name: str = "", compute_data_type: Any = None
) -> Tensor:
"""ReLU (optionally leaky via negative_slope, and/or clipped)."""
return self._pointwise(
"relu", [input], self._get_name("relu", name), compute_data_type, dict(negative_slope=negative_slope, lower_clip=lower_clip, upper_clip=upper_clip)
)
def leaky_relu(self, input: Any, negative_slope: Any, name: str = "", compute_data_type: Any = None) -> Tensor:
"""Leaky ReLU."""
return self._pointwise("leaky_relu", [input], self._get_name("leaky_relu", name), compute_data_type, dict(negative_slope=negative_slope))
def swish(self, input: Any, swish_beta: Any = None, name: str = "", compute_data_type: Any = None) -> Tensor:
"""Swish / SiLU."""
return self._pointwise("swish", [input], self._get_name("swish", name), compute_data_type, dict(swish_beta=swish_beta))
def gen_index(self, input: Any, axis: int, name: str = "", compute_data_type: Any = None) -> Tensor:
"""Generate index along an axis."""
return self._pointwise("gen_index", [input], self._get_name("gen_index", name), compute_data_type, dict(axis=axis))
def relu_backward(
self, loss: Any, input: Any, negative_slope: Any = None, lower_clip: Any = None, upper_clip: Any = None, name: str = "", compute_data_type: Any = None
) -> Tensor:
"""ReLU backward."""
return self._pointwise(
"relu_backward",
[loss, input],
self._get_name("relu_backward", name),
compute_data_type,
dict(negative_slope=negative_slope, lower_clip=lower_clip, upper_clip=upper_clip),
)
def leaky_relu_backward(self, loss: Any, input: Any, negative_slope: Any, name: str = "", compute_data_type: Any = None) -> Tensor:
"""Leaky ReLU backward."""
return self._pointwise(
"leaky_relu_backward", [loss, input], self._get_name("leaky_relu_backward", name), compute_data_type, dict(negative_slope=negative_slope)
)
def swish_backward(self, loss: Any, input: Any, swish_beta: Any = None, name: str = "", compute_data_type: Any = None) -> Tensor:
"""Swish backward."""
return self._pointwise("swish_backward", [loss, input], self._get_name("swish_backward", name), compute_data_type, dict(swish_beta=swish_beta))
# NOTE: reduction / block-scale / MoE / conv / norms / structural ops are all
# declared in _STRUCTURED_OPS (module tail) — one table entry per op, one
# generic lowering branch. Only ops whose call shape doesn't fit the table
# (matmul's positional ergonomics, sdpa's conditional kwargs) stay explicit.
# NOTE: the sdpa family (sdpa / sdpa_backward / sdpa_fp8 / sdpa_mxfp8 /
# sdpa_fp8_backward / sdpa_mxfp8_backward) is declared in _CAPTURED_OPS
# (module tail): kwargs are captured generically — tensors become named
# ports (port == C++ kwarg), scalars/enums/callbacks go to params verbatim,
# dropout tuples are flattened per element — and lowering forwards them
# verbatim, so the full C++ kwarg surface (~130 args across variants) is
# supported without hand-mirroring each argument.
# =========================================================================
# Inspection
# =========================================================================
@property
def nodes(self) -> List[Node]:
"""All nodes in the graph (a copy — the graph's own list is not a
public mutation path)."""
return list(self._nodes)
@property
def tensors(self) -> Dict[str, Tensor]:
"""All tensors by name (a copy — see nodes)."""
return dict(self._tensors)
@property
def context(self) -> GraphContext:
"""Graph context."""
return self._context
def find_tensor(self, name_or_uid: Union[str, int]) -> Optional[Tensor]:
"""Find tensor by name or UID."""
if isinstance(name_or_uid, int):
return self._tensor_by_uid.get(name_or_uid)
if name_or_uid in self._ambiguous_names:
raise ValueError(f"tensor name {name_or_uid!r} is ambiguous (duplicate labels are legal; look up by uid or Tensor)")
return self._tensors.get(name_or_uid)
def get_node(self, name: str) -> Optional[Node]:
"""Find node by name."""
return next((n for n in self._nodes if n.name == name), None)
def get_inputs(self) -> List[Tensor]:
"""Get non-virtual input tensors."""
produced = {t.uid for n in self._nodes for t in n.outputs.values() if t}
return [t for t in self._tensors.values() if not t.is_virtual and t.uid not in produced]
def get_outputs(self) -> List[Tensor]:
"""Get non-virtual output tensors."""
return [t for t in self._tensors.values() if not t.is_virtual and any(t.uid == o.uid for n in self._nodes for o in n.outputs.values() if o)]
def inspect(self) -> Dict[str, Any]:
"""Return graph structure for inspection."""
return {
"context": {
"io_data_type": str(self._context.io_data_type),
"compute_data_type": str(self._context.compute_data_type),
},
"nodes": [
{
"name": n.name,
"type": n.node_type.name,
"inputs": {k: v.name for k, v in n.inputs.items()},
"outputs": {k: v.name for k, v in n.outputs.items()},
"params": n.params,
}
for n in self._nodes
],
"tensors": {
name: {"dim": t.dim, "stride": t.stride, "dtype": str(t.data_type), "is_virtual": t.is_virtual, "uid": t.uid}
for name, t in self._tensors.items()
},
}
# =========================================================================
# Build & Execute
# =========================================================================
def validate(self) -> None:
"""Validate graph and infer properties.
Classic parity: op outputs stay VIRTUAL unless the user marks them
with set_output(True). A leaf output is NOT auto-marked — discarding
an op result (e.g. the Stats of a training SDPA) is legal classic
usage, and auto-marking it would make its uid required in the variant
pack.
"""
for node in self._nodes:
node.infer_properties(self._context)
# Table-driven shape inference, topologically: builder-time infer
# only sees graph-input dims; chained ops (e.g. conv on a virtual
# relu output) get their output dims here, once inputs are known.
spec_entry = _STRUCTURED_BY_TYPE.get(node.node_type) or _CAPTURED_BY_TYPE.get(node.node_type)
if spec_entry:
_, spec = spec_entry
infer = spec.get("infer", {})
for oport, out_t in node.outputs.items():
if out_t is not None and not out_t.dim:
try:
d = infer.get(oport, lambda n: None)(node)
except Exception: # noqa: BLE001 — best-effort
d = None
if d:
out_t.dim = list(d)
out_t.stride = _row_major_stride(out_t.dim)
node.validate()
for t in self._tensors.values():
if t.dim and not t.stride: # classic: stride optional, row-major inferred
t.stride = _row_major_stride(t.dim)
if not t.is_pass_by_value:
t.validate()
self._is_validated = True
# Classic parity: with no python engines registered, C++ validation
# happens HERE — tests catch cudnnGraphNotSupportedError around
# graph.validate() (unsupported configs must skip, not fail later).
if not self._backends and self._lowered_graph is None:
self._lowered_graph = self._lower_to_cpp()
self._lowered_graph.validate()
self._verify_uid_ownership()
def build_operation_graph(self) -> None:
"""Validate the graph; lower to C++ when no python engines are registered.
Backend selection is deferred to create_execution_plans() (the Router
stage). With python engines registered, nothing is lowered here (a graph
routed to a python engine never touches C++). Without them — the classic
sequencing — lowering happens now, so plan-configuration and query
methods (deselect_engines, get_engine_count, ...) work between
build_operation_graph() and create_execution_plans(), exactly as on the
classic API (they delegate to the lowered C++ graph via __getattr__).
"""
if not self._is_validated:
self.validate()
if self._lowered_graph is not None and not self._cpp_bog_done:
self._lowered_graph.build_operation_graph()
self._cpp_bog_done = True
self._sync_ir_shapes_from_backend()
def _sync_ir_shapes_from_backend(self) -> None:
"""After the backend's shape/layout inference (build_operation_graph),
reflect the REAL dim/stride back into the IR tensors. The IR's own
inferred strides are provisional row-major; the backend applies
classic per-op layout inference (channels-last conv etc.), and
consumers of the IR (wrapper.Graph buffer allocation, engines,
introspection) must see the layout that will actually execute."""
for ir_uid, cpp_t in self._cpp_tensors.items():
ir = self._tensor_by_uid.get(ir_uid)
if ir is None:
continue
try:
d, st = cpp_t.get_dim(), cpp_t.get_stride()
except Exception: # noqa: BLE001 — some tensors have no dims (scalars)
continue
if d:
object.__setattr__(ir, "dim", tuple(d)) # sealed (graph is frozen)
if st:
object.__setattr__(ir, "stride", tuple(st))
def create_execution_plans(self, heuristics: Optional[List] = None) -> None:
"""Build the ranked execution-plan list (the dispatch stage).
The Router returns one flat list of PlanConfig(engine_id, knobs) mixing
python engines (reserved id region) and the backend side, in one shared
engine-id space. Nothing is lowered here — a plan is built lazily when
selected. ``_plan_index`` selects which plan runs (default 0, the
highest-ranked); cuDNN heuristic modes are carried on the backend plan's
knobs.
Args:
heuristics: cuDNN heuristic modes, carried to the backend plan.
"""
if not self._is_validated:
self.validate()
from .engines.router import default_router
# One-shot planning (classic conformance: the C++ graph never supported
# re-planning — a second call there appends plans by accident, and no
# user re-plans). Plan once; to plan differently, build a new graph
# (IR construction is microseconds). Autotune re-selects WITHIN this
# plan set via select_plan(). Explicit state flag, not an
# is-the-list-nonempty proxy.
if self._planning_done:
raise RuntimeError(
"create_execution_plans() was already called on this graph; planning is one-shot — build a new graph to re-plan, or use select_plan() to switch plans"
)
router = self._router or default_router
plans = router.plan(self, self._backends)
# Validate the FINAL router output (a custom Router must not bypass
# registration): python entries must name registered engines; the only
# non-python entry allowed is ONE backend delegating sentinel.
from .engines.engine_ids import BACKEND_HEURISTIC_ENGINE_ID, is_python_engine
registered = {e.engine_id for e in self._backends}
if not plans:
raise ValueError("router returned an empty plan list — there is no legal empty planning state (return the backend delegating entry at minimum)")
n_cudnn = 0
for cfg in plans:
if is_python_engine(cfg.engine_id):
if cfg.engine_id not in registered:
raise ValueError(f"router produced a plan for unregistered engine_id {cfg.engine_id}")
elif cfg.engine_id == BACKEND_HEURISTIC_ENGINE_ID:
n_cudnn += 1
else:
raise ValueError(f"router produced a plan with invalid engine_id {cfg.engine_id}")
if n_cudnn > 1:
raise ValueError("router produced more than one backend delegating entry")
self._plans = plans
self._planning_done = True
self._freeze() # plans reference the graph as-is: no mutation from here
self._plan_index = 0
self._backend_heuristics = heuristics # applied when a backend plan is built
# Classic sequencing: if the graph was already lowered (no python
# engines -> build_operation_graph lowered eagerly) and the selected
# plan is the backend one, create the C++ plans now.
if self.selected_engine is None and self._lowered_graph is not None:
self._lower_backend_plan()
def _has_backend_plan(self) -> bool:
from .engines.engine_ids import BACKEND_HEURISTIC_ENGINE_ID
return any(cfg.engine_id == BACKEND_HEURISTIC_ENGINE_ID for cfg in self._plans)
def get_execution_plan_count(self) -> int:
"""Classic passthrough, ALWAYS: the cuDNN backend's plan count for this
graph (its plan list is discovered per graph from the lowered C++ graph
and addressed via the classic ``build_plan_at_index`` /
``execute_plan_at_index`` / ``get_workspace_size_plan_at_index`` APIs).
The semantics never depend on whether python engines are registered.
The ROUTED plan list (the Router's entries: python plans + at most one
backend delegating entry) is a separate index space: ``graph.plans``,
selected with ``select_plan()``. Its indices are stable — the cuDNN
entry is one index forever and never expands into this count.
"""
if self._planning_done:
if not self._has_backend_plan():
raise RuntimeError(
"this graph's Router produced python plans only (no backend entry), so there are no backend plans — the routed plan list is graph.plans / select_plan()"
)
self._lower_backend_plan() # backend plans exist on demand (one-shot)
return self._lowered_graph.get_execution_plan_count()
if self._lowered_graph is not None:
# classic pre-planning sequencing: delegate, C++ reports its state
return self._lowered_graph.get_execution_plan_count()
return 0 # classic: an unplanned graph has zero plans (not an error)
def select_plan(self, index: int) -> "pygraph":
"""Pick a ROUTED plan entry: the index is into ``graph.plans`` (the
Router's entries — stable, never shifted by backend lowering). Backend
sub-plans are a separate space, selected via the classic at-index APIs
(see get_execution_plan_count)."""
if not self._planning_done:
raise RuntimeError("call create_execution_plans() before select_plan()")
if not 0 <= index < len(self._plans):
raise IndexError(f"plan index {index} out of range for {len(self._plans)} routed plan(s) (graph.plans)")
self._plan_index = index
self._is_built = False
return self
def _resolve_stream(self, handle: Any) -> Any:
"""Stream for a supplied handle (classic set_stream semantics). A failed
query on a SUPPLIED handle is a correctness error and raises — never a
silent fall-back to another stream. No handle -> None (the engine must
resolve deterministically from its framework, e.g. torch current stream)."""
if handle is None:
return None
import cudnn
return cudnn.get_stream(handle)
def _build_context(self, handle: Any = None) -> Any:
from .engines.base import ExecutionContext
h = handle if handle is not None else self._handle
return ExecutionContext(handle=h, stream=self._resolve_stream(h))
def _verify_uid_ownership(self) -> None:
# Verify the uid-ownership invariant (see _lower_to_cpp): every C++
# tensor must carry exactly its IR uid. An assertion — not a silent
# translation — so a lowering path that forgets to push a uid fails
# loudly in tests instead of mis-binding buffers (a swapped
# multi-output pairing writes past the smaller buffer: corruption).
for ir_uid, cpp_t in self._cpp_tensors.items():
cpp_uid = cpp_t.get_uid()
if cpp_uid != ir_uid:
raise RuntimeError(f"uid ownership violated: IR tensor uid {ir_uid} lowered to C++ uid {cpp_uid} — a lowering path failed to push the uid")
def _lower_backend_plan(self) -> None:
"""Lower to C++ (if not already) and create the backend plans (once)."""
import cudnn
if self._lowered_graph is None:
self._lowered_graph = self._lower_to_cpp()
self._lowered_graph.validate()
self._verify_uid_ownership()
if not self._cpp_bog_done:
self._lowered_graph.build_operation_graph()
self._cpp_bog_done = True
self._sync_ir_shapes_from_backend()
if not self._cpp_plans_created:
heur = self._backend_heuristics or [cudnn.heur_mode.A, cudnn.heur_mode.FALLBACK]
self._lowered_graph.create_execution_plans(heur)
self._cpp_plans_created = True
def check_support(self) -> None:
"""Check the selected plan's engine supports the graph.
A python plan re-affirms its engine's check_support() (already passed
when the Router included it); a backend plan lowers and checks C++ support.
"""
eng = self.selected_engine
if eng is not None:
eng.check_support(self)
return
if self._lowered_graph is None:
self._lower_backend_plan()
self._lowered_graph.check_support()
def build_plans(self, *args) -> None:
"""Finalize the selected plan. A python plan compiles HERE (once per
graph/plan; the CompiledPlan is cached on the graph and reused across
executions). The classic optional build_plan_policy passes through on
the backend path."""
eng = self.selected_engine
if eng is not None:
if self._plan_index not in self._compiled_plans:
self._compiled_plans[self._plan_index] = eng.build_plan(self, self._selected_plan_config, self._build_context())
if eng is None:
if self._lowered_graph is None or not self._cpp_plans_created:
self._lower_backend_plan()
self._lowered_graph.build_plans(*args)
self._is_built = True
def build(self, heuristics: Optional[List] = None) -> None:
"""Convenience: validate -> build_operation_graph -> create_execution_plans
-> check_support -> build_plans, in sequence."""
if not self._is_validated:
self.validate()
self.build_operation_graph()
if not self._planning_done: # never silently re-plan: preserves select_plan()
self.create_execution_plans(heuristics)
self.check_support()
self.build_plans()
def get_workspace_size(self, *args, **kwargs) -> int:
"""Workspace bytes for the selected plan. Classic overloads (handle /
dynamic-shape overrides) pass through on the backend path."""
if not self._is_built:
raise RuntimeError("Call build() first")
if self.selected_engine is not None:
if args or kwargs:
raise NotImplementedError("dynamic workspace-query overrides are not supported by python plans")
return self._compiled_plans[self._plan_index].get_workspace_size()
return self._lowered_graph.get_workspace_size(*args, **kwargs)
def execute(
self,
tensor_dict: Dict[Union[str, int, Tensor], Any],
workspace: Any = None,
handle: int = None,
override_uids: Any = None,
override_shapes: Any = None,
override_strides: Any = None,
) -> None:
"""Execute the selected plan.