rosetta-sql 1.0.0__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.
- benchmark/generate_csv_data.py +83 -0
- benchmark/import_data.py +168 -0
- rosetta/__init__.py +3 -0
- rosetta/__main__.py +8 -0
- rosetta/benchmark.py +1678 -0
- rosetta/buglist.py +108 -0
- rosetta/cli/__init__.py +11 -0
- rosetta/cli/config_cmd.py +243 -0
- rosetta/cli/exec.py +219 -0
- rosetta/cli/interactive_cmd.py +124 -0
- rosetta/cli/list_cmd.py +215 -0
- rosetta/cli/main.py +617 -0
- rosetta/cli/output.py +545 -0
- rosetta/cli/result.py +61 -0
- rosetta/cli/result_cmd.py +247 -0
- rosetta/cli/run.py +625 -0
- rosetta/cli/status.py +161 -0
- rosetta/comparator.py +205 -0
- rosetta/config.py +139 -0
- rosetta/executor.py +403 -0
- rosetta/flamegraph.py +630 -0
- rosetta/interactive.py +1790 -0
- rosetta/models.py +197 -0
- rosetta/parser.py +308 -0
- rosetta/reporter/__init__.py +1 -0
- rosetta/reporter/bench_html.py +1457 -0
- rosetta/reporter/bench_text.py +162 -0
- rosetta/reporter/history.py +1686 -0
- rosetta/reporter/html.py +644 -0
- rosetta/reporter/text.py +110 -0
- rosetta/runner.py +3089 -0
- rosetta/ui.py +736 -0
- rosetta/whitelist.py +161 -0
- rosetta_sql-1.0.0.dist-info/LICENSE +21 -0
- rosetta_sql-1.0.0.dist-info/METADATA +379 -0
- rosetta_sql-1.0.0.dist-info/RECORD +42 -0
- rosetta_sql-1.0.0.dist-info/WHEEL +5 -0
- rosetta_sql-1.0.0.dist-info/entry_points.txt +2 -0
- rosetta_sql-1.0.0.dist-info/top_level.txt +4 -0
- skills/rosetta/scripts/install_rosetta.py +469 -0
- skills/rosetta/scripts/rosetta_wrapper.py +377 -0
- tests/test_cli.py +749 -0
rosetta/flamegraph.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""Flame graph capture and SVG generation for Rosetta benchmark profiling.
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
1. ``PerfProfiler`` – captures CPU stack traces via ``perf record`` / ``perf script``
|
|
5
|
+
for a running mysqld process while a SQL query is being executed.
|
|
6
|
+
2. ``collapse_stacks()`` – folds raw ``perf script`` output into the
|
|
7
|
+
"folded stacks" format (``func1;func2;func3 count``).
|
|
8
|
+
3. ``flamegraph_svg()`` – renders folded stacks into an interactive SVG
|
|
9
|
+
flame graph (pure Python, no external dependencies).
|
|
10
|
+
|
|
11
|
+
Requirements:
|
|
12
|
+
- ``perf`` installed on the server (``linux-tools-*`` or ``perf-tools``).
|
|
13
|
+
- The mysqld process accessible locally (same machine).
|
|
14
|
+
- Sufficient privileges to run ``perf record -g -p <pid>``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Dict, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("rosetta")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Data structures
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FlameGraphData:
|
|
38
|
+
"""Holds the profiling result for a single capture session."""
|
|
39
|
+
query_name: str = ""
|
|
40
|
+
svg_content: str = ""
|
|
41
|
+
folded_stacks: str = ""
|
|
42
|
+
sample_count: int = 0
|
|
43
|
+
duration_ms: float = 0.0
|
|
44
|
+
error: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# perf availability check
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def check_perf_available() -> Tuple[bool, str]:
|
|
52
|
+
"""Check if ``perf`` is installed and usable.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
(ok, message) tuple.
|
|
56
|
+
"""
|
|
57
|
+
perf_path = shutil.which("perf")
|
|
58
|
+
if not perf_path:
|
|
59
|
+
return False, "perf not found in PATH. Install linux-tools or perf-tools."
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
proc = subprocess.run(
|
|
63
|
+
["perf", "--version"],
|
|
64
|
+
capture_output=True, text=True, timeout=5,
|
|
65
|
+
)
|
|
66
|
+
if proc.returncode == 0:
|
|
67
|
+
return True, proc.stdout.strip()
|
|
68
|
+
return False, f"perf returned exit code {proc.returncode}: {proc.stderr.strip()}"
|
|
69
|
+
except Exception as e:
|
|
70
|
+
return False, f"perf check failed: {e}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_mysqld_pid(port: int = 3306) -> Optional[int]:
|
|
74
|
+
"""Find the PID of the mysqld process listening on *port*.
|
|
75
|
+
|
|
76
|
+
Strategy:
|
|
77
|
+
1. Try ``ss -tlnp`` to find the process bound to *port*.
|
|
78
|
+
2. Fall back to ``pidof mysqld``.
|
|
79
|
+
"""
|
|
80
|
+
# Strategy 1: ss -tlnp
|
|
81
|
+
try:
|
|
82
|
+
proc = subprocess.run(
|
|
83
|
+
["ss", "-tlnp"],
|
|
84
|
+
capture_output=True, text=True, timeout=5,
|
|
85
|
+
)
|
|
86
|
+
if proc.returncode == 0:
|
|
87
|
+
for line in proc.stdout.splitlines():
|
|
88
|
+
# Match lines containing the target port
|
|
89
|
+
if f":{port}" in line:
|
|
90
|
+
# Extract pid from "pid=12345,"
|
|
91
|
+
m = re.search(r'pid=(\d+)', line)
|
|
92
|
+
if m:
|
|
93
|
+
return int(m.group(1))
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# Strategy 2: pidof mysqld
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.run(
|
|
100
|
+
["pidof", "mysqld"],
|
|
101
|
+
capture_output=True, text=True, timeout=5,
|
|
102
|
+
)
|
|
103
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
104
|
+
# pidof may return multiple PIDs; take the first
|
|
105
|
+
pids = proc.stdout.strip().split()
|
|
106
|
+
return int(pids[0])
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# PerfProfiler – capture CPU stacks around SQL execution
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
class PerfProfiler:
|
|
118
|
+
"""Profile a mysqld process using ``perf record`` during query execution.
|
|
119
|
+
|
|
120
|
+
Usage::
|
|
121
|
+
|
|
122
|
+
profiler = PerfProfiler(mysqld_pid=12345, perf_freq=99)
|
|
123
|
+
|
|
124
|
+
profiler.start() # starts perf record in background
|
|
125
|
+
cursor.execute(sql) # run the query
|
|
126
|
+
fg_data = profiler.stop() # stops perf, generates SVG
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
mysqld_pid: int,
|
|
132
|
+
perf_freq: int = 99,
|
|
133
|
+
tmp_dir: Optional[str] = None,
|
|
134
|
+
):
|
|
135
|
+
self.mysqld_pid = mysqld_pid
|
|
136
|
+
self.perf_freq = perf_freq
|
|
137
|
+
self._tmp_dir = tmp_dir or tempfile.mkdtemp(prefix="rosetta_perf_")
|
|
138
|
+
self._perf_data_path = os.path.join(self._tmp_dir, "perf.data")
|
|
139
|
+
self._perf_proc: Optional[subprocess.Popen] = None
|
|
140
|
+
self._start_time: float = 0.0
|
|
141
|
+
|
|
142
|
+
def start(self):
|
|
143
|
+
"""Start ``perf record`` in the background."""
|
|
144
|
+
cmd = [
|
|
145
|
+
"perf", "record",
|
|
146
|
+
"-g", # call-graph (DWARF or fp)
|
|
147
|
+
"-F", str(self.perf_freq), # sampling frequency
|
|
148
|
+
"-p", str(self.mysqld_pid), # attach to mysqld
|
|
149
|
+
"-o", self._perf_data_path, # output file
|
|
150
|
+
"--",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
self._perf_proc = subprocess.Popen(
|
|
155
|
+
cmd,
|
|
156
|
+
stdout=subprocess.DEVNULL,
|
|
157
|
+
stderr=subprocess.PIPE,
|
|
158
|
+
)
|
|
159
|
+
self._start_time = time.monotonic()
|
|
160
|
+
except Exception as e:
|
|
161
|
+
log.warning("Failed to start perf record: %s", e)
|
|
162
|
+
self._perf_proc = None
|
|
163
|
+
|
|
164
|
+
def stop(self, query_name: str = "") -> FlameGraphData:
|
|
165
|
+
"""Stop ``perf record``, process output, and return flame graph data."""
|
|
166
|
+
result = FlameGraphData(query_name=query_name)
|
|
167
|
+
|
|
168
|
+
if self._perf_proc is None:
|
|
169
|
+
result.error = "perf record was not started"
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
duration = time.monotonic() - self._start_time
|
|
173
|
+
result.duration_ms = duration * 1000.0
|
|
174
|
+
|
|
175
|
+
# Send SIGINT to perf to stop recording gracefully
|
|
176
|
+
try:
|
|
177
|
+
self._perf_proc.send_signal(2) # SIGINT
|
|
178
|
+
self._perf_proc.wait(timeout=10)
|
|
179
|
+
except subprocess.TimeoutExpired:
|
|
180
|
+
log.warning("perf record did not stop after SIGINT, killing")
|
|
181
|
+
self._perf_proc.kill()
|
|
182
|
+
try:
|
|
183
|
+
self._perf_proc.wait(timeout=5)
|
|
184
|
+
except subprocess.TimeoutExpired:
|
|
185
|
+
log.warning("perf record could not be killed")
|
|
186
|
+
result.error = "perf record could not be stopped"
|
|
187
|
+
return result
|
|
188
|
+
except Exception as e:
|
|
189
|
+
log.warning("Error stopping perf: %s", e)
|
|
190
|
+
result.error = f"Failed to stop perf: {e}"
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
# Check if perf.data was created
|
|
194
|
+
if not os.path.isfile(self._perf_data_path):
|
|
195
|
+
stderr_out = ""
|
|
196
|
+
try:
|
|
197
|
+
stderr_out = self._perf_proc.stderr.read().decode(
|
|
198
|
+
"utf-8", errors="replace")
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
result.error = f"perf.data not created. stderr: {stderr_out}"
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
# Check perf.data size — skip processing if too large (>200MB)
|
|
205
|
+
# to avoid perf script hanging for minutes
|
|
206
|
+
try:
|
|
207
|
+
data_size = os.path.getsize(self._perf_data_path)
|
|
208
|
+
if data_size > 200 * 1024 * 1024:
|
|
209
|
+
result.error = (
|
|
210
|
+
f"perf.data too large ({data_size // (1024*1024)}MB), "
|
|
211
|
+
f"skipping flamegraph generation")
|
|
212
|
+
log.warning("[%s] %s", query_name, result.error)
|
|
213
|
+
return result
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
# Run perf script to get human-readable stack traces
|
|
218
|
+
# Use a generous but bounded timeout to avoid hanging indefinitely
|
|
219
|
+
perf_script_timeout = max(60, min(300, int(duration * 3)))
|
|
220
|
+
try:
|
|
221
|
+
script_proc = subprocess.run(
|
|
222
|
+
["perf", "script", "-i", self._perf_data_path],
|
|
223
|
+
capture_output=True, text=True, timeout=perf_script_timeout,
|
|
224
|
+
)
|
|
225
|
+
if script_proc.returncode != 0:
|
|
226
|
+
result.error = (
|
|
227
|
+
f"perf script failed (rc={script_proc.returncode}): "
|
|
228
|
+
f"{script_proc.stderr[:500]}")
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
raw_script = script_proc.stdout
|
|
232
|
+
except subprocess.TimeoutExpired:
|
|
233
|
+
result.error = (
|
|
234
|
+
f"perf script timed out after {perf_script_timeout}s "
|
|
235
|
+
f"(perf.data may be too large)")
|
|
236
|
+
log.warning("[%s] %s", query_name, result.error)
|
|
237
|
+
return result
|
|
238
|
+
except Exception as e:
|
|
239
|
+
result.error = f"perf script error: {e}"
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
# Collapse stacks
|
|
243
|
+
folded = collapse_stacks(raw_script)
|
|
244
|
+
result.folded_stacks = folded
|
|
245
|
+
|
|
246
|
+
# Count total samples
|
|
247
|
+
total_samples = 0
|
|
248
|
+
for line in folded.splitlines():
|
|
249
|
+
parts = line.rsplit(" ", 1)
|
|
250
|
+
if len(parts) == 2:
|
|
251
|
+
try:
|
|
252
|
+
total_samples += int(parts[1])
|
|
253
|
+
except ValueError:
|
|
254
|
+
pass
|
|
255
|
+
result.sample_count = total_samples
|
|
256
|
+
|
|
257
|
+
# Generate SVG
|
|
258
|
+
if total_samples > 0:
|
|
259
|
+
result.svg_content = flamegraph_svg(
|
|
260
|
+
folded, title=query_name or "Flame Graph")
|
|
261
|
+
else:
|
|
262
|
+
result.error = "No samples captured (query may have been too fast)"
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
def cleanup(self):
|
|
267
|
+
"""Remove temporary perf data files."""
|
|
268
|
+
try:
|
|
269
|
+
if os.path.isdir(self._tmp_dir):
|
|
270
|
+
shutil.rmtree(self._tmp_dir, ignore_errors=True)
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Stack collapsing (equivalent to stackcollapse-perf.pl)
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def collapse_stacks(perf_script_output: str) -> str:
|
|
280
|
+
"""Collapse ``perf script`` output into folded stack format.
|
|
281
|
+
|
|
282
|
+
Each output line: ``func1;func2;func3 count``
|
|
283
|
+
|
|
284
|
+
This is a pure-Python equivalent of Brendan Gregg's
|
|
285
|
+
``stackcollapse-perf.pl`` script.
|
|
286
|
+
"""
|
|
287
|
+
stacks: Dict[str, int] = defaultdict(int)
|
|
288
|
+
current_stack: List[str] = []
|
|
289
|
+
in_stack = False
|
|
290
|
+
|
|
291
|
+
for line in perf_script_output.splitlines():
|
|
292
|
+
line = line.rstrip()
|
|
293
|
+
|
|
294
|
+
# Empty line = end of a stack trace
|
|
295
|
+
if not line:
|
|
296
|
+
if current_stack:
|
|
297
|
+
# Reverse to get caller→callee order (bottom-up)
|
|
298
|
+
key = ";".join(reversed(current_stack))
|
|
299
|
+
stacks[key] += 1
|
|
300
|
+
current_stack = []
|
|
301
|
+
in_stack = False
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
# Stack frame lines start with whitespace and a hex address
|
|
305
|
+
# Example: " ffffffff81234567 do_something+0x12 ([kernel.kallsyms])"
|
|
306
|
+
if line.startswith(("\t", " ")) and in_stack:
|
|
307
|
+
# Extract function name
|
|
308
|
+
stripped = line.strip()
|
|
309
|
+
# Format: <addr> <func+offset> (<module>)
|
|
310
|
+
parts = stripped.split(None, 2)
|
|
311
|
+
if len(parts) >= 2:
|
|
312
|
+
func = parts[1]
|
|
313
|
+
# Remove offset suffix: "func+0x1a" → "func"
|
|
314
|
+
plus_idx = func.find("+0x")
|
|
315
|
+
if plus_idx > 0:
|
|
316
|
+
func = func[:plus_idx]
|
|
317
|
+
current_stack.append(func)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# Header line (process info): "mysqld 12345 [001] 12345.678901: ..."
|
|
321
|
+
# Indicates start of a new sample
|
|
322
|
+
if not line.startswith(("\t", " ")):
|
|
323
|
+
if current_stack:
|
|
324
|
+
key = ";".join(reversed(current_stack))
|
|
325
|
+
stacks[key] += 1
|
|
326
|
+
current_stack = []
|
|
327
|
+
in_stack = True
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
# Handle last stack if file doesn't end with blank line
|
|
331
|
+
if current_stack:
|
|
332
|
+
key = ";".join(reversed(current_stack))
|
|
333
|
+
stacks[key] += 1
|
|
334
|
+
|
|
335
|
+
# Sort by count (descending) for consistent output
|
|
336
|
+
lines = []
|
|
337
|
+
for stack, count in sorted(stacks.items(), key=lambda x: -x[1]):
|
|
338
|
+
lines.append(f"{stack} {count}")
|
|
339
|
+
|
|
340
|
+
return "\n".join(lines)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
# SVG Flame Graph renderer (pure Python)
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
# Color palette is now generated algorithmically in _flame_color().
|
|
348
|
+
# No static list needed.
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def flamegraph_svg(
|
|
352
|
+
folded_stacks: str,
|
|
353
|
+
title: str = "Flame Graph",
|
|
354
|
+
width: int = 1200,
|
|
355
|
+
frame_height: int = 16,
|
|
356
|
+
font_size: float = 12.0,
|
|
357
|
+
min_width_px: float = 0.1,
|
|
358
|
+
) -> str:
|
|
359
|
+
"""Generate an interactive SVG flame graph from folded stack data.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
folded_stacks: Folded stacks (``func1;func2 count`` per line).
|
|
363
|
+
title: Title shown at the top of the SVG.
|
|
364
|
+
width: Total SVG width in pixels.
|
|
365
|
+
frame_height: Height of each stack frame in pixels.
|
|
366
|
+
font_size: Font size for labels.
|
|
367
|
+
min_width_px: Minimum frame width to render (in pixels).
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
SVG content as a string.
|
|
371
|
+
"""
|
|
372
|
+
# Parse folded stacks
|
|
373
|
+
stack_counts: Dict[str, int] = {}
|
|
374
|
+
total_samples = 0
|
|
375
|
+
for line in folded_stacks.strip().splitlines():
|
|
376
|
+
line = line.strip()
|
|
377
|
+
if not line:
|
|
378
|
+
continue
|
|
379
|
+
parts = line.rsplit(" ", 1)
|
|
380
|
+
if len(parts) != 2:
|
|
381
|
+
continue
|
|
382
|
+
stack, count_str = parts
|
|
383
|
+
try:
|
|
384
|
+
count = int(count_str)
|
|
385
|
+
except ValueError:
|
|
386
|
+
continue
|
|
387
|
+
stack_counts[stack] = stack_counts.get(stack, 0) + count
|
|
388
|
+
total_samples += count
|
|
389
|
+
|
|
390
|
+
if total_samples == 0:
|
|
391
|
+
return ""
|
|
392
|
+
|
|
393
|
+
# Build a tree from the folded stacks
|
|
394
|
+
# Each node: {name, value, children: {name: node}}
|
|
395
|
+
root = {"name": "root", "value": 0, "children": {}}
|
|
396
|
+
|
|
397
|
+
for stack, count in stack_counts.items():
|
|
398
|
+
funcs = stack.split(";")
|
|
399
|
+
node = root
|
|
400
|
+
for func in funcs:
|
|
401
|
+
if func not in node["children"]:
|
|
402
|
+
node["children"][func] = {
|
|
403
|
+
"name": func, "value": 0, "children": {},
|
|
404
|
+
}
|
|
405
|
+
node = node["children"][func]
|
|
406
|
+
node["value"] += count
|
|
407
|
+
|
|
408
|
+
# Propagate values upward (each node's total = own + children)
|
|
409
|
+
def _total_value(node):
|
|
410
|
+
total = node["value"]
|
|
411
|
+
for child in node["children"].values():
|
|
412
|
+
total += _total_value(child)
|
|
413
|
+
return total
|
|
414
|
+
|
|
415
|
+
root_total = _total_value(root)
|
|
416
|
+
|
|
417
|
+
# Compute the maximum depth for SVG height
|
|
418
|
+
def _max_depth(node, depth=0):
|
|
419
|
+
if not node["children"]:
|
|
420
|
+
return depth
|
|
421
|
+
return max(_max_depth(c, depth + 1) for c in node["children"].values())
|
|
422
|
+
|
|
423
|
+
max_depth = _max_depth(root)
|
|
424
|
+
y_pad_top = 50 # space for title and info
|
|
425
|
+
y_pad_bottom = 40 # space for bottom text
|
|
426
|
+
svg_height = y_pad_top + (max_depth + 2) * frame_height + y_pad_bottom
|
|
427
|
+
x_pad = 10
|
|
428
|
+
chart_width = width - 2 * x_pad
|
|
429
|
+
|
|
430
|
+
# Flatten tree into rectangles
|
|
431
|
+
# Each rect: (x, y, w, h, name, self_count, total_count)
|
|
432
|
+
rects: List[Tuple[float, float, float, float, str, int, int]] = []
|
|
433
|
+
|
|
434
|
+
def _layout(node, x_start, depth):
|
|
435
|
+
total = _total_value(node)
|
|
436
|
+
w = (total / root_total) * chart_width
|
|
437
|
+
|
|
438
|
+
if w < min_width_px:
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
# y position: flame graph is bottom-up, so deeper = lower
|
|
442
|
+
# We'll use an inverted layout: root at bottom
|
|
443
|
+
y = svg_height - y_pad_bottom - (depth + 1) * frame_height
|
|
444
|
+
|
|
445
|
+
rects.append((
|
|
446
|
+
x_start + x_pad, y, w, frame_height - 1,
|
|
447
|
+
node["name"], node["value"], total,
|
|
448
|
+
))
|
|
449
|
+
|
|
450
|
+
# Layout children left-to-right
|
|
451
|
+
child_x = x_start
|
|
452
|
+
for child in sorted(node["children"].values(),
|
|
453
|
+
key=lambda c: _total_value(c), reverse=True):
|
|
454
|
+
child_total = _total_value(child)
|
|
455
|
+
child_w = (child_total / root_total) * chart_width
|
|
456
|
+
if child_w >= min_width_px:
|
|
457
|
+
_layout(child, child_x, depth + 1)
|
|
458
|
+
child_x += child_w
|
|
459
|
+
|
|
460
|
+
# Layout from root's children (skip the "root" pseudo-node itself)
|
|
461
|
+
child_x = 0.0
|
|
462
|
+
for child in sorted(root["children"].values(),
|
|
463
|
+
key=lambda c: _total_value(c), reverse=True):
|
|
464
|
+
_layout(child, child_x, 0)
|
|
465
|
+
child_total = _total_value(child)
|
|
466
|
+
child_x += (child_total / root_total) * chart_width
|
|
467
|
+
|
|
468
|
+
# Generate SVG
|
|
469
|
+
svg_parts = []
|
|
470
|
+
svg_parts.append(
|
|
471
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
472
|
+
f'viewBox="0 0 {width} {svg_height}" '
|
|
473
|
+
f'width="{width}" height="{svg_height}" '
|
|
474
|
+
f'style="background:#0d1117" '
|
|
475
|
+
f'data-chart-width="{chart_width}" data-x-pad="{x_pad}" '
|
|
476
|
+
f'data-total-samples="{total_samples}">'
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# Embedded styles and interactivity
|
|
480
|
+
svg_parts.append("""
|
|
481
|
+
<defs>
|
|
482
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
483
|
+
<stop offset="0%" style="stop-color:#161b22"/>
|
|
484
|
+
<stop offset="100%" style="stop-color:#0d1117"/>
|
|
485
|
+
</linearGradient>
|
|
486
|
+
<filter id="outline" x="-2%" y="-2%" width="104%" height="104%">
|
|
487
|
+
<feMorphology in="SourceAlpha" result="dilated" operator="dilate" radius="1"/>
|
|
488
|
+
<feFlood flood-color="#000000" flood-opacity="0.9" result="black"/>
|
|
489
|
+
<feComposite in="black" in2="dilated" operator="in" result="shadow"/>
|
|
490
|
+
<feMerge>
|
|
491
|
+
<feMergeNode in="shadow"/>
|
|
492
|
+
<feMergeNode in="SourceGraphic"/>
|
|
493
|
+
</feMerge>
|
|
494
|
+
</filter>
|
|
495
|
+
</defs>
|
|
496
|
+
<style>
|
|
497
|
+
.fg-frame { stroke: #0d1117; stroke-width: 0.5; cursor: pointer; }
|
|
498
|
+
.fg-frame:hover { stroke: #f0e68c; stroke-width: 1.5; }
|
|
499
|
+
.fg-label { font-family: 'SF Mono', Consolas, monospace; fill: #ffffff;
|
|
500
|
+
filter: url(#outline); pointer-events: none; }
|
|
501
|
+
.fg-title { font-family: -apple-system, sans-serif; fill: #e6edf3;
|
|
502
|
+
font-weight: 700; }
|
|
503
|
+
.fg-info { font-family: 'SF Mono', Consolas, monospace; fill: #8b949e; }
|
|
504
|
+
</style>
|
|
505
|
+
""")
|
|
506
|
+
|
|
507
|
+
# Background
|
|
508
|
+
svg_parts.append(
|
|
509
|
+
f'<rect x="0" y="0" width="{width}" height="{svg_height}" '
|
|
510
|
+
f'fill="url(#bg)" />'
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# Title
|
|
514
|
+
svg_parts.append(
|
|
515
|
+
f'<text x="{width / 2}" y="24" text-anchor="middle" '
|
|
516
|
+
f'class="fg-title" font-size="16">{_svg_escape(title)}</text>'
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Info line
|
|
520
|
+
svg_parts.append(
|
|
521
|
+
f'<text x="{width / 2}" y="40" text-anchor="middle" '
|
|
522
|
+
f'class="fg-info" font-size="11">'
|
|
523
|
+
f'samples: {total_samples} | '
|
|
524
|
+
f'Hover for details, click to zoom</text>'
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Tooltip / details container
|
|
528
|
+
svg_parts.append(
|
|
529
|
+
f'<text class="fg-details" x="{x_pad}" y="{svg_height - 12}" '
|
|
530
|
+
f'font-size="11"> </text>'
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Render frames – store original geometry as data attributes so the
|
|
534
|
+
# HTML-side JS can implement zoom (scale / translate) without needing
|
|
535
|
+
# an embedded <script> (which does not execute when SVG is injected
|
|
536
|
+
# via innerHTML).
|
|
537
|
+
#
|
|
538
|
+
# Each frame's text is clipped via a <clipPath> that matches the
|
|
539
|
+
# rectangle bounds, so labels never overflow even if truncation
|
|
540
|
+
# estimates are slightly off. A trailing "…" is appended when the
|
|
541
|
+
# label is truncated to signal that the full name is available on
|
|
542
|
+
# hover / in the details bar.
|
|
543
|
+
char_w = font_size * 0.65 # approximate width of a single monospace char
|
|
544
|
+
min_text_w = 20 # show text in frames wider than this (px)
|
|
545
|
+
|
|
546
|
+
for i, (x, y, w, h, name, self_count, total_count) in enumerate(rects):
|
|
547
|
+
color = _flame_color(name, i)
|
|
548
|
+
pct = (total_count / total_samples) * 100
|
|
549
|
+
|
|
550
|
+
# Truncate label to fit – leave 8 px padding (4 left + 4 right)
|
|
551
|
+
avail_w = w - 8
|
|
552
|
+
max_chars = int(avail_w / char_w) if avail_w > 0 else 0
|
|
553
|
+
|
|
554
|
+
if max_chars >= len(name):
|
|
555
|
+
label = name # fits completely
|
|
556
|
+
elif max_chars > 3:
|
|
557
|
+
label = name[: max_chars - 1] + "\u2026" # truncate + ellipsis
|
|
558
|
+
elif max_chars > 0:
|
|
559
|
+
label = name[: max_chars] # very narrow – no room for ellipsis
|
|
560
|
+
else:
|
|
561
|
+
label = ""
|
|
562
|
+
|
|
563
|
+
clip_id = f"clip-{i}"
|
|
564
|
+
|
|
565
|
+
svg_parts.append(
|
|
566
|
+
f'<g class="fg-frame" '
|
|
567
|
+
f'data-name="{_svg_escape(name)}" '
|
|
568
|
+
f'data-samples="{total_count}" '
|
|
569
|
+
f'data-pct="{pct:.2f}" '
|
|
570
|
+
f'data-x="{x:.1f}" data-y="{y:.1f}" '
|
|
571
|
+
f'data-w="{w:.1f}" data-h="{h}">'
|
|
572
|
+
)
|
|
573
|
+
# clipPath keeps text inside the rectangle
|
|
574
|
+
svg_parts.append(
|
|
575
|
+
f'<clipPath id="{clip_id}">'
|
|
576
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" '
|
|
577
|
+
f'width="{w:.1f}" height="{h}" />'
|
|
578
|
+
f'</clipPath>'
|
|
579
|
+
)
|
|
580
|
+
svg_parts.append(
|
|
581
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" '
|
|
582
|
+
f'width="{w:.1f}" height="{h}" '
|
|
583
|
+
f'fill="{color}" rx="2" />'
|
|
584
|
+
)
|
|
585
|
+
if label and w > min_text_w:
|
|
586
|
+
svg_parts.append(
|
|
587
|
+
f'<text x="{x + 4:.1f}" y="{y + h - 3:.1f}" '
|
|
588
|
+
f'clip-path="url(#{clip_id})" '
|
|
589
|
+
f'class="fg-label" font-size="{font_size}">'
|
|
590
|
+
f'{_svg_escape(label)}</text>'
|
|
591
|
+
)
|
|
592
|
+
svg_parts.append('</g>')
|
|
593
|
+
|
|
594
|
+
svg_parts.append('</svg>')
|
|
595
|
+
|
|
596
|
+
return "\n".join(svg_parts)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _svg_escape(text: str) -> str:
|
|
600
|
+
"""Escape text for safe SVG/XML inclusion."""
|
|
601
|
+
return (text.replace("&", "&")
|
|
602
|
+
.replace("<", "<")
|
|
603
|
+
.replace(">", ">")
|
|
604
|
+
.replace('"', """)
|
|
605
|
+
.replace("'", "'"))
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _flame_color(name: str, index: int) -> str:
|
|
609
|
+
"""Pick a warm flame color for a flame graph frame.
|
|
610
|
+
|
|
611
|
+
Uses a hash-based approach to generate classic flame graph colors
|
|
612
|
+
(red → orange → yellow gradient). The same function name always
|
|
613
|
+
gets the same color for easy cross-run comparison.
|
|
614
|
+
"""
|
|
615
|
+
# Kernel frames / unknown addresses → muted grey
|
|
616
|
+
if name.startswith(("[", "0x")):
|
|
617
|
+
return "#626262"
|
|
618
|
+
|
|
619
|
+
# Hash the name for deterministic color assignment
|
|
620
|
+
h = hash(name) & 0xFFFFFFFF
|
|
621
|
+
|
|
622
|
+
# Classic flame graph palette:
|
|
623
|
+
# Red channel: 200–240 (warm base)
|
|
624
|
+
# Green channel: 50–200 (controls red→orange→yellow shift)
|
|
625
|
+
# Blue channel: 30–55 (subtle warmth)
|
|
626
|
+
r = 200 + (h % 41) # 200–240
|
|
627
|
+
g = 50 + ((h >> 8) % 151) # 50–200
|
|
628
|
+
b = 30 + ((h >> 16) % 26) # 30–55
|
|
629
|
+
|
|
630
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|