Last active
February 5, 2026 12:23
-
-
Save puzpuzpuz/0894a0140a2f054dec06a919ad1a5b11 to your computer and use it in GitHub Desktop.
analyze-flamegraph.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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