wafer-core 0.1.45__py3-none-any.whl → 0.1.47__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ identifying kernel-level performance differences and fusion opportunities.
5
5
  """
6
6
 
7
7
  from .analyzer import analyze_traces
8
+ # from .api import analyze_trace_pair # TODO: api.py has unimplemented dependencies
9
+ from .architecture import ArchitectureType, detect_architecture
8
10
  from .classifier import Op, classify
9
11
  from .formatter import (
10
12
  format_csv,
@@ -15,6 +17,9 @@ from .formatter import (
15
17
  format_text,
16
18
  )
17
19
  from .fusion_analyzer import analyze_fusion_differences
20
+ from .graph_formatter import format_graph_comparison_json, format_graph_comparison_text
21
+ from .graph_formatter_detailed import format_graph_comparison_detailed
22
+ from .graph_matcher import match_traces
18
23
  from .loader import load_trace
19
24
 
20
25
  __all__ = [
@@ -22,6 +27,9 @@ __all__ = [
22
27
  "classify",
23
28
  "load_trace",
24
29
  "analyze_traces",
30
+ # "analyze_trace_pair", # TODO: not yet implemented
31
+ "detect_architecture",
32
+ "ArchitectureType",
25
33
  "analyze_fusion_differences",
26
34
  "format_text",
27
35
  "format_csv",
@@ -29,4 +37,8 @@ __all__ = [
29
37
  "format_fusion_text",
30
38
  "format_fusion_csv",
31
39
  "format_fusion_json",
40
+ "match_traces",
41
+ "format_graph_comparison_text",
42
+ "format_graph_comparison_json",
43
+ "format_graph_comparison_detailed",
32
44
  ]
@@ -0,0 +1,263 @@
1
+ """Graph-based pattern-focused formatter for trace comparison.
2
+
3
+ Presents results grouped by CUDA graph execution patterns, reducing cognitive load
4
+ by showing 1-7 unique patterns instead of thousands of individual kernel executions.
5
+ """
6
+
7
+ from collections import Counter, defaultdict
8
+ from typing import Any
9
+
10
+
11
+ def format_graph_comparison_text(result: dict[str, Any], show_all: bool = False) -> str:
12
+ """Format graph matching results as pattern-focused text report.
13
+
14
+ Args:
15
+ result: Results from graph_matcher.match_traces()
16
+ show_all: Show all patterns without truncation
17
+
18
+ Returns:
19
+ Formatted text report with pattern-focused UX
20
+ """
21
+ lines = []
22
+ summary = result['summary']
23
+ graph_pairs = result['graph_pairs']
24
+
25
+ # Header
26
+ lines.append("=" * 80)
27
+ lines.append("TRACE COMPARISON - GRAPH-BASED ANALYSIS")
28
+ lines.append("=" * 80)
29
+ lines.append("")
30
+
31
+ # Overview section
32
+ lines.append("┌" + "─" * 78 + "┐")
33
+ lines.append("│ OVERVIEW" + " " * 69 + "│")
34
+ lines.append("├" + "─" * 78 + "┤")
35
+
36
+ # Calculate total time from graph pairs
37
+ amd_total_ms = sum(
38
+ sum(k.get('dur', 0) for k in pair['amd_kernels'])
39
+ for pair in graph_pairs
40
+ ) / 1000
41
+ nv_total_ms = sum(
42
+ sum(k.get('dur', 0) for k in pair['nv_kernels'])
43
+ for pair in graph_pairs
44
+ ) / 1000
45
+
46
+ amd_kernels = summary['total_kernel_pairs'] # Actually shows unique positions
47
+ nv_kernels = summary['total_kernel_pairs']
48
+
49
+ lines.append(f"│ AMD: {amd_kernels:>6,} kernel positions {amd_total_ms:>8.1f}ms" + " " * 25 + "│")
50
+ lines.append(f"│ NVIDIA: {nv_kernels:>6,} kernel positions {nv_total_ms:>8.1f}ms" + " " * 25 + "│")
51
+ lines.append(f"│" + " " * 78 + "│")
52
+ lines.append(f"│ Match rate: {summary['match_rate']:.1f}% "
53
+ f"({summary['matched']:,} matched, "
54
+ f"{summary['amd_only']:,} AMD-only, "
55
+ f"{summary['nv_only']:,} NV-only)" + " " * 5 + "│")
56
+ lines.append("└" + "─" * 78 + "┘")
57
+ lines.append("")
58
+
59
+ # CUDA Graph Patterns section
60
+ lines.append("┌" + "─" * 78 + "┐")
61
+ lines.append("│ CUDA GRAPH PATTERNS (Transformer Layers)" + " " * 36 + "│")
62
+ lines.append("├" + "─" * 78 + "┤")
63
+ lines.append(f"│ Total graph executions: {summary['num_graph_pairs']:,}" + " " * 50 + "│")
64
+
65
+ # Group patterns by kernel sequence
66
+ amd_patterns = _group_by_pattern(graph_pairs, 'amd')
67
+ nv_patterns = _group_by_pattern(graph_pairs, 'nv')
68
+
69
+ lines.append(f"│ Unique AMD patterns: {len(amd_patterns)}" + " " * 50 + "│")
70
+ lines.append(f"│ Unique NVIDIA patterns: {len(nv_patterns)}" + " " * 50 + "│")
71
+ lines.append("└" + "─" * 78 + "┘")
72
+ lines.append("")
73
+
74
+ # Pattern Details
75
+ lines.append("=" * 80)
76
+ lines.append("PATTERN DETAILS")
77
+ lines.append("=" * 80)
78
+ lines.append("")
79
+
80
+ # Show AMD patterns
81
+ lines.append("AMD Patterns:")
82
+ max_patterns = len(amd_patterns) if show_all else min(5, len(amd_patterns))
83
+ for i, (pattern, executions) in enumerate(list(amd_patterns.items())[:max_patterns], 1):
84
+ kernel_count = len(executions[0]['amd_kernels'])
85
+ lines.append(f" Pattern {i}: {len(executions):>4} executions ({kernel_count} kernels each)")
86
+
87
+ if i == 1: # Show detail for main pattern
88
+ lines.append(f" First few kernels:")
89
+ first_kernels = sorted(executions[0]['amd_kernels'], key=lambda x: x.get('ts', 0))[:5]
90
+ for k in first_kernels:
91
+ name = k.get('name', '')[:60]
92
+ lines.append(f" - {name}")
93
+
94
+ if not show_all and len(amd_patterns) > 5:
95
+ lines.append(f" ... ({len(amd_patterns) - 5} more patterns)")
96
+ lines.append("")
97
+
98
+ # Show NVIDIA patterns
99
+ lines.append("NVIDIA Patterns:")
100
+ max_patterns = len(nv_patterns) if show_all else min(5, len(nv_patterns))
101
+ for i, (pattern, executions) in enumerate(list(nv_patterns.items())[:max_patterns], 1):
102
+ kernel_count = len(executions[0]['nv_kernels'])
103
+ lines.append(f" Pattern {i}: {len(executions):>4} executions ({kernel_count} kernels each)")
104
+
105
+ if i == 1: # Show detail for main pattern
106
+ lines.append(f" First few kernels:")
107
+ first_kernels = sorted(executions[0]['nv_kernels'], key=lambda x: x.get('ts', 0))[:5]
108
+ for k in first_kernels:
109
+ name = k.get('name', '')[:60]
110
+ lines.append(f" - {name}")
111
+
112
+ if not show_all and len(nv_patterns) > 5:
113
+ lines.append(f" ... ({len(nv_patterns) - 5} more patterns)")
114
+ lines.append("")
115
+
116
+ # Drilling down into main pattern
117
+ lines.append("=" * 80)
118
+ lines.append("MAIN PATTERN COMPARISON (Pattern 1)")
119
+ lines.append("=" * 80)
120
+ lines.append("")
121
+
122
+ if amd_patterns and nv_patterns:
123
+ # Get first execution of main patterns
124
+ amd_main_executions = list(amd_patterns.values())[0]
125
+ nv_main_executions = list(nv_patterns.values())[0]
126
+
127
+ amd_main = amd_main_executions[0]
128
+ nv_main = nv_main_executions[0]
129
+
130
+ # Get kernel type distribution from matches
131
+ amd_types = Counter()
132
+ nv_types = Counter()
133
+
134
+ for match in amd_main['matches']:
135
+ if match['status'] in ['MATCH', 'AMD_ONLY']:
136
+ amd_types[match['amd_type']] += 1
137
+ if match['status'] in ['MATCH', 'NV_ONLY']:
138
+ nv_types[match['nv_type']] += 1
139
+
140
+ lines.append("Kernel Type Distribution (per execution):")
141
+ lines.append(f"{'Type':<20} {'AMD':>8} {'NVIDIA':>8} {'Diff':>8}")
142
+ lines.append("-" * 50)
143
+
144
+ all_types = sorted(set(amd_types.keys()) | set(nv_types.keys()))
145
+ differences = []
146
+
147
+ for ktype in all_types:
148
+ amd_count = amd_types.get(ktype, 0)
149
+ nv_count = nv_types.get(ktype, 0)
150
+ diff = amd_count - nv_count
151
+ diff_str = f"+{diff}" if diff > 0 else str(diff) if diff < 0 else "="
152
+ lines.append(f"{ktype:<20} {amd_count:>8} {nv_count:>8} {diff_str:>8}")
153
+
154
+ if diff != 0:
155
+ differences.append((ktype, diff))
156
+
157
+ lines.append("")
158
+ lines.append("-" * 80)
159
+ lines.append("Key Findings:")
160
+
161
+ if differences:
162
+ # Sort by absolute difference
163
+ differences.sort(key=lambda x: abs(x[1]), reverse=True)
164
+
165
+ for ktype, diff in differences[:3]:
166
+ total_extra = abs(diff) * len(amd_main_executions)
167
+ if diff > 0:
168
+ lines.append(f" • AMD runs {diff:+d} extra {ktype} per execution")
169
+ lines.append(f" → {total_extra:,} extra operations across all executions")
170
+ else:
171
+ lines.append(f" • NVIDIA runs {abs(diff)} extra {ktype} per execution")
172
+ lines.append(f" → {total_extra:,} extra operations across all executions")
173
+ else:
174
+ lines.append(" • Perfect match - kernel types align exactly!")
175
+
176
+ lines.append("")
177
+
178
+ # Aggregate Statistics
179
+ lines.append("=" * 80)
180
+ lines.append("AGGREGATE STATISTICS")
181
+ lines.append("=" * 80)
182
+ lines.append("")
183
+
184
+ if amd_patterns and nv_patterns:
185
+ amd_main_executions = list(amd_patterns.values())[0]
186
+ nv_main_executions = list(nv_patterns.values())[0]
187
+
188
+ amd_main_count = len(amd_main_executions)
189
+ nv_main_count = len(nv_main_executions)
190
+
191
+ lines.append(f"Main Pattern (appears {min(amd_main_count, nv_main_count)}x on both platforms):")
192
+
193
+ amd_kernels_per = len(amd_main_executions[0]['amd_kernels'])
194
+ nv_kernels_per = len(nv_main_executions[0]['nv_kernels'])
195
+
196
+ lines.append(f" AMD: {amd_kernels_per} kernels × {amd_main_count} executions = {amd_kernels_per * amd_main_count:,} total kernels")
197
+ lines.append(f" NVIDIA: {nv_kernels_per} kernels × {nv_main_count} executions = {nv_kernels_per * nv_main_count:,} total kernels")
198
+
199
+ # Calculate time for main pattern
200
+ amd_time = sum(
201
+ sum(k.get('dur', 0) for k in exec['amd_kernels'])
202
+ for exec in amd_main_executions
203
+ )
204
+ nv_time = sum(
205
+ sum(k.get('dur', 0) for k in exec['nv_kernels'])
206
+ for exec in nv_main_executions
207
+ )
208
+
209
+ lines.append(f"")
210
+ lines.append(f" Total time in main pattern:")
211
+ lines.append(f" AMD: {amd_time/1000:.1f}ms ({amd_time/amd_total_ms/10:.1f}% of total)")
212
+ lines.append(f" NVIDIA: {nv_time/1000:.1f}ms ({nv_time/nv_total_ms/10:.1f}% of total)")
213
+
214
+ lines.append("")
215
+ return "\n".join(lines)
216
+
217
+
218
+ def _group_by_pattern(
219
+ graph_pairs: list[dict[str, Any]],
220
+ platform: str
221
+ ) -> dict[tuple, list[dict[str, Any]]]:
222
+ """Group graph executions by their kernel sequence pattern.
223
+
224
+ Args:
225
+ graph_pairs: List of graph pair dictionaries
226
+ platform: 'amd' or 'nv'
227
+
228
+ Returns:
229
+ Dictionary mapping pattern signatures to list of executions
230
+ """
231
+ patterns: dict[tuple, list[dict[str, Any]]] = defaultdict(list)
232
+
233
+ kernels_key = f'{platform}_kernels'
234
+
235
+ for pair in graph_pairs:
236
+ kernels = pair[kernels_key]
237
+ sorted_kernels = sorted(kernels, key=lambda x: x.get('ts', 0))
238
+
239
+ # Pattern signature: tuple of kernel names in order
240
+ signature = tuple(k.get('name', '') for k in sorted_kernels)
241
+ patterns[signature].append(pair)
242
+
243
+ # Sort by frequency (most common first)
244
+ sorted_patterns = dict(sorted(
245
+ patterns.items(),
246
+ key=lambda x: len(x[1]),
247
+ reverse=True
248
+ ))
249
+
250
+ return sorted_patterns
251
+
252
+
253
+ def format_graph_comparison_json(result: dict[str, Any]) -> str:
254
+ """Format graph matching results as JSON.
255
+
256
+ Args:
257
+ result: Results from graph_matcher.match_traces()
258
+
259
+ Returns:
260
+ JSON string
261
+ """
262
+ import json
263
+ return json.dumps(result, indent=2)
@@ -0,0 +1,225 @@
1
+ """Detailed kernel-to-kernel formatter for graph matching results.
2
+
3
+ Shows individual kernel pairs in position order (default) or grouped by operation type.
4
+ """
5
+
6
+ from collections import Counter, defaultdict
7
+ from typing import Any
8
+
9
+
10
+ def format_graph_comparison_detailed(
11
+ result: dict[str, Any],
12
+ show_all: bool = False,
13
+ group_by_op: bool = False,
14
+ max_graphs: int = 3,
15
+ ) -> str:
16
+ """Format graph matching results with kernel-to-kernel details.
17
+
18
+ Args:
19
+ result: Results from graph_matcher.match_traces()
20
+ show_all: Show all kernel pairs without truncation
21
+ group_by_op: Group kernels by operation type instead of position order
22
+ max_graphs: Maximum number of graph pairs to show in detail (default: 3)
23
+
24
+ Returns:
25
+ Formatted text report with kernel-to-kernel matching
26
+ """
27
+ lines = []
28
+ summary = result['summary']
29
+ graph_pairs = result['graph_pairs']
30
+
31
+ # Header
32
+ lines.append("=" * 80)
33
+ lines.append("TRACE COMPARISON - AMD vs NVIDIA")
34
+ lines.append("=" * 80)
35
+ lines.append("")
36
+
37
+ # Section 1: Overview
38
+ lines.append("┏" + "━" * 78 + "┓")
39
+ lines.append("┃ SECTION 1: OVERVIEW" + " " * 58 + "┃")
40
+ lines.append("┣" + "━" * 78 + "┫")
41
+
42
+ total_graphs = summary['num_graph_pairs']
43
+ amd_total_kernels = sum(len(pair['amd_kernels']) for pair in graph_pairs)
44
+ nv_total_kernels = sum(len(pair['nv_kernels']) for pair in graph_pairs)
45
+
46
+ lines.append(f"┃ Transformer layer graphs: AMD: {total_graphs} NVIDIA: {total_graphs}" + " " * 27 + "┃")
47
+ lines.append(f"┃ Graph pairs to compare: {total_graphs}" + " " * 50 + "┃")
48
+ lines.append(f"┃ Total kernels in graphs: AMD: {amd_total_kernels} NVIDIA: {nv_total_kernels}" + " " * 23 + "┃")
49
+ lines.append("┗" + "━" * 78 + "┛")
50
+ lines.append("")
51
+
52
+ # Section 2: Non-graph kernels (placeholder - always 0 for transformer layers)
53
+ lines.append("┏" + "━" * 78 + "┓")
54
+ lines.append("┃ SECTION 2: NON-GRAPH KERNELS" + " " * 48 + "┃")
55
+ lines.append("┣" + "━" * 78 + "┫")
56
+ lines.append("┃ ✓ All kernels are in CUDA graphs" + " " * 44 + "┃")
57
+ lines.append("┗" + "━" * 78 + "┛")
58
+ lines.append("")
59
+
60
+ # Section 3: Unique patterns count
61
+ amd_patterns = _group_by_pattern(graph_pairs, 'amd')
62
+ nv_patterns = _group_by_pattern(graph_pairs, 'nv')
63
+
64
+ lines.append("┏" + "━" * 78 + "┓")
65
+ lines.append("┃ SECTION 3: CUDA GRAPH PATTERNS (Transformer Layers)" + " " * 25 + "┃")
66
+ lines.append("┣" + "━" * 78 + "┫")
67
+ lines.append(f"┃ Unique patterns: AMD: {len(amd_patterns)}, NVIDIA: {len(nv_patterns)}" + " " * 42 + "┃")
68
+ lines.append(f"┃ Total executions: AMD: {total_graphs}, NVIDIA: {total_graphs}" + " " * 32 + "┃")
69
+ lines.append("┗" + "━" * 78 + "┛")
70
+ lines.append("")
71
+
72
+ # Show representative patterns
73
+ lines.append("=" * 80)
74
+ lines.append(f"UNIQUE GRAPH PATTERNS (showing up to {max_graphs})")
75
+ lines.append("=" * 80)
76
+ lines.append("")
77
+
78
+ num_patterns_to_show = min(max_graphs, len(amd_patterns)) if not show_all else len(amd_patterns)
79
+
80
+ for pattern_idx, (amd_pattern, amd_executions) in enumerate(list(amd_patterns.items())[:num_patterns_to_show], 1):
81
+ lines.append(f"┌─ Pattern {pattern_idx} " + "─" * (73 - len(f"Pattern {pattern_idx}")) + "┐")
82
+
83
+ # Find corresponding NVIDIA pattern
84
+ nv_pattern_idx = min(pattern_idx - 1, len(nv_patterns) - 1)
85
+ nv_executions = list(nv_patterns.values())[nv_pattern_idx]
86
+
87
+ amd_kernels_per = len(amd_executions[0]['amd_kernels'])
88
+ nv_kernels_per = len(nv_executions[0]['nv_kernels'])
89
+
90
+ lines.append(f"│ AMD: {len(amd_executions)} executions × {amd_kernels_per} kernels each" + " " * 35 + "│")
91
+ lines.append(f"│ NVIDIA: {len(nv_executions)} executions × {nv_kernels_per} kernels each" + " " * 35 + "│")
92
+ lines.append("└" + "─" * 78 + "┘")
93
+ lines.append("")
94
+
95
+ # Show match quality for this pattern
96
+ matches = amd_executions[0]['matches']
97
+ matched = sum(1 for m in matches if m['status'] == 'MATCH')
98
+ amd_only = sum(1 for m in matches if m['status'] == 'AMD_ONLY')
99
+ nv_only = sum(1 for m in matches if m['status'] == 'NV_ONLY')
100
+ mismatch = sum(1 for m in matches if m['status'] == 'MISMATCH')
101
+
102
+ match_rate = (matched / len(matches) * 100) if matches else 0
103
+
104
+ lines.append(f"Match Quality: {match_rate:.1f}%")
105
+ lines.append(f" ✓ Matched: {matched}")
106
+ lines.append(f" ⚠ AMD only: {amd_only} (fusion differences)")
107
+ lines.append(f" ⚠ NVIDIA only: {nv_only} (fusion differences)")
108
+ if mismatch > 0:
109
+ lines.append(f" ✗ Mismatched: {mismatch}")
110
+ lines.append("")
111
+
112
+ # Show kernel details
113
+ if group_by_op:
114
+ lines.extend(_format_kernels_grouped_by_op(matches, show_all))
115
+ else:
116
+ lines.extend(_format_kernels_in_order(matches, show_all))
117
+
118
+ lines.append("")
119
+
120
+ return "\n".join(lines)
121
+
122
+
123
+ def _format_kernels_in_order(matches: list[dict[str, Any]], show_all: bool) -> list[str]:
124
+ """Format kernels in position order."""
125
+ lines = []
126
+ lines.append("Kernel-to-Kernel Comparison (representative execution):")
127
+ lines.append("")
128
+ lines.append(f"{'Pos':<4} {'AMD Kernel':<45} {'NVIDIA Kernel':<45} {'Status':<8}")
129
+ lines.append("-" * 110)
130
+
131
+ max_pairs = len(matches) if show_all else min(20, len(matches))
132
+
133
+ for idx, match in enumerate(matches[:max_pairs], 1):
134
+ status_icon = {
135
+ 'MATCH': '✓',
136
+ 'AMD_ONLY': '⚠ AMD',
137
+ 'NV_ONLY': '⚠ NV',
138
+ 'MISMATCH': '✗',
139
+ }.get(match['status'], '?')
140
+
141
+ # Add operation type label
142
+ if idx == 1 or (idx > 1 and match['amd_type'] != matches[idx-2]['amd_type']):
143
+ op_type = match['amd_type'] if match['amd_type'] != '-' else match['nv_type']
144
+ lines.append("")
145
+ lines.append(f"[{op_type}]")
146
+
147
+ amd_name = match['amd_name'][:44] if match['amd_name'] != '-' else '-'
148
+ nv_name = match['nv_name'][:44] if match['nv_name'] != '-' else '-'
149
+
150
+ lines.append(f"{idx:<4} {amd_name:<45} {nv_name:<45} {status_icon:<8}")
151
+
152
+ if not show_all and len(matches) > 20:
153
+ lines.append(f" ... ({len(matches) - 20} more kernel pairs)")
154
+
155
+ return lines
156
+
157
+
158
+ def _format_kernels_grouped_by_op(matches: list[dict[str, Any]], show_all: bool) -> list[str]:
159
+ """Format kernels grouped by operation type."""
160
+ lines = []
161
+ lines.append("Kernel-to-Kernel Comparison (representative execution):")
162
+ lines.append("")
163
+
164
+ # Group matches by operation type
165
+ by_op: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
166
+ for idx, match in enumerate(matches, 1):
167
+ op_type = match['amd_type'] if match['amd_type'] != '-' else match['nv_type']
168
+ by_op[op_type].append((idx, match))
169
+
170
+ # Sort operations by first appearance
171
+ sorted_ops = sorted(by_op.items(), key=lambda x: x[1][0][0])
172
+
173
+ for op_type, op_matches in sorted_ops:
174
+ lines.append(f"── {op_type} ({len(op_matches)} kernel pairs) " + "─" * (80 - len(f"── {op_type} ({len(op_matches)} kernel pairs) ")))
175
+ lines.append(f"{'Pos':<4} {'AMD Kernel':<45} {'NVIDIA Kernel':<45} {'Status':<8}")
176
+ lines.append("-" * 110)
177
+
178
+ max_to_show = len(op_matches) if show_all else min(3, len(op_matches))
179
+
180
+ for idx, match in op_matches[:max_to_show]:
181
+ status_icon = {
182
+ 'MATCH': '✓',
183
+ 'AMD_ONLY': '⚠ AMD',
184
+ 'NV_ONLY': '⚠ NV',
185
+ 'MISMATCH': '✗',
186
+ }.get(match['status'], '?')
187
+
188
+ amd_name = match['amd_name'][:44] if match['amd_name'] != '-' else '-'
189
+ nv_name = match['nv_name'][:44] if match['nv_name'] != '-' else '-'
190
+
191
+ lines.append(f"{idx:<4} {amd_name:<45} {nv_name:<45} {status_icon:<8}")
192
+
193
+ if not show_all and len(op_matches) > 3:
194
+ lines.append(f" ... ({len(op_matches) - 3} more {op_type} pairs)")
195
+
196
+ lines.append("")
197
+
198
+ return lines
199
+
200
+
201
+ def _group_by_pattern(
202
+ graph_pairs: list[dict[str, Any]],
203
+ platform: str
204
+ ) -> dict[tuple, list[dict[str, Any]]]:
205
+ """Group graph executions by their kernel sequence pattern."""
206
+ patterns: dict[tuple, list[dict[str, Any]]] = defaultdict(list)
207
+
208
+ kernels_key = f'{platform}_kernels'
209
+
210
+ for pair in graph_pairs:
211
+ kernels = pair[kernels_key]
212
+ sorted_kernels = sorted(kernels, key=lambda x: x.get('ts', 0))
213
+
214
+ # Pattern signature: tuple of kernel names in order
215
+ signature = tuple(k.get('name', '') for k in sorted_kernels)
216
+ patterns[signature].append(pair)
217
+
218
+ # Sort by frequency (most common first)
219
+ sorted_patterns = dict(sorted(
220
+ patterns.items(),
221
+ key=lambda x: len(x[1]),
222
+ reverse=True
223
+ ))
224
+
225
+ return sorted_patterns