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.
This commit is contained in:
Pablo Galindo Salgado 2025-12-03 03:43:18 +00:00
parent 70f2ae025f
commit aedc000a15
2 changed files with 117 additions and 10 deletions

View file

@ -8,6 +8,32 @@ let currentThreadFilter = 'all';
// Heat colors are now defined in CSS variables (--heat-1 through --heat-8)
// and automatically switch with theme changes - no JS color arrays needed!
// Opcode mappings - loaded from embedded data (generated by Python)
let OPCODE_NAMES = {};
let DEOPT_MAP = {};
// Initialize opcode mappings from embedded data
function initOpcodeMapping(data) {
if (data && data.opcode_mapping) {
OPCODE_NAMES = data.opcode_mapping.names || {};
DEOPT_MAP = data.opcode_mapping.deopt || {};
}
}
// Get opcode info from opcode number
function getOpcodeInfo(opcode) {
const opname = OPCODE_NAMES[opcode] || `<${opcode}>`;
const baseOpcode = DEOPT_MAP[opcode];
const isSpecialized = baseOpcode !== undefined;
const baseOpname = isSpecialized ? (OPCODE_NAMES[baseOpcode] || `<${baseOpcode}>`) : opname;
return {
opname: opname,
baseOpname: baseOpname,
isSpecialized: isSpecialized
};
}
// ============================================================================
// String Resolution
// ============================================================================
@ -249,6 +275,55 @@ function createPythonTooltip(data) {
</div>`;
}
// Create bytecode/opcode section if available
let opcodeSection = "";
const opcodes = d.data.opcodes;
if (opcodes && typeof opcodes === 'object' && Object.keys(opcodes).length > 0) {
// Sort opcodes by sample count (descending)
const sortedOpcodes = Object.entries(opcodes)
.sort((a, b) => b[1] - a[1])
.slice(0, 8); // Limit to top 8
const totalOpcodeSamples = sortedOpcodes.reduce((sum, [, count]) => sum + count, 0);
const maxCount = sortedOpcodes[0][1] || 1;
const opcodeLines = sortedOpcodes.map(([opcode, count]) => {
const opcodeInfo = getOpcodeInfo(parseInt(opcode, 10));
const pct = ((count / totalOpcodeSamples) * 100).toFixed(1);
const barWidth = (count / maxCount) * 100;
const specializedBadge = opcodeInfo.isSpecialized
? '<span style="background: #2e7d32; color: white; font-size: 9px; padding: 1px 4px; border-radius: 3px; margin-left: 4px;">SPECIALIZED</span>'
: '';
const baseOpHint = opcodeInfo.isSpecialized
? `<span style="color: #888; font-size: 11px; margin-left: 4px;">(${opcodeInfo.baseOpname})</span>`
: '';
return `
<div style="display: grid; grid-template-columns: 1fr 60px 60px; gap: 8px; align-items: center; padding: 3px 0;">
<div style="font-family: monospace; font-size: 11px; color: ${opcodeInfo.isSpecialized ? '#2e7d32' : '#333'}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${opcodeInfo.opname}${baseOpHint}${specializedBadge}
</div>
<div style="text-align: right; font-size: 11px; color: #666;">${count.toLocaleString()}</div>
<div style="background: #e9ecef; border-radius: 2px; height: 8px; overflow: hidden;">
<div style="background: linear-gradient(90deg, #3776ab, #5a9bd5); height: 100%; width: ${barWidth}%;"></div>
</div>
</div>`;
}).join('');
opcodeSection = `
<div style="margin-top: 16px; padding-top: 12px;
border-top: 1px solid #e9ecef;">
<div style="color: #3776ab; font-size: 13px;
margin-bottom: 8px; font-weight: 600;">
Bytecode Instructions:
</div>
<div style="background: #f8f9fa; border: 1px solid #e9ecef;
border-radius: 6px; padding: 10px;">
${opcodeLines}
</div>
</div>`;
}
const fileLocationHTML = isSpecialFrame ? "" : `
<div class="tooltip-location">${filename}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`;
@ -275,6 +350,7 @@ function createPythonTooltip(data) {
` : ''}
</div>
${sourceSection}
${opcodeSection}
<div class="tooltip-hint">
${childCount > 0 ? "Click to zoom into this function" : "Leaf function - no children"}
</div>
@ -994,6 +1070,9 @@ function initFlamegraph() {
processedData = resolveStringIndices(EMBEDDED_DATA);
}
// Initialize opcode mapping from embedded data
initOpcodeMapping(EMBEDDED_DATA);
originalData = processedData;
initThreadFilter(processedData);

View file

@ -7,7 +7,8 @@
import os
from ._css_utils import get_combined_css
from .collector import Collector
from .collector import Collector, extract_lineno
from .opcode_utils import get_opcode_mapping
from .string_table import StringTable
@ -32,7 +33,11 @@ def __init__(self, *args, **kwargs):
self.stack_counter = collections.Counter()
def process_frames(self, frames, thread_id):
call_tree = tuple(reversed(frames))
# Extract only (filename, lineno, funcname) - opcode not needed for collapsed stacks
# frame is (filename, location, funcname, opcode)
call_tree = tuple(
(f[0], extract_lineno(f[1]), f[2]) for f in reversed(frames)
)
self.stack_counter[(call_tree, thread_id)] += 1
def export(self, filename):
@ -205,6 +210,11 @@ def convert_children(children, min_samples):
source_indices = [self._string_table.intern(line) for line in source]
child_entry["source"] = source_indices
# Include opcode data if available
opcodes = node.get("opcodes", {})
if opcodes:
child_entry["opcodes"] = dict(opcodes)
# Recurse
child_entry["children"] = convert_children(
node["children"], min_samples
@ -251,6 +261,9 @@ def convert_children(children, min_samples):
**stats
}
# Build opcode mapping for JS
opcode_mapping = get_opcode_mapping()
# If we only have one root child, make it the root to avoid redundant level
if len(root_children) == 1:
main_child = root_children[0]
@ -265,6 +278,7 @@ def convert_children(children, min_samples):
}
main_child["threads"] = sorted(list(self._all_threads))
main_child["strings"] = self._string_table.get_strings()
main_child["opcode_mapping"] = opcode_mapping
return main_child
return {
@ -277,27 +291,41 @@ def convert_children(children, min_samples):
"per_thread_stats": per_thread_stats_with_pct
},
"threads": sorted(list(self._all_threads)),
"strings": self._string_table.get_strings()
"strings": self._string_table.get_strings(),
"opcode_mapping": opcode_mapping
}
def process_frames(self, frames, thread_id):
# Reverse to root->leaf
call_tree = reversed(frames)
"""Process stack frames into flamegraph tree structure.
Args:
frames: List of (filename, location, funcname, opcode) tuples in
leaf-to-root order. location is (lineno, end_lineno, col_offset, end_col_offset).
opcode is None if not gathered.
thread_id: Thread ID for this stack trace
"""
# Reverse to root->leaf order for tree building
self._root["samples"] += 1
self._total_samples += 1
self._root["threads"].add(thread_id)
self._all_threads.add(thread_id)
current = self._root
for func in call_tree:
for filename, location, funcname, opcode in reversed(frames):
lineno = extract_lineno(location)
func = (filename, lineno, funcname)
func = self._func_intern.setdefault(func, func)
children = current["children"]
node = children.get(func)
node = current["children"].get(func)
if node is None:
node = {"samples": 0, "children": {}, "threads": set()}
children[func] = node
node = {"samples": 0, "children": {}, "threads": set(), "opcodes": collections.Counter()}
current["children"][func] = node
node["samples"] += 1
node["threads"].add(thread_id)
if opcode is not None:
node["opcodes"][opcode] += 1
current = node
def _get_source_lines(self, func):