Skip to content

Instantly share code, notes, and snippets.

@puzpuzpuz
Last active February 5, 2026 12:23
Show Gist options
  • Select an option

  • Save puzpuzpuz/0894a0140a2f054dec06a919ad1a5b11 to your computer and use it in GitHub Desktop.

Select an option

Save puzpuzpuz/0894a0140a2f054dec06a919ad1a5b11 to your computer and use it in GitHub Desktop.
analyze-flamegraph.js
#!/usr/bin/env node
/**
* Flame graph analyzer for async-profiler HTML output.
*
* Usage: node analyze-flamegraph.js <flamegraph.html> [thread-filter]
*
* Examples:
* node analyze-flamegraph.js flame.html # All threads
* node analyze-flamegraph.js flame.html query_ # Only query worker threads
* node analyze-flamegraph.js flame.html shared-network # Network threads
*/
const fs = require('fs');
const args = process.argv.slice(2);
if (args.length < 1) {
console.log('Usage: node analyze-flamegraph.js <flamegraph.html> [thread-filter]');
console.log('');
console.log('Examples:');
console.log(' node analyze-flamegraph.js flame.html # All threads');
console.log(' node analyze-flamegraph.js flame.html query_ # Only query worker threads');
console.log(' node analyze-flamegraph.js flame.html shared-network # Network threads');
process.exit(1);
}
const htmlFile = args[0];
const threadFilter = args[1] || null;
const html = fs.readFileSync(htmlFile, 'utf8');
// Extract and unpack cpool
const cpoolMatch = html.match(/const cpool = \[([\s\S]*?)\];/);
if (!cpoolMatch) {
console.error('Error: Could not find cpool in HTML file');
process.exit(1);
}
const cpool = eval('[' + cpoolMatch[1] + ']');
for (let i = 1; i < cpool.length; i++) {
cpool[i] = cpool[i - 1].substring(0, cpool[i].charCodeAt(0) - 32) + cpool[i].substring(1);
}
// Parse flame graph structure
const frameData = html.match(/unpack\(cpool\);([\s\S]*?)$/);
if (!frameData) {
console.error('Error: Could not find frame data in HTML file');
process.exit(1);
}
let level0 = 0, left0 = 0, width0 = 0;
const allFrames = [];
const lines = frameData[1].split('\n');
for (const line of lines) {
// f(key,level,left,width,...)
let match = line.match(/^f\((\d+),(\d+),(\d+),(\d+)/);
if (match) {
const [_, key, level, left, width] = match.map(Number);
level0 = level;
left0 += left;
width0 = width;
const idx = key >> 3;
if (idx < cpool.length) {
allFrames.push({ name: cpool[idx], level: level0, left: left0, width: width0 });
}
continue;
}
// u(key,width,...) - child (level+1)
match = line.match(/^u\((\d+),?(\d*)/);
if (match) {
const key = Number(match[1]);
const width = match[2] ? Number(match[2]) : width0;
level0 = level0 + 1;
width0 = width || width0;
const idx = key >> 3;
if (idx < cpool.length) {
allFrames.push({ name: cpool[idx], level: level0, left: left0, width: width0 });
}
continue;
}
// n(key,width,...) - sibling (same level)
match = line.match(/^n\((\d+),(\d+)/);
if (match) {
const [_, key, width] = match.map(Number);
left0 += width0;
width0 = width;
const idx = key >> 3;
if (idx < cpool.length) {
allFrames.push({ name: cpool[idx], level: level0, left: left0, width: width0 });
}
continue;
}
}
// Find thread frames at level 1
const threadFrames = allFrames.filter(f => f.level === 1 && f.name.includes('tid='));
// Apply thread filter if specified
const filteredThreads = threadFilter
? threadFrames.filter(f => f.name.includes(threadFilter))
: threadFrames;
if (filteredThreads.length === 0) {
console.error('No threads found' + (threadFilter ? ` matching filter "${threadFilter}"` : ''));
process.exit(1);
}
// Calculate total samples for filtered threads
const totalSamples = filteredThreads.reduce((s, t) => s + t.width, 0);
const threadRanges = filteredThreads.map(t => ({ left: t.left, right: t.left + t.width }));
// Print thread summary
console.log('='.repeat(70));
console.log('FLAME GRAPH ANALYSIS');
console.log('='.repeat(70));
console.log('');
console.log('Threads' + (threadFilter ? ` (filter: "${threadFilter}")` : '') + ':');
for (const t of filteredThreads) {
const pct = (t.width / totalSamples * 100).toFixed(1);
console.log(` ${t.name}: ${t.width} samples (${pct}%)`);
}
console.log('');
console.log(`Total samples: ${totalSamples}`);
console.log('');
// Filter frames within selected thread ranges
const threadFrameData = allFrames.filter(f => {
if (f.level <= 1) return false;
return threadRanges.some(r => f.left >= r.left && f.left < r.right);
});
// Calculate self-time for each frame.
// Self-time = frame width - sum of direct children widths
// A frame's direct children are frames at level+1 that start within its range.
// Sort frames by left position, then by level (deeper first for processing)
const sortedFrames = [...threadFrameData].sort((a, b) => {
if (a.left !== b.left) return a.left - b.left;
return b.level - a.level; // deeper levels first
});
// Build a map of frames by their position for quick lookup
// For each frame, calculate self-time by subtracting children's width
const selfTimeByMethod = {};
const inclusiveTimeByMethod = {};
// Group frames by (left, level) to handle them properly
const framesByPos = new Map();
for (const f of threadFrameData) {
const key = `${f.left},${f.level}`;
framesByPos.set(key, f);
}
// For each frame, find its direct children and calculate self-time
for (const frame of threadFrameData) {
if (frame.name.includes('tid=')) continue;
// Find all direct children (level+1, within this frame's range)
let childrenWidth = 0;
for (const other of threadFrameData) {
if (other.level === frame.level + 1 &&
other.left >= frame.left &&
other.left < frame.left + frame.width) {
childrenWidth += other.width;
}
}
const selfTime = frame.width - childrenWidth;
if (selfTime > 0) {
selfTimeByMethod[frame.name] = (selfTimeByMethod[frame.name] || 0) + selfTime;
}
// For inclusive time, only count each unique (left position, method) once
// to avoid double-counting when a method appears multiple times in a stack
inclusiveTimeByMethod[frame.name] = (inclusiveTimeByMethod[frame.name] || 0) + frame.width;
}
// For inclusive time, we need to deduplicate properly.
// A method's inclusive time should be: for each sample, count 1 if method appears in stack.
// This is complex to calculate correctly from aggregated data.
// Instead, let's compute it by finding the "topmost" occurrence of each method per column.
const inclusiveCorrect = {};
// Group frames by their left position (each left pos represents a set of samples)
const framesByLeft = new Map();
for (const f of threadFrameData) {
if (f.name.includes('tid=')) continue;
if (!framesByLeft.has(f.left)) {
framesByLeft.set(f.left, []);
}
framesByLeft.get(f.left).push(f);
}
// For each unique left position, find methods and their widths at that position
// Only count the topmost (lowest level) occurrence of each method
for (const [left, frames] of framesByLeft) {
const methodAtPos = new Map(); // method -> {level, width}
for (const f of frames) {
const existing = methodAtPos.get(f.name);
if (!existing || f.level < existing.level) {
methodAtPos.set(f.name, { level: f.level, width: f.width });
}
}
for (const [method, { width }] of methodAtPos) {
inclusiveCorrect[method] = (inclusiveCorrect[method] || 0) + width;
}
}
// Sort by self-time descending
const sortedBySelf = Object.entries(selfTimeByMethod)
.sort((a, b) => b[1] - a[1]);
// Sort by inclusive time descending
const sortedByInclusive = Object.entries(inclusiveCorrect)
.sort((a, b) => b[1] - a[1]);
// Print self-time (most useful for finding hotspots)
console.log('='.repeat(70));
console.log('TOP METHODS BY SELF TIME (where CPU is actually spent)');
console.log('='.repeat(70));
console.log('');
console.log('Samples % Method');
console.log('-'.repeat(70));
for (const [name, samples] of sortedBySelf.slice(0, 30)) {
const pct = (samples / totalSamples * 100).toFixed(1);
let shortName = name
.replace('io/questdb/', '')
.replace('java/lang/', 'j.l.')
.replace('java/util/', 'j.u.');
if (shortName.length > 55) {
shortName = shortName.substring(0, 52) + '...';
}
console.log(`${String(samples).padStart(7)} ${pct.padStart(5)}% ${shortName}`);
}
// Print inclusive time
console.log('');
console.log('='.repeat(70));
console.log('TOP METHODS BY INCLUSIVE TIME (including callees)');
console.log('='.repeat(70));
console.log('');
console.log('Samples % Method');
console.log('-'.repeat(70));
for (const [name, samples] of sortedByInclusive.slice(0, 30)) {
const pct = (samples / totalSamples * 100).toFixed(1);
let shortName = name
.replace('io/questdb/', '')
.replace('java/lang/', 'j.l.')
.replace('java/util/', 'j.u.');
if (shortName.length > 55) {
shortName = shortName.substring(0, 52) + '...';
}
console.log(`${String(samples).padStart(7)} ${pct.padStart(5)}% ${shortName}`);
}
console.log('');
console.log('='.repeat(70));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment