Skip to content

Commit aedc000

Browse files
committed
Track opcode sample counts in flamegraph collector
Stores per-node opcode counts in the tree structure. Exports opcode mapping (names and deopt relationships) in JSON so the JS renderer can show instruction names and distinguish specialized variants.
1 parent 70f2ae0 commit aedc000

2 files changed

Lines changed: 117 additions & 10 deletions

File tree

Lib/profiling/sampling/_flamegraph_assets/flamegraph.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@ let currentThreadFilter = 'all';
88
// Heat colors are now defined in CSS variables (--heat-1 through --heat-8)
99
// and automatically switch with theme changes - no JS color arrays needed!
1010

11+
// Opcode mappings - loaded from embedded data (generated by Python)
12+
let OPCODE_NAMES = {};
13+
let DEOPT_MAP = {};
14+
15+
// Initialize opcode mappings from embedded data
16+
function initOpcodeMapping(data) {
17+
if (data && data.opcode_mapping) {
18+
OPCODE_NAMES = data.opcode_mapping.names || {};
19+
DEOPT_MAP = data.opcode_mapping.deopt || {};
20+
}
21+
}
22+
23+
// Get opcode info from opcode number
24+
function getOpcodeInfo(opcode) {
25+
const opname = OPCODE_NAMES[opcode] || `<${opcode}>`;
26+
const baseOpcode = DEOPT_MAP[opcode];
27+
const isSpecialized = baseOpcode !== undefined;
28+
const baseOpname = isSpecialized ? (OPCODE_NAMES[baseOpcode] || `<${baseOpcode}>`) : opname;
29+
30+
return {
31+
opname: opname,
32+
baseOpname: baseOpname,
33+
isSpecialized: isSpecialized
34+
};
35+
}
36+
1137
// ============================================================================
1238
// String Resolution
1339
// ============================================================================
@@ -249,6 +275,55 @@ function createPythonTooltip(data) {
249275
</div>`;
250276
}
251277

278+
// Create bytecode/opcode section if available
279+
let opcodeSection = "";
280+
const opcodes = d.data.opcodes;
281+
if (opcodes && typeof opcodes === 'object' && Object.keys(opcodes).length > 0) {
282+
// Sort opcodes by sample count (descending)
283+
const sortedOpcodes = Object.entries(opcodes)
284+
.sort((a, b) => b[1] - a[1])
285+
.slice(0, 8); // Limit to top 8
286+
287+
const totalOpcodeSamples = sortedOpcodes.reduce((sum, [, count]) => sum + count, 0);
288+
const maxCount = sortedOpcodes[0][1] || 1;
289+
290+
const opcodeLines = sortedOpcodes.map(([opcode, count]) => {
291+
const opcodeInfo = getOpcodeInfo(parseInt(opcode, 10));
292+
const pct = ((count / totalOpcodeSamples) * 100).toFixed(1);
293+
const barWidth = (count / maxCount) * 100;
294+
const specializedBadge = opcodeInfo.isSpecialized
295+
? '<span style="background: #2e7d32; color: white; font-size: 9px; padding: 1px 4px; border-radius: 3px; margin-left: 4px;">SPECIALIZED</span>'
296+
: '';
297+
const baseOpHint = opcodeInfo.isSpecialized
298+
? `<span style="color: #888; font-size: 11px; margin-left: 4px;">(${opcodeInfo.baseOpname})</span>`
299+
: '';
300+
301+
return `
302+
<div style="display: grid; grid-template-columns: 1fr 60px 60px; gap: 8px; align-items: center; padding: 3px 0;">
303+
<div style="font-family: monospace; font-size: 11px; color: ${opcodeInfo.isSpecialized ? '#2e7d32' : '#333'}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
304+
${opcodeInfo.opname}${baseOpHint}${specializedBadge}
305+
</div>
306+
<div style="text-align: right; font-size: 11px; color: #666;">${count.toLocaleString()}</div>
307+
<div style="background: #e9ecef; border-radius: 2px; height: 8px; overflow: hidden;">
308+
<div style="background: linear-gradient(90deg, #3776ab, #5a9bd5); height: 100%; width: ${barWidth}%;"></div>
309+
</div>
310+
</div>`;
311+
}).join('');
312+
313+
opcodeSection = `
314+
<div style="margin-top: 16px; padding-top: 12px;
315+
border-top: 1px solid #e9ecef;">
316+
<div style="color: #3776ab; font-size: 13px;
317+
margin-bottom: 8px; font-weight: 600;">
318+
Bytecode Instructions:
319+
</div>
320+
<div style="background: #f8f9fa; border: 1px solid #e9ecef;
321+
border-radius: 6px; padding: 10px;">
322+
${opcodeLines}
323+
</div>
324+
</div>`;
325+
}
326+
252327
const fileLocationHTML = isSpecialFrame ? "" : `
253328
<div class="tooltip-location">${filename}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`;
254329

@@ -275,6 +350,7 @@ function createPythonTooltip(data) {
275350
` : ''}
276351
</div>
277352
${sourceSection}
353+
${opcodeSection}
278354
<div class="tooltip-hint">
279355
${childCount > 0 ? "Click to zoom into this function" : "Leaf function - no children"}
280356
</div>
@@ -994,6 +1070,9 @@ function initFlamegraph() {
9941070
processedData = resolveStringIndices(EMBEDDED_DATA);
9951071
}
9961072

1073+
// Initialize opcode mapping from embedded data
1074+
initOpcodeMapping(EMBEDDED_DATA);
1075+
9971076
originalData = processedData;
9981077
initThreadFilter(processedData);
9991078

Lib/profiling/sampling/stack_collector.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import os
88

99
from ._css_utils import get_combined_css
10-
from .collector import Collector
10+
from .collector import Collector, extract_lineno
11+
from .opcode_utils import get_opcode_mapping
1112
from .string_table import StringTable
1213

1314

@@ -32,7 +33,11 @@ def __init__(self, *args, **kwargs):
3233
self.stack_counter = collections.Counter()
3334

3435
def process_frames(self, frames, thread_id):
35-
call_tree = tuple(reversed(frames))
36+
# Extract only (filename, lineno, funcname) - opcode not needed for collapsed stacks
37+
# frame is (filename, location, funcname, opcode)
38+
call_tree = tuple(
39+
(f[0], extract_lineno(f[1]), f[2]) for f in reversed(frames)
40+
)
3641
self.stack_counter[(call_tree, thread_id)] += 1
3742

3843
def export(self, filename):
@@ -205,6 +210,11 @@ def convert_children(children, min_samples):
205210
source_indices = [self._string_table.intern(line) for line in source]
206211
child_entry["source"] = source_indices
207212

213+
# Include opcode data if available
214+
opcodes = node.get("opcodes", {})
215+
if opcodes:
216+
child_entry["opcodes"] = dict(opcodes)
217+
208218
# Recurse
209219
child_entry["children"] = convert_children(
210220
node["children"], min_samples
@@ -251,6 +261,9 @@ def convert_children(children, min_samples):
251261
**stats
252262
}
253263

264+
# Build opcode mapping for JS
265+
opcode_mapping = get_opcode_mapping()
266+
254267
# If we only have one root child, make it the root to avoid redundant level
255268
if len(root_children) == 1:
256269
main_child = root_children[0]
@@ -265,6 +278,7 @@ def convert_children(children, min_samples):
265278
}
266279
main_child["threads"] = sorted(list(self._all_threads))
267280
main_child["strings"] = self._string_table.get_strings()
281+
main_child["opcode_mapping"] = opcode_mapping
268282
return main_child
269283

270284
return {
@@ -277,27 +291,41 @@ def convert_children(children, min_samples):
277291
"per_thread_stats": per_thread_stats_with_pct
278292
},
279293
"threads": sorted(list(self._all_threads)),
280-
"strings": self._string_table.get_strings()
294+
"strings": self._string_table.get_strings(),
295+
"opcode_mapping": opcode_mapping
281296
}
282297

283298
def process_frames(self, frames, thread_id):
284-
# Reverse to root->leaf
285-
call_tree = reversed(frames)
299+
"""Process stack frames into flamegraph tree structure.
300+
301+
Args:
302+
frames: List of (filename, location, funcname, opcode) tuples in
303+
leaf-to-root order. location is (lineno, end_lineno, col_offset, end_col_offset).
304+
opcode is None if not gathered.
305+
thread_id: Thread ID for this stack trace
306+
"""
307+
# Reverse to root->leaf order for tree building
286308
self._root["samples"] += 1
287309
self._total_samples += 1
288310
self._root["threads"].add(thread_id)
289311
self._all_threads.add(thread_id)
290312

291313
current = self._root
292-
for func in call_tree:
314+
for filename, location, funcname, opcode in reversed(frames):
315+
lineno = extract_lineno(location)
316+
func = (filename, lineno, funcname)
293317
func = self._func_intern.setdefault(func, func)
294-
children = current["children"]
295-
node = children.get(func)
318+
319+
node = current["children"].get(func)
296320
if node is None:
297-
node = {"samples": 0, "children": {}, "threads": set()}
298-
children[func] = node
321+
node = {"samples": 0, "children": {}, "threads": set(), "opcodes": collections.Counter()}
322+
current["children"][func] = node
299323
node["samples"] += 1
300324
node["threads"].add(thread_id)
325+
326+
if opcode is not None:
327+
node["opcodes"][opcode] += 1
328+
301329
current = node
302330

303331
def _get_source_lines(self, func):

0 commit comments

Comments
 (0)