-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtrace_module_analyzer.py
More file actions
executable file
·3754 lines (3316 loc) · 166 KB
/
Copy pathtrace_module_analyzer.py
File metadata and controls
executable file
·3754 lines (3316 loc) · 166 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
#!/usr/bin/env python3
"""Trace Module Analyzer — Correlation-based GPU kernel classification.
Uses nn.Module hierarchy from PyTorch traces to automatically classify GPU
kernels by module, eliminating the need for hardcoded regex patterns.
Supports two trace modes:
- Full mode: kernel events present (diffusion traces) — correlates via cuda_runtime
- CPU-only mode: no kernel events (LLM traces) — uses cpu_op time containment
Pipeline:
ModuleTreeBuilder — Parse python_function events into an nn.Module hierarchy tree.
KernelCorrelator — Map GPU kernels to modules via cuda_runtime correlation IDs.
CudaGraphCorrelator — Handle CUDA-graph-replayed kernels by mapping them to
synthetic layer modules.
CpuOpCorrelator — Map cpu_ops to modules via timestamp containment (CPU-only mode).
ModuleAggregator — Roll up per-module statistics (time, counts, breakdowns).
PhaseDetector — Detect prefill vs decode phases in LLM traces.
ReportGenerator — Produce console summaries and Excel reports.
TraceModuleAnalyzer — Top-level orchestrator that chains all the above.
PhaseDetector (prefill vs decode):
Three-tier approach — use explicit function-call markers when available,
fall back to CUDA graph replay detection, then to keyword heuristics.
1. Phase markers: During trace loading, scan python_function events for
SGLang's ModelRunner dispatch functions:
- "model_runner.py(...): forward_extend" → prefill (duration span)
- "model_runner.py(...): forward_decode" → decode (duration span)
Each marker records (start_ts, end_ts, phase, tid, pid).
NOTE: We do NOT use forward_batch_info "is_extend"/"is_decode" events.
Those are boolean property checks on ForwardMode that fire during both
prefill and decode paths (e.g. inside init_new, attention backend init),
making them unreliable as phase indicators.
2. Tagging modules: For each module node, check whether it falls within
any forward_extend or forward_decode time span. Children inherit their
parent's phase since a single forward pass is entirely prefill or decode.
3. CUDA graph replay: CudaGraphReplay roots are always decode (hardcoded
at construction time in CudaGraphReplayHandler).
4. Fallback (no markers): If the trace has no model_runner markers
(e.g. non-SGLang traces), scan each module's cpu_op event names and
majority-vote on "prefill"/"extend" vs "decode" keywords.
5. Propagation: _propagate_phase pushes assigned phases down to any untagged
child nodes, ensuring every node in the tree has a phase label.
"""
import argparse
import bisect
import csv
import gzip
import json
import logging
import math
import operator
import os
import re
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
try:
from fix_rocm_trace_flow import fix_trace as _rocm_fix_trace
_HAS_ROCM_FIX = True
except ImportError:
_HAS_ROCM_FIX = False
# Maximum data rows per Excel tab (excluding header). Keeps file size
# manageable for large traces (e.g. DeepSeek with 24K+ decoder instances).
MAX_ROWS_PER_TAB = 1000
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class ModuleNode:
"""Tree node representing an nn.Module invocation."""
name: str # e.g. "WanTransformerBlock_5"
module_type: str # e.g. "WanTransformerBlock"
instance_id: int # e.g. 5
ts: float # start timestamp (us)
end: float # ts + dur
tid: int
pid: int
children: List["ModuleNode"] = field(default_factory=list)
kernels: List[Dict] = field(default_factory=list)
cpu_ops: List[Dict] = field(default_factory=list)
class KernelDetail:
"""Individual kernel/op record for detail reporting.
Uses __slots__ for fast instantiation (critical when creating millions
of instances for large traces).
"""
__slots__ = ("name", "duration", "category", "module_path",
"ts", "phase", "input_dims", "source_path")
def __init__(self, name: str, duration: float, category: str,
module_path: str, ts: float = 0.0, phase: str = "",
input_dims: str = "", source_path: str = ""):
self.name = name
self.duration = duration
self.category = category
self.module_path = module_path
self.ts = ts
self.phase = phase
self.input_dims = input_dims
self.source_path = source_path
@dataclass
class ModuleStats:
"""Aggregated statistics for a module node."""
name: str
module_type: str
instance_id: int
depth: int
total_kernel_time: float = 0.0
total_cpu_op_time: float = 0.0
self_kernel_time: float = 0.0
self_cpu_op_time: float = 0.0
kernel_count: int = 0
cpu_op_count: int = 0
kernel_breakdown: Dict[str, Tuple[float, int]] = field(default_factory=dict) # category -> (dur, count)
kernel_details: List[KernelDetail] = field(default_factory=list) # all kernels/ops in time order
children_stats: List["ModuleStats"] = field(default_factory=list)
phase: str = "" # "prefill" / "decode" / ""
# Ancestor path from nn.Module root to this node's parent ("" at roots).
parent_tree_path: str = ""
# ---------------------------------------------------------------------------
# Module tree builder
# ---------------------------------------------------------------------------
class ModuleTreeBuilder:
"""Build nn.Module hierarchy tree from python_function events."""
MODULE_PREFIX = "nn.Module: "
def build(self, events: List[Dict]) -> List[ModuleNode]:
"""Build forest from all events (filters for nn.Module internally)."""
module_events = [
e for e in events
if e.get("cat") == "python_function"
and str(e.get("name", "")).startswith(self.MODULE_PREFIX)
and e.get("dur") is not None
]
return self.build_from_module_events(module_events)
def build_from_module_events(self, module_events: List[Dict]) -> List[ModuleNode]:
"""Build forest from pre-filtered nn.Module events (faster for large traces)."""
if not module_events:
return []
# Group by (pid, tid)
by_thread: Dict[Tuple[int, int], List[Dict]] = defaultdict(list)
for e in module_events:
by_thread[(e["pid"], e["tid"])].append(e)
roots = []
for (pid, tid), thread_events in by_thread.items():
thread_events.sort(key=lambda x: (x["ts"], -x.get("dur", 0)))
thread_roots = self._build_thread(thread_events, pid, tid)
roots.extend(thread_roots)
return roots
def _build_thread(self, events: List[Dict], pid: int, tid: int) -> List[ModuleNode]:
"""Stack-based nesting for events on a single thread."""
roots = []
stack: List[ModuleNode] = []
for e in events:
ts = e["ts"]
dur = e.get("dur", 0)
end = ts + dur
raw_name = e["name"][len(self.MODULE_PREFIX):]
module_type, instance_id = self._parse_name(raw_name)
node = ModuleNode(
name=raw_name,
module_type=module_type,
instance_id=instance_id,
ts=ts, end=end,
tid=tid, pid=pid,
)
# Pop finished ancestors
while stack and stack[-1].end <= ts:
stack.pop()
if stack:
stack[-1].children.append(node)
else:
roots.append(node)
stack.append(node)
return roots
@staticmethod
def _parse_name(raw_name: str) -> Tuple[str, int]:
m = re.match(r"^(.+?)_(\d+)$", raw_name)
if m:
return m.group(1), int(m.group(2))
return raw_name, 0
# ---------------------------------------------------------------------------
# Kernel correlator (full mode: kernel → cuda_runtime → module)
# ---------------------------------------------------------------------------
class _IntervalIndex:
"""Shared interval index for O(log n) module lookup by timestamp."""
def __init__(self, roots: List[ModuleNode]):
self._intervals: Dict[Tuple[int, int], List[Tuple[float, float, ModuleNode]]] = defaultdict(list)
self._starts_cache: Dict[Tuple[int, int], List[float]] = {}
self._collect_all_nodes(roots)
for key in self._intervals:
self._intervals[key].sort(key=lambda x: (x[0], -x[1]))
self._starts_cache[key] = [iv[0] for iv in self._intervals[key]]
def _collect_all_nodes(self, nodes: List[ModuleNode]):
for node in nodes:
self._intervals[(node.pid, node.tid)].append((node.ts, node.end, node))
self._collect_all_nodes(node.children)
def find_deepest(self, ts: float, pid: int, tid: int) -> Optional[ModuleNode]:
key = (pid, tid)
intervals = self._intervals.get(key)
if not intervals:
return None
starts = self._starts_cache[key]
idx = bisect.bisect_right(starts, ts) - 1
best: Optional[ModuleNode] = None
best_span = float("inf")
for i in range(max(0, idx - 20), min(len(intervals), idx + 20)):
s, e, node = intervals[i]
if s <= ts <= e:
span = e - s
if span < best_span:
best_span = span
best = node
elif s > ts:
break
return best
class CpuOpShapeIndex:
"""Map correlation IDs to cpu_op tensor shapes via timestamp containment.
For each cuda_runtime/cuda_driver launch event, finds the enclosing cpu_op
on the same thread and extracts its ``Input Dims`` argument.
"""
def __init__(self, cpu_ops: List[Dict], runtime_events: List[Dict],
driver_events: Optional[List[Dict]] = None):
# Build interval index of cpu_ops that carry Input Dims
shaped_ops: Dict[Tuple[int, int], List[Tuple[float, float, str]]] = defaultdict(list)
for op in cpu_ops:
dims = op.get("args", {}).get("Input Dims")
if not dims:
continue
ts = op["ts"]
dur = op.get("dur", 0)
tid = op["tid"]
pid = op.get("pid", tid)
dims_str = self._format_dims(dims)
if dims_str:
shaped_ops[(pid, tid)].append((ts, ts + dur, dims_str))
for key in shaped_ops:
shaped_ops[key].sort(key=lambda x: (x[0], -x[1]))
self._shaped_ops = shaped_ops
self._starts_cache: Dict[Tuple[int, int], List[float]] = {
k: [iv[0] for iv in v] for k, v in shaped_ops.items()
}
# Build correlation → shape mapping
self._corr_to_shape: Dict[int, str] = {}
all_launches = list(runtime_events)
if driver_events:
all_launches.extend(driver_events)
for e in all_launches:
corr = e.get("args", {}).get("correlation")
if corr is None or corr in self._corr_to_shape:
continue
ts = e["ts"]
tid = e["tid"]
pid = e.get("pid", tid)
shape = self._find_shape(ts, pid, tid)
if shape:
self._corr_to_shape[corr] = shape
def get_shape(self, correlation_id: int) -> str:
"""Return Input Dims string for a kernel's correlation ID, or ''."""
return self._corr_to_shape.get(correlation_id, "")
def _find_shape(self, ts: float, pid: int, tid: int) -> str:
key = (pid, tid)
intervals = self._shaped_ops.get(key)
if not intervals:
return ""
starts = self._starts_cache[key]
idx = bisect.bisect_right(starts, ts) - 1
# Search nearby intervals for the deepest (narrowest) enclosing cpu_op
best = ""
best_span = float("inf")
for i in range(max(0, idx - 5), min(len(intervals), idx + 5)):
s, e, dims_str = intervals[i]
if s <= ts <= e:
span = e - s
if span < best_span:
best_span = span
best = dims_str
elif s > ts + 100:
break
return best
@staticmethod
def _format_dims(dims) -> str:
"""Format Input Dims into a concise string, omitting empty entries."""
if not isinstance(dims, list):
return ""
non_empty = [d for d in dims if isinstance(d, list) and len(d) > 0]
if not non_empty:
return ""
return str(non_empty)
class PythonSourceIndex:
"""Map correlation IDs to the Python source location of each kernel launch.
For each cuda_runtime/cuda_driver launch event, finds the narrowest
enclosing python_function event whose name contains a ``.py(`` source
reference (e.g. ``sglang/srt/layers/attention/fla/chunk.py(26):
chunk_gated_delta_rule_fwd``).
Falls back to a cleaned-up ``<built-in method NAME ...>`` if no ``.py``
source is found in the enclosing stack.
"""
_BUILTIN_RE = re.compile(
r"<built-in (?:method|function) (\S+)")
_FRAMEWORK_PREFIXES = (
"torch/", "threading.py", "multiprocessing/",
"<string>", "tqdm/", "importlib/", "contextlib.py",
)
_WRAPPER_RE = re.compile(
r":\s*(?:wrapper|custom_wrapper|outer_wrapper|wrapper_custom"
r"|__call__|_call_impl|<lambda>|<module>|<genexpr>"
r"|decorate_context|decorate_fwd|run)\s*$"
)
def __init__(self, pyfunc_events: List[Dict], runtime_events: List[Dict],
driver_events: Optional[List[Dict]] = None):
pf_intervals: Dict[Tuple[int, int], List[Tuple[float, float, str]]] = defaultdict(list)
for e in pyfunc_events:
dur = e.get("dur")
if dur is None or dur < 1.0:
continue
ts = e["ts"]
tid = e["tid"]
pid = e.get("pid", tid)
pf_intervals[(pid, tid)].append((ts, ts + dur, e.get("name", "")))
for key in pf_intervals:
pf_intervals[key].sort(key=lambda x: (x[0], -x[1]))
self._intervals = pf_intervals
self._starts_cache: Dict[Tuple[int, int], List[float]] = {
k: [iv[0] for iv in v] for k, v in pf_intervals.items()
}
self._corr_to_source: Dict[int, str] = {}
all_launches = list(runtime_events)
if driver_events:
all_launches.extend(driver_events)
for e in all_launches:
corr = e.get("args", {}).get("correlation")
if corr is None or corr in self._corr_to_source:
continue
ts = e["ts"]
tid = e["tid"]
pid = e.get("pid", tid)
src = self._find_source(ts, pid, tid)
if src:
self._corr_to_source[corr] = src
def get_source(self, correlation_id: int) -> str:
return self._corr_to_source.get(correlation_id, "")
def _find_source(self, ts: float, pid: int, tid: int) -> str:
"""Find the best Python source location enclosing *ts*.
Four-tier preference (narrowest wins within each tier):
1. App-level ``.py(`` frames — not framework, not wrapper/dispatch
2. App-level ``.py(`` frames — not framework (wrapper allowed)
3. Any ``.py(`` frame (including framework internals)
4. ``<built-in method/function NAME ...>`` with pybind11 noise stripped
"""
key = (pid, tid)
intervals = self._intervals.get(key)
if not intervals:
return ""
starts = self._starts_cache[key]
idx = bisect.bisect_right(starts, ts) - 1
best_app = ""
best_app_span = float("inf")
best_app_wrap = ""
best_app_wrap_span = float("inf")
best_any_py = ""
best_any_py_span = float("inf")
best_fallback = ""
best_fb_span = float("inf")
fw_prefixes = self._FRAMEWORK_PREFIXES
wrapper_re = self._WRAPPER_RE
lo = max(0, idx - 80)
hi = min(len(intervals), idx + 10)
for i in range(lo, hi):
s, e, name = intervals[i]
if s <= ts <= e:
span = e - s
if ".py(" in name:
is_framework = name.startswith(fw_prefixes)
is_wrapper = bool(wrapper_re.search(name))
if not is_framework:
if not is_wrapper and span < best_app_span:
best_app_span = span
best_app = name
if span < best_app_wrap_span:
best_app_wrap_span = span
best_app_wrap = name
if span < best_any_py_span:
best_any_py_span = span
best_any_py = name
elif span < best_fb_span:
best_fb_span = span
best_fallback = name
elif s > ts + 100:
break
if best_app:
return best_app
if best_app_wrap:
return best_app_wrap
if best_any_py:
return best_any_py
if best_fallback:
m = self._BUILTIN_RE.match(best_fallback)
if m:
return f"<built-in {m.group(1)}>"
return best_fallback
return ""
class KernelCorrelator:
"""Map GPU kernels to modules via correlation ID chain."""
def __init__(self, runtime_events: List[Dict], roots: List[ModuleNode],
driver_events: Optional[List[Dict]] = None):
# Build correlation → launch event mapping from both cuda_runtime
# and cuda_driver events. On B200, many kernels are launched via
# cuLaunchKernelEx (cuda_driver) instead of cuda_runtime, so both
# sources are needed for complete correlation coverage.
self._corr_to_rt: Dict[int, Dict] = {}
for e in runtime_events:
corr = e.get("args", {}).get("correlation")
if corr is not None:
self._corr_to_rt[corr] = e
if driver_events:
for e in driver_events:
corr = e.get("args", {}).get("correlation")
if corr is not None and corr not in self._corr_to_rt:
self._corr_to_rt[corr] = e
self._index = _IntervalIndex(roots)
def correlate(self, kernel_events: List[Dict], roots: List[ModuleNode],
shape_index: Optional["CpuOpShapeIndex"] = None,
source_index: Optional["PythonSourceIndex"] = None) -> int:
"""Assign each kernel to its deepest enclosing module. Returns count matched."""
matched = 0
for k in kernel_events:
corr = k.get("args", {}).get("correlation")
if corr is None:
continue
rt = self._corr_to_rt.get(corr)
if rt is None:
continue
cpu_ts = rt["ts"]
cpu_tid = rt["tid"]
cpu_pid = rt.get("pid", cpu_tid)
module = self._index.find_deepest(cpu_ts, cpu_pid, cpu_tid)
if module is not None:
if shape_index is not None:
k["_input_dims"] = shape_index.get_shape(corr)
if source_index is not None:
k["_source_path"] = source_index.get_source(corr)
k["_matched"] = True
module.kernels.append(k)
matched += 1
return matched
# ---------------------------------------------------------------------------
# CUDA graph replay correlator
# ---------------------------------------------------------------------------
class CudaGraphCorrelator:
"""Correlate CUDA-graph-replayed kernels to synthetic layer modules."""
_COMM_RE = re.compile(
r"all_reduce|allreduce|cross_device_reduce|nccl|rccl|allgather"
r"|reduce_scatter|quickreduce|all_to_all", re.IGNORECASE)
_ATTN_RE = re.compile(
r"aiter::mla_|mla_a8w8|decode_attention|flash_attn|attention|softmax"
r"|fmha|mla_reduce|kv_cache|paged_attention|chunk_gated_delta_rule"
r"|fused_gdn|kn_get_mla_metadata|kn_mla_reduce|gating_delta_rule",
re.IGNORECASE)
_MOE_RE = re.compile(
r"fused_moe|moe_align|topk|expert|MoeFlatmm|MoeSorting|kernel_moe_gemm"
r"|kernel_moe_mxgemm|shared_experts|grouped_topk|fused_append_shared_experts",
re.IGNORECASE)
def __init__(self, runtime_events):
self._graph_corrs = set()
for e in runtime_events:
name = e.get("name", "")
if "hipGraphLaunch" in name or "cudaGraphLaunch" in name:
corr = e.get("args", {}).get("correlation")
if corr is not None:
self._graph_corrs.add(corr)
@property
def has_graph_replays(self):
return bool(self._graph_corrs)
def correlate(self, gpu_events, capture_roots):
"""Split graph-replayed events into synthetic layer modules.
Returns (new_roots, matched_count).
"""
# 1. Partition: graph-replay events grouped by correlation ID
corr_to_events = defaultdict(list)
for e in gpu_events:
corr = e.get("args", {}).get("correlation")
if corr in self._graph_corrs:
corr_to_events[corr].append(e)
if not corr_to_events:
return [], 0
# Sort each group by timestamp
for corr in corr_to_events:
corr_to_events[corr].sort(key=lambda e: e.get("ts", 0))
# 2. Extract capture-iteration layer names for naming (needed before
# template detection so we can skip half-layer merging when the
# capture iteration already has distinct half-layer module types).
capture_layer_names = self._extract_layer_names(capture_roots)
has_distinct_half_layers = self._has_distinct_half_layer_types(
capture_layer_names)
# 3. Deduplicate into templates by kernel count (signature)
templates = {} # sig_len -> layer boundaries [(start, end, label), ...]
for corr, evts in corr_to_events.items():
sig_len = len(evts)
if sig_len not in templates:
names = [e.get("name", "") for e in evts]
templates[sig_len] = self._detect_layers(
names, skip_merge=has_distinct_half_layers)
# 3b. Classify each template as "target" or "draft" based on layer count.
# The target model's CUDA graph has many layers (e.g. 61 for DeepSeek V3);
# the MTP draft model's graph has very few (e.g. 1).
max_layers = max(len(b) for b in templates.values()) if templates else 0
template_is_target = {}
for sig_len, bounds in templates.items():
template_is_target[sig_len] = len(bounds) >= max(max_layers // 2, 2)
has_both = (any(template_is_target.values())
and not all(template_is_target.values()))
# 4. Build synthetic module trees for each replay
new_roots = []
matched = 0
target_idx = 0
draft_idx = 0
for corr in sorted(corr_to_events,
key=lambda c: corr_to_events[c][0].get("ts", 0)):
evts = corr_to_events[corr]
sig_len = len(evts)
layer_bounds = templates.get(sig_len, [(0, sig_len, "unknown")])
is_target = template_is_target.get(sig_len, True)
if has_both:
if is_target:
root_name = f"CudaGraphReplay_Target_{target_idx}"
root_type = "CudaGraphReplay_Target"
root_id = target_idx
target_idx += 1
else:
root_name = f"CudaGraphReplay_Draft_{draft_idx}"
root_type = "CudaGraphReplay_Draft"
root_id = draft_idx
draft_idx += 1
else:
root_name = f"CudaGraphReplay_{target_idx + draft_idx}"
root_type = "CudaGraphReplay"
root_id = target_idx + draft_idx
target_idx += 1
root = ModuleNode(
name=root_name,
module_type=root_type,
instance_id=root_id,
ts=evts[0].get("ts", 0),
end=evts[-1].get("ts", 0) + evts[-1].get("dur", 0),
tid=evts[0].get("tid", 0),
pid=evts[0].get("pid", 0),
)
root._phase = "decode"
for layer_i, (start, end, label) in enumerate(layer_bounds):
if is_target and layer_i < len(capture_layer_names):
layer_name = capture_layer_names[layer_i]
else:
layer_name = f"Layer_{layer_i}"
mod_type, inst_id = ModuleTreeBuilder._parse_name(layer_name)
layer_evts = evts[start:end]
if not layer_evts:
continue
layer_node = ModuleNode(
name=layer_name,
module_type=mod_type,
instance_id=inst_id,
ts=layer_evts[0].get("ts", 0),
end=layer_evts[-1].get("ts", 0) + layer_evts[-1].get("dur", 0),
tid=root.tid,
pid=root.pid,
)
layer_node._phase = "decode"
layer_node.kernels = layer_evts
matched += len(layer_evts)
root.children.append(layer_node)
new_roots.append(root)
if has_both:
print(f" CUDA graph sub-types: {target_idx} target, {draft_idx} draft")
return new_roots, matched
def _detect_layers(self, names, skip_merge=False):
"""COMM-based segmentation + half-layer merging.
Returns [(start_idx, end_idx, type_label), ...].
skip_merge: when True, never merge half-layers. Used when the
capture iteration already has distinct module types for each half
(e.g. Qwen3_5AttentionDecoderLayer + Qwen3_5LinearDecoderLayer).
"""
cats = [self._quick_cat(n) for n in names]
comm_pos = [i for i, c in enumerate(cats) if c == "COMM"]
if len(comm_pos) < 2:
return [(0, len(names), "unknown")]
# Build segments ending at each COMM
segments = []
prev = 0
for cp in comm_pos:
seg = cats[prev:cp + 1]
segments.append((prev, cp + 1, "ATTN" in seg, "MOE" in seg))
prev = cp + 1
if prev < len(cats):
seg = cats[prev:]
segments.append((prev, len(cats), "ATTN" in seg, "MOE" in seg))
if skip_merge:
return [(s[0], s[1], self._seg_label(s[2], s[3])) for s in segments]
# Decide: half-layer vs full-layer model
attn_only = sum(1 for s in segments if s[2] and not s[3])
full_layer = sum(1 for s in segments if s[2] and s[3])
if attn_only >= full_layer:
return self._merge_half_layers(segments)
else:
return [(s[0], s[1], self._seg_label(s[2], s[3])) for s in segments]
@staticmethod
def _merge_half_layers(segments):
"""Merge adjacent ATTN-only + non-ATTN pairs into full layers."""
layers = []
i = 0
while i < len(segments):
start, end, has_attn, has_moe = segments[i]
if has_attn and not has_moe and i + 1 < len(segments):
_, next_end, _, next_moe = segments[i + 1]
layers.append((start, next_end, "MoE" if next_moe else "FC"))
i += 2
else:
layers.append((start, end,
CudaGraphCorrelator._seg_label(has_attn, has_moe)))
i += 1
return layers
@staticmethod
def _seg_label(has_attn, has_moe):
if has_attn and has_moe:
return "MoE"
if has_attn:
return "Attn"
if has_moe:
return "MoE"
return "other"
def _quick_cat(self, name):
if self._COMM_RE.search(name):
return "COMM"
if self._ATTN_RE.search(name):
return "ATTN"
if self._MOE_RE.search(name):
return "MOE"
return "X"
@staticmethod
def _extract_layer_names(roots):
"""Extract decoder layer names from capture-iteration module tree."""
names = []
for root in roots:
for child in root.children:
if ("DecoderLayer" in child.module_type
or "TransformerBlock" in child.module_type):
names.append(child.name)
return names
@staticmethod
def _has_distinct_half_layer_types(layer_names):
"""Check if capture layer names contain multiple distinct DecoderLayer types.
Models like Qwen3.5 split each transformer layer into two separate
nn.Module types (AttentionDecoderLayer + LinearDecoderLayer). When
this is the case, the CUDA graph segmentation should NOT merge
adjacent half-layers, because each half already has its own module
identity from the capture iteration.
"""
types = set()
for name in layer_names:
m = re.match(r"^(.+?)_(\d+)$", name)
types.add(m.group(1) if m else name)
return len(types) > 1
# ---------------------------------------------------------------------------
# CPU-op correlator (CPU-only mode: cpu_op → module via time containment)
# ---------------------------------------------------------------------------
class CpuOpCorrelator:
"""Map cpu_ops to modules via time containment on the same thread."""
def __init__(self, roots: List[ModuleNode]):
self._index = _IntervalIndex(roots)
def correlate(self, cpu_ops: List[Dict], roots: List[ModuleNode]) -> int:
"""Assign cpu_ops to deepest enclosing module. Returns count matched."""
matched = 0
find = self._index.find_deepest # avoid attribute lookup in hot loop
for op in cpu_ops:
dur = op.get("dur", 0)
if dur < 1.0: # skip trivial ops under 1 us
continue
ts = op["ts"]
tid = op["tid"]
pid = op.get("pid", tid)
module = find(ts, pid, tid)
if module is not None:
module.cpu_ops.append(op)
matched += 1
return matched
# ---------------------------------------------------------------------------
# Module aggregator
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Kernel category classification — loaded from kernel_categories.csv
# ---------------------------------------------------------------------------
_DEFAULT_CSV = os.path.join(os.path.dirname(os.path.abspath(__file__)), "kernel_categories.csv")
def _load_kernel_categories(csv_path: str = _DEFAULT_CSV) -> List[Tuple[str, re.Pattern]]:
"""Load (category, compiled_regex) list from a CSV file.
The CSV must have columns: category, pattern
Each pattern is a regex alternation (e.g. "nccl|rccl|all_reduce").
Rows are matched top-to-bottom; first match wins.
"""
if not os.path.isfile(csv_path):
logger.warning("kernel_categories.csv not found at %s, no categories loaded", csv_path)
return []
categories = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
cat = row["category"].strip()
pat = row["pattern"].strip()
categories.append((cat, re.compile(pat, re.IGNORECASE)))
return categories
_KERNEL_CATEGORIES = _load_kernel_categories()
_categorize_cache: Dict[str, str] = {}
def _categorize_kernel(name: str) -> str:
"""Categorize a kernel name into a high-level category."""
cached = _categorize_cache.get(name)
if cached is not None:
return cached
for cat, pat in _KERNEL_CATEGORIES:
if pat.search(name):
_categorize_cache[name] = cat
return cat
_categorize_cache[name] = "other"
return "other"
def _categorize_cpu_op(name: str) -> str:
"""Categorize a cpu_op name into a high-level category."""
cached = _categorize_cache.get(name)
if cached is not None:
return cached
for cat, pat in _KERNEL_CATEGORIES:
if pat.search(name):
_categorize_cache[name] = cat
return cat
_categorize_cache[name] = "other"
return "other"
def _parse_dims_literal(dims_str: str):
"""Parse Input Dims string from trace (list-of-lists repr) for FLOP heuristics."""
if not dims_str or not isinstance(dims_str, str):
return None
s = dims_str.strip()
if not s.startswith("["):
return None
try:
import ast
return ast.literal_eval(s)
except Exception:
pass
try:
return json.loads(s)
except Exception:
return None
def _try_gemm_flops(a: list, b: list) -> float:
"""Try to compute 2*M*K*N from a pair of 2D or 3D tensor shapes.
Handles both row-major [M,K]@[K,N] and column-major weight [M,K]@[N,K]
(transposed), as well as batched variants [B,M,K]@[B,K,N] / [B,M,K]@[B,N,K].
Returns NaN if shapes don't look like a matmul pair.
"""
try:
if len(a) == 2 and len(b) == 2:
M, K = float(a[0]), float(a[1])
if a[1] == b[0]: # [M,K] @ [K,N]
return 2.0 * M * K * float(b[1])
if a[1] == b[1]: # [M,K] @ [N,K] (weight transposed)
return 2.0 * M * K * float(b[0])
if len(a) == 3 and len(b) == 3 and a[0] == b[0]:
B, M, K = float(a[0]), float(a[1]), float(a[2])
if a[2] == b[1]: # [B,M,K] @ [B,K,N]
return 2.0 * B * M * K * float(b[2])
if a[2] == b[2]: # [B,M,K] @ [B,N,K] (weight transposed)
return 2.0 * B * M * K * float(b[1])
except (TypeError, ValueError, IndexError):
pass
return float("nan")
_ATTN_SKIP_RE = re.compile(
r"mla_reduce|kn_mla_reduce|softmax|kv_cache|set_mla_kv|"
r"kn_get_mla_metadata|paged_attention|PagedKVCache|radix", re.IGNORECASE)
_MLA_QH_VH_RE = re.compile(r"qh(\d+)_vh(\d+)", re.IGNORECASE)
_ATTN_HD_RE = re.compile(r"hd(\d+)", re.IGNORECASE)
def _try_attention_flops(dims: list, kernel_name: str = "") -> float:
"""Estimate FLOPs for attention compute kernels from Q/K/V tensor shapes.
Skips non-compute attention sub-kernels (reduce, softmax, kv_cache, etc.).
For MLA kernels whose name encodes head dims (e.g. ``mla_pfl_qh192_vh128``),
d_qk and d_v are parsed from the name so chunked/split input dims don't
mislead. Otherwise head dims are inferred from the 3-D tensor shapes.
Prefill (self-attention):
FLOPs = 2 * S^2 * H * d_qk + 2 * S^2 * H * d_v
Decode (Q against KV cache, S_q << S_kv):
FLOPs = 2 * S_q * S_kv * H * d_qk + 2 * S_q * S_kv * H * d_v
Returns NaN when shapes can't be interpreted as attention.
Verified examples:
# MLA prefill, self-attn S=75600, qh192_vh128
# dims=[[75600,40,192],[75600,40,192],[75600,40,128],...]
# kernel="aiter::mla_pfl_qh192_vh128_..."
# -> 2*75600^2*40*192 + 2*75600^2*40*128 = 146.313 TFLOP
# MLA reduce (skipped — non-compute sub-kernel)
# kernel="...kn_mla_reduce_v1_ps..." -> NaN
# MLA prefill USP split, Q=75600 KV=512, qh192_vh128
# dims=[[75600,40,192],[512,40,192],[512,40,128],...]
# -> 2*75600*512*40*192 + 2*75600*512*40*128 = 0.991 TFLOP
# Standard flash_attn, S=4096, hd128
# dims=[[4096,32,128],[4096,32,128],[4096,32,128]]
# -> 2*4096^2*32*128 * 2 = 0.275 TFLOP
# Unknown attn kernel (shape-only inference), S=2048, d=64
# dims=[[2048,16,64],[2048,16,64],[2048,16,64]]
# -> 2*2048^2*16*64 * 2 = 0.017 TFLOP
"""
if _ATTN_SKIP_RE.search(kernel_name):
return float("nan")
if dims is None or not isinstance(dims, list):
return float("nan")
tensors_3d = [d for d in dims if isinstance(d, list) and len(d) == 3]
if len(tensors_3d) < 2:
return float("nan")
try:
# --- Extract head dims from kernel name if available ---
name_d_qk = name_d_v = None
m = _MLA_QH_VH_RE.search(kernel_name)
if m:
name_d_qk, name_d_v = int(m.group(1)), int(m.group(2))
else:
m = _ATTN_HD_RE.search(kernel_name)
if m:
name_d_qk = name_d_v = int(m.group(1))
# --- Group 3-D tensors by head count (dim[1]) ---
by_heads: Dict[int, list] = defaultdict(list)
for t in tensors_3d:
by_heads[t[1]].append(t)
for H_val, group in sorted(by_heads.items(), key=lambda x: -len(x[1])):
if len(group) < 2:
continue
H = float(H_val)
if name_d_qk is not None:
d_qk, d_v = float(name_d_qk), float(name_d_v or name_d_qk)
qk = [t for t in group if t[2] == name_d_qk]
if len(qk) < 2:
continue
qk_seqs = sorted(set(t[0] for t in qk))
S_q = float(qk_seqs[-1])
S_kv = float(qk_seqs[0])
return 2.0 * S_q * S_kv * H * d_qk + 2.0 * S_q * S_kv * H * d_v
by_seq: Dict[int, list] = defaultdict(list)
for t in group:
by_seq[t[0]].append(t)
seq_vals = sorted(by_seq.keys())
# --- Prefill: >=2 tensors share the largest S ---
S_max = seq_vals[-1]
same_s = by_seq[S_max]