-
Notifications
You must be signed in to change notification settings - Fork 251
Expand file tree
/
Copy path_pygraph.py
More file actions
3104 lines (2764 loc) · 148 KB
/
Copy path_pygraph.py
File metadata and controls
3104 lines (2764 loc) · 148 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
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""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
heuristics, 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() -> heuristics -> selected backend
(a python engine of the graph's family, or the cuDNN Graph backend by lazy
lowering)
Example (pass torch tensors directly):
>>> graph = pygraph()
>>> C = graph.matmul(a_tensor, b_tensor) # auto-creates descriptors
>>> graph.execute({C: c_tensor}) # routes to a supporting engine, else cuDNN
"""
import ctypes
from dataclasses import dataclass
import logging
import weakref
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from cudnn import _pybind_module
from .datatypes import _buffer_dtype_to_cudnn, _dlpack_code_bits, _torch_to_cudnn_data_type
from .engines.base import ExecutionContext, VariantPack
from .engines.engine_ids import is_python_engine
from .graph_types import NodeType, Tensor, byte_size as _byte_size, describing_tensor
from .nodes import Node, _row_major_stride
_LOG = logging.getLogger("cudnn.pygraph")
def _is_dense(dim, stride) -> bool:
"""Row-major compact."""
expect = 1
for extent, step in zip(reversed(tuple(dim)), reversed(tuple(stride))):
if extent != 1 and step != expect:
return False
expect *= extent
return True
def _in_axis_order_of(shape, stride, reference_stride):
"""``(shape, stride)`` re-expressed in the axis order ``reference_stride`` uses.
``override_shapes`` speaks the GRAPH's declaration (a matmul's B is
``[batch, K, N]``); the slot holds what the caller's buffer reports (B is
allocated ``(batch, N, K)``). Same memory, two orders — so an engine
indexing an extent by position would read the wrong one. Both orders rank
their axes the same way by stride, which gives the permutation.
"""
if len(shape) != len(stride) or len(stride) != len(reference_stride):
return tuple(shape), tuple(stride)
by_stride = sorted(range(len(stride)), key=lambda i: -stride[i])
reference = sorted(range(len(reference_stride)), key=lambda i: -reference_stride[i])
permutation = [0] * len(stride)
for rank, axis in enumerate(by_stride):
permutation[reference[rank]] = axis
return tuple(shape[a] for a in permutation), tuple(stride[a] for a in permutation)
def cudnn_graph_not_supported(message: str) -> Exception:
"""The classic unsupported-graph error (built lazily: importing cudnn at
module scope here would be circular)."""
import cudnn
return cudnn.cudnnGraphNotSupportedError(message)
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
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,
**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/heuristics.py). Graph construction is
# backend-agnostic. At create_execution_plans() the heuristics build 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._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
self._candidates: Optional[List["BaseEngine"]] = None # manifest matches + registered
self._facts: Dict[Any, Any] = {} # analyzer callable -> its record; see _facts_for()
self._backend_declined: Optional[Exception] = None # why the backend has no entries
self._backend_entries: Optional[List[Any]] = None # backend_plan_entries(), once
self._backend_mode_spans: List[Any] = [] # (mode, lo, hi) over the C++ plan list
self._barred_names: set = set() # deselect_engines()
self._workspace_limit: Optional[int] = None # deselect_workspace_greater_than()
self._note_filters: List[Any] = [] # (kind, note, keep) from the classic note filters
self._plan_pinned: bool = False # select_plan() => the walk is strict
# Backend operand order + the reusable pointer array handed to execute.
# Both are properties of the frozen graph, so they outlive any one call.
self._sorted_uids: Optional[List[int]] = None
self._selected_engine_cache = None # (plan config, engine); see selected_engine
# =========================================================================
# Routing
# =========================================================================
@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]:
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
def _engine_for(self, cfg) -> Optional["BaseEngine"]:
"""The python engine that owns ``cfg``'s id, or None for a backend entry."""
if cfg is None or not is_python_engine(cfg.engine_id):
return None
owners = self._owners_for_id(cfg.engine_id)
if not owners:
raise KeyError(f"no python engine declares id {cfg.engine_id}")
if len(owners) > 1: # registration proves this cannot happen; assert it anyway
raise KeyError(f"id {cfg.engine_id} is declared by {[e.name for e in owners]} — dispatch is ambiguous")
return owners[0]
def _owners_for_id(self, engine_id: int) -> List["BaseEngine"]:
"""The candidate engine answering for ``engine_id``, if any.
The single owner lookup: dispatch, the ranking's output validation and
replay all go through it. An id names exactly one engine (the manifest
hands each slot one), so this is an equality test — and it must stay the
same test the manifest fallback below makes."""
out = [engine for engine in self._candidate_engines() if engine.engine_id == engine_id]
if not out:
# Not a candidate for THIS graph, but the id may still name an
# in-tree engine: the manifest decodes it without anything being
# registered. That is what lets create_execution_plan() replay a
# recorded (engine_id, knobs) on a fresh graph.
from .engines import manifest
engine = manifest.engine_for_id(engine_id)
if engine is not None:
out.append(engine)
return out
def _barred_indices(self) -> set:
"""Plan INDICES excluded by ``deselect_engines()`` (classic: match by
name substring) or by a note filter. Per index, not per engine: one
engine proposes several knob configurations and a name may match only
one of them."""
barred = set()
if self._barred_names:
barred |= {i for i in range(len(self._plans)) if any(p in self.get_plan_name_at_index(i) for p in self._barred_names)}
# Same rule the backend applies per note (plans.h::filter_behavior_notes):
# bar a plan that HAS the note when deselecting, or LACKS it when selecting.
for kind, note, keep in self._note_filters:
for i, cfg in enumerate(self._plans):
if self._engine_for(cfg) is None:
continue # backend entry: the backend already filtered it
if (note in self._notes_of(cfg, kind)) != keep:
barred.add(i)
return barred
@property
def selected_engine(self) -> Optional["BaseEngine"]:
"""The python engine for the currently selected plan entry, or None for
the backend path. Populated after create_execution_plans().
Cached, because ``execute()`` asks on every call and answering means
walking every registered engine for the one declaring this id — 2.75 us
to re-derive something that only ``select_plan`` can change. Keyed on
the config OBJECT, so replanning invalidates it without needing a hook
on every writer of ``_plan_index``.
"""
cfg = self._selected_plan_config
cached = self._selected_engine_cache
if cached is not None and cached[0] is cfg:
return cached[1]
engine = self._engine_for(cfg)
self._selected_engine_cache = (cfg, engine)
return engine
# =========================================================================
# 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.
Called at lowering and at planning, whichever happens first.
Frozen-ness is ONE flag, on the graph. Every mutation route the public
API offers goes through _check_mutable (the chained setters via
Tensor._guard / Node._guard, and the op builders), so the flag alone is
the guard. The structures the caller could otherwise mutate behind the
API's back are made immutable in their own right rather than watched:
node.inputs/outputs/params become MappingProxy views and dim/stride
become 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))
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
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
# Freeze-guarded like every other setter. A name is a label, but it is
# also a variant-pack key: a compiled plan may be holding the name it
# was built with, and the lowered graph keeps the old one, so a rename
# after planning leaves two answers to "which tensor is 'q'" -- and
# swapping two names would silently rebind buffers. Nothing needs to
# rename a planned graph; build another one.
self._check_mutable("rename a tensor")
if self._tensors.get(t.name) is t:
del self._tensors[t.name]
object.__setattr__(t, "name", name)
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: C++ validation happens HERE, so a config the backend
# rejects raises from validate() where callers catch it to skip. Skipped
# only for a graph the backend has no lowering for (GDN/KDA/...), or when
# the caller registered its own engine — the pre-existing exemption.
if self._backend_lowerable() 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 heuristics
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.
USER-assigned dim/stride are never overwritten: they describe the
caller's actual buffers and are the authoritative API-level layout.
Some composite nodes remap their C++ input descriptors in place to
the backend's internal convention (the fp8/mxfp8 SDPA nodes swap K's
dim/stride to the KT = (B, H, D, S) view the backend wants — same
memory, transposed description); reflecting that back would corrupt
the IR's user-facing (B, H, S, D) contract that graph analyzers and
python engines consume."""
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 and not ir.dim_assigned:
object.__setattr__(ir, "dim", tuple(d)) # sealed (graph is frozen)
if st and not ir.stride_assigned:
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).
Both sides are collected here and ranked into ONE flat list of
``PlanConfig(engine_id, knobs)``: the python engines that claim the
graph (discovered from ``engines.manifest`` — no registration call, no
environment variable) and the backend's own ranked recommendation
(``backend_plan_entries()``, [] when the backend declined the graph or
is not installed). ``engines.heuristics.rank`` decides the order;
``build_plans()`` walks it.
Args:
heuristics: cuDNN heuristic modes for the backend's recommendation.
"""
if not self._is_validated:
self.validate()
from .engines.heuristics import rank
# 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"
)
self._backend_heuristics = heuristics # read by backend_plan_entries()
# Finalize -> freeze -> analyze, in that order, BEFORE any ranking runs.
# build_operation_graph() is where the backend's own layout inference
# lands (a no-op for a graph that was never lowered), and it writes
# through object.__setattr__ precisely to bypass the freeze — so the
# snapshot has to come after it, not merely after validate(). Doing it
# here rather than inside the heuristics keeps the epoch boundary out of
# overridable policy: every engine probe and the ranking then read one
# set of facts describing a graph that can no longer change.
self._finalize_backend_layout()
self._freeze()
self._attach_facts()
plans = list(rank(self, self._candidate_engines(), self.backend_plan_entries(), self._backend_heuristics))
# Validate the FINAL router output: every entry must name an engine this
# graph can actually dispatch to.
from .engines.engine_ids import is_python_engine
known = {e.engine_id for e in self._candidate_engines()}
for cfg in plans:
if is_python_engine(cfg.engine_id) and not self._owners_for_id(cfg.engine_id):
raise ValueError(f"heuristics produced a plan for unknown python engine_id {cfg.engine_id} (known: {sorted(known)})")
if not plans:
# Say WHY, or the user is left guessing which side had nothing: the
# backend's own rejection is the usual answer.
why = f" (the backend declined: {self._backend_declined})" if self._backend_declined is not None else ""
raise cudnn_graph_not_supported(f"no engine — python or backend — proposed a plan for this graph{why}")
self._plans = plans
self._planning_done = True
self._plan_index = 0
def _candidate_engines(self) -> List["BaseEngine"]:
"""The python engines this graph may dispatch to: the in-tree manifest
family whose coarse key matches, and nothing else — an engine is known
because the library ships it, not because someone handed it over.
(Candidate ORDER is not rank — ``heuristics.rank`` ranks.) Cached: the
graph is frozen at planning time anyway."""
if self._candidates is None:
from .engines import manifest
self._candidates = list(manifest.engines_for(self))
return self._candidates
def _finalize_backend_layout(self) -> None:
"""Let the backend's layout inference land before the graph is frozen.
Lowering and reflecting strides back is how the GRAPH learns what it
will execute with, so it runs for every backend-lowerable graph -- a
path that skipped it for graphs a python engine might claim is what
left the snapshot unenforceable, since _sync_ir_shapes_from_backend
writes through object.__setattr__ to bypass the freeze.
A failure here is the backend DECLINING (a python engine may still
serve the graph), recorded as backend_plan_entries() records one. Not
routed through that, which also runs the ~178 ms C++ plan query a
the heuristics may never ask for.
"""
import cudnn
if not self._backend_lowerable(): # no backend lowering for this op at all
return
try:
self._lower_backend_graph()
except (cudnn.cudnnGraphNotSupportedError, RuntimeError, ImportError, AttributeError) as exc:
_LOG.warning("backend could not build this graph, treating as a decline: %s", exc)
self._backend_declined = exc
self._lowered_graph = None
self._cpp_tensors.clear()
self._cpp_bog_done = False
self._cpp_plans_created = False
self._backend_mode_spans.clear()
self._backend_entries = []
def _attach_facts(self) -> None:
"""Describe this frozen graph in its family's vocabulary.
Part of planning, not a call anyone makes. Runs AFTER _freeze(): a
snapshot of a graph that can still change means chasing every mutation
point, and missing one leaves facts describing a graph that is gone.
A graph no family claims carries no payload.
"""
from .engines import manifest
family = manifest.family_for(self)
if family is not None and family.offered_ids():
analyzer = manifest.resolve_analyzer(family)
if analyzer is not None:
self._facts_for(analyzer)
def _facts_for(self, analyzer):
"""The record ``analyzer`` produced for this graph, computing it once.
Keyed by the analyzer itself so the ranking and the engine reach ONE
record with no name to keep in sync -- two extractions of a graph is
how a feature vector drifts from what the kernel does.
"""
if not self._frozen:
# Still mutable (a direct engine probe before planning): answer, but