ui: add source and assembly view for callstack sample functions Add a details tab showing a function's source and disassembly, each line and instruction annotated with the number of samples taken on it (self) and including its callees (total), shaded by share of the hottest row. The data comes from the source_file and disassembly tables populated by `trace_processor bundle` and the callstacks.annotate stdlib module; when neither source nor disassembly was bundled the tab explains how to bundle them. Source is syntax highlighted for C, C++ and Rust and instructions for their mnemonics, registers and numbers, with a small dependency-free tokenizer. The assembly view draws branches to targets within the function as arrows in a gutter, packed into lanes with loops coloured distinctly, each row rendering its own slice so the listing stays virtualized. The tab opens from a "View source & assembly" action on flamegraph nodes of the stack sample flamegraphs, both for area selections and for single samples. To locate the function, the stack sample metrics carry the source file, sampled address and mapping id of each node as hidden properties.
diff --git a/ui/src/components/source_annotation/highlight.ts b/ui/src/components/source_annotation/highlight.ts new file mode 100644 index 0000000..e9d8286 --- /dev/null +++ b/ui/src/components/source_annotation/highlight.ts
@@ -0,0 +1,321 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// A small dependency-free tokenizer for the source and assembly listings. +// It colours just enough to make code scannable: keywords, types, strings, +// comments and numbers in C, C++ and Rust, and mnemonics, registers and +// numbers in disassembly. Tokens become spans classed +// `pf-source-annotation__syntax--<kind>`. + +import m from 'mithril'; + +export type SourceLanguage = 'c' | 'rust' | 'plain'; + +type TokenKind = + | 'keyword' + | 'type' + | 'string' + | 'comment' + | 'number' + | 'mnemonic' + | 'register'; + +const KEYWORDS = new Set([ + // C / C++. + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'return', + 'goto', + 'default', + 'sizeof', + 'typedef', + 'struct', + 'union', + 'enum', + 'class', + 'namespace', + 'template', + 'typename', + 'public', + 'private', + 'protected', + 'virtual', + 'override', + 'final', + 'new', + 'delete', + 'this', + 'nullptr', + 'true', + 'false', + 'using', + 'static', + 'const', + 'constexpr', + 'consteval', + 'volatile', + 'inline', + 'extern', + 'register', + 'auto', + 'operator', + 'throw', + 'try', + 'catch', + 'noexcept', + 'friend', + 'explicit', + 'mutable', + 'static_cast', + 'reinterpret_cast', + 'const_cast', + 'dynamic_cast', + 'decltype', + 'concept', + 'requires', + 'co_await', + 'co_return', + 'co_yield', + // Rust. + 'fn', + 'let', + 'mut', + 'impl', + 'trait', + 'pub', + 'use', + 'mod', + 'match', + 'loop', + 'in', + 'as', + 'dyn', + 'move', + 'ref', + 'unsafe', + 'async', + 'await', + 'crate', + 'super', + 'where', + 'self', + 'Self', +]); + +const TYPES = new Set([ + 'int', + 'char', + 'short', + 'long', + 'float', + 'double', + 'unsigned', + 'signed', + 'bool', + 'void', + 'wchar_t', + 'char16_t', + 'char32_t', + 'size_t', + 'ssize_t', + 'ptrdiff_t', + 'intptr_t', + 'uintptr_t', + 'int8_t', + 'int16_t', + 'int32_t', + 'int64_t', + 'uint8_t', + 'uint16_t', + 'uint32_t', + 'uint64_t', + 'u8', + 'u16', + 'u32', + 'u64', + 'u128', + 'usize', + 'i8', + 'i16', + 'i32', + 'i64', + 'i128', + 'isize', + 'f32', + 'f64', + 'str', +]); + +const ID_START = /[A-Za-z_$]/; +const ID_PART = /[A-Za-z0-9_$]/; +const DIGIT = /[0-9]/; + +// x86 (Intel syntax) and arm64 register names. +const REGISTER = + /^(?:[er]?[abcd]x|[er]?[sd]i|[er]?[sb]p|r(?:8|9|1[0-5])[dwb]?|[abcd][lh]|[sd]il|[sb]pl|[xyz]mm\d+|k[0-7]|[wx](?:[12]?\d|3[01]|zr)|sp|lr|pc|fp|[vqdshb]\d+|[cdefgs]s|[er]?ip|[er]?flags)$/i; + +export function languageForPath(path: string | undefined): SourceLanguage { + if (path === undefined) return 'plain'; + const dot = path.lastIndexOf('.'); + const ext = dot === -1 ? '' : path.slice(dot + 1).toLowerCase(); + if (ext === 'rs') return 'rust'; + if ( + ['c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'hxx', 'm', 'mm'].includes(ext) + ) { + return 'c'; + } + return 'plain'; +} + +function span(kind: TokenKind, text: string): m.Children { + return m(`span.pf-source-annotation__syntax--${kind}`, text); +} + +export interface HighlightedLine { + readonly children: m.Children; + // Whether a block comment is still open at the end of the line, to be + // passed to the next line. + readonly inBlockComment: boolean; +} + +// Tokenizes one line of source. Block comments spanning lines are tracked +// through `inBlockComment`. +export function highlightSourceLine( + line: string, + language: SourceLanguage, + inBlockComment: boolean, +): HighlightedLine { + if (language === 'plain') { + return {children: line, inBlockComment: false}; + } + const out: m.Children[] = []; + const n = line.length; + let i = 0; + let open = inBlockComment; + while (i < n) { + if (open) { + const end = line.indexOf('*/', i); + if (end === -1) { + out.push(span('comment', line.slice(i))); + i = n; + } else { + out.push(span('comment', line.slice(i, end + 2))); + i = end + 2; + open = false; + } + continue; + } + const c = line[i]; + const pair = line.slice(i, i + 2); + if (pair === '//') { + out.push(span('comment', line.slice(i))); + break; + } + if (pair === '/*') { + const end = line.indexOf('*/', i + 2); + if (end === -1) { + out.push(span('comment', line.slice(i))); + open = true; + i = n; + } else { + out.push(span('comment', line.slice(i, end + 2))); + i = end + 2; + } + continue; + } + if (c === '"' || c === "'") { + const start = i; + i++; + while (i < n) { + if (line[i] === '\\') { + i += 2; + continue; + } + if (line[i] === c) { + i++; + break; + } + i++; + } + out.push(span('string', line.slice(start, i))); + continue; + } + if (DIGIT.test(c)) { + const start = i; + while (i < n && /[0-9a-fA-FxXbBoO._']/.test(line[i])) i++; + // Suffixes: 10u, 10ul, 1.0f and Rust's 10usize. + while (i < n && /[uUlLfFiIsSzZe]/.test(line[i])) i++; + out.push(span('number', line.slice(start, i))); + continue; + } + if (ID_START.test(c)) { + const start = i; + while (i < n && ID_PART.test(line[i])) i++; + const word = line.slice(start, i); + if (TYPES.has(word)) { + out.push(span('type', word)); + } else if (KEYWORDS.has(word)) { + out.push(span('keyword', word)); + } else { + out.push(word); + } + continue; + } + // A run of operators, punctuation and whitespace. + const start = i; + i++; + while ( + i < n && + !ID_START.test(line[i]) && + !DIGIT.test(line[i]) && + line[i] !== '"' && + line[i] !== "'" && + line.slice(i, i + 2) !== '//' && + line.slice(i, i + 2) !== '/*' + ) { + i++; + } + out.push(line.slice(start, i)); + } + return {children: out, inBlockComment: open}; +} + +// Tokenizes one disassembled instruction: the mnemonic, then registers and +// numbers among the operands. +export function highlightInstruction(text: string): m.Children { + const parts = text.split(/(\s+|,|\(|\)|\[|\]|\+|\*|:|<|>|!|#)/); + const out: m.Children[] = []; + let seenMnemonic = false; + for (const part of parts) { + if (part === '') continue; + if (!seenMnemonic && /^[a-z][a-z0-9.]*$/i.test(part)) { + seenMnemonic = true; + out.push(span('mnemonic', part)); + } else if (/^-?0x[0-9a-f]+$/i.test(part) || /^-?\d+$/.test(part)) { + out.push(span('number', part)); + } else if (REGISTER.test(part)) { + out.push(span('register', part)); + } else { + out.push(part); + } + } + return out; +}
diff --git a/ui/src/components/source_annotation/highlight_unittest.ts b/ui/src/components/source_annotation/highlight_unittest.ts new file mode 100644 index 0000000..d8cc001 --- /dev/null +++ b/ui/src/components/source_annotation/highlight_unittest.ts
@@ -0,0 +1,155 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type m from 'mithril'; +import { + highlightInstruction, + highlightSourceLine, + languageForPath, +} from './highlight'; + +// Flattens rendered children into [kind, text] pairs, with plain text +// carrying an undefined kind. +function tokens(children: m.Children): Array<[string | undefined, string]> { + const out: Array<[string | undefined, string]> = []; + const visit = (c: m.Children) => { + if (Array.isArray(c)) { + c.forEach(visit); + } else if (typeof c === 'string') { + out.push([undefined, c]); + } else if (c !== null && typeof c === 'object' && 'tag' in c) { + const className = String(c.attrs?.className ?? ''); + const kind = className.replace('pf-source-annotation__syntax--', ''); + out.push([kind, textOf(c)]); + } + }; + // The text of a vnode: text vnodes have tag '#'. + const textOf = (v: m.Vnode): string => { + if (v.tag === '#') return String(v.children); + if (typeof v.children === 'string') return v.children; + if (Array.isArray(v.children)) { + return v.children + .map((child) => + child !== null && typeof child === 'object' && 'tag' in child + ? textOf(child) + : String(child ?? ''), + ) + .join(''); + } + return ''; + }; + visit(children); + return out; +} + +describe('languageForPath', () => { + it('maps extensions', () => { + expect(languageForPath('/src/a.cc')).toBe('c'); + expect(languageForPath('/src/a.h')).toBe('c'); + expect(languageForPath('/src/lib.rs')).toBe('rust'); + expect(languageForPath('/src/notes.txt')).toBe('plain'); + expect(languageForPath(undefined)).toBe('plain'); + }); +}); + +describe('highlightSourceLine', () => { + it('classifies keywords, types, numbers and strings', () => { + const {children} = highlightSourceLine( + 'const int x = 0x10; return "a\\"b";', + 'c', + false, + ); + expect(tokens(children)).toEqual([ + ['keyword', 'const'], + [undefined, ' '], + ['type', 'int'], + [undefined, ' '], + [undefined, 'x'], + [undefined, ' = '], + ['number', '0x10'], + [undefined, '; '], + ['keyword', 'return'], + [undefined, ' '], + ['string', '"a\\"b"'], + [undefined, ';'], + ]); + }); + + it('threads block comments across lines', () => { + const first = highlightSourceLine('int a; /* start', 'c', false); + expect(first.inBlockComment).toBe(true); + expect(tokens(first.children)).toEqual([ + ['type', 'int'], + [undefined, ' '], + [undefined, 'a'], + [undefined, '; '], + ['comment', '/* start'], + ]); + const second = highlightSourceLine('end */ x++; // done', 'c', true); + expect(second.inBlockComment).toBe(false); + expect(tokens(second.children)).toEqual([ + ['comment', 'end */'], + [undefined, ' '], + [undefined, 'x'], + [undefined, '++; '], + ['comment', '// done'], + ]); + }); + + it('leaves plain files untouched', () => { + const {children} = highlightSourceLine('int a;', 'plain', false); + expect(children).toBe('int a;'); + }); +}); + +describe('highlightInstruction', () => { + it('classifies the mnemonic, registers and numbers', () => { + expect( + tokens(highlightInstruction('mov rax, qword ptr [rbp - 0x8]')), + ).toEqual([ + ['mnemonic', 'mov'], + [undefined, ' '], + ['register', 'rax'], + [undefined, ','], + [undefined, ' '], + [undefined, 'qword'], + [undefined, ' '], + [undefined, 'ptr'], + [undefined, ' '], + [undefined, '['], + ['register', 'rbp'], + [undefined, ' '], + [undefined, '-'], + [undefined, ' '], + ['number', '0x8'], + [undefined, ']'], + ]); + }); + + it('handles arm64 syntax', () => { + expect( + tokens(highlightInstruction('b.lt 0x1000003ac <_compute+0x78>')), + ).toEqual([ + ['mnemonic', 'b.lt'], + [undefined, ' '], + ['number', '0x1000003ac'], + [undefined, ' '], + [undefined, '<'], + [undefined, '_compute'], + [undefined, '+'], + ['number', '0x78'], + [undefined, '>'], + ]); + }); +});
diff --git a/ui/src/components/source_annotation/jump_arrows.ts b/ui/src/components/source_annotation/jump_arrows.ts new file mode 100644 index 0000000..858a649 --- /dev/null +++ b/ui/src/components/source_annotation/jump_arrows.ts
@@ -0,0 +1,140 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Arrows from each branch in a disassembly listing to its target, drawn in a +// gutter to the left of the instructions like `objdump --visualize-jumps`. +// Arcs are packed into lanes so that overlapping ones never collide, with +// tighter loops in the lanes closest to the code. Each row draws only its +// own slice of the gutter, so the listing can be virtualized. + +import m from 'mithril'; + +export interface JumpArc { + // Row indices of the branch and its target. + readonly fromIndex: number; + readonly toIndex: number; + // Lane 0 is closest to the code; higher lanes are further left. + readonly lane: number; + // The target is at or before the branch: a loop. + readonly backEdge: boolean; +} + +export interface JumpArcLayout { + readonly arcs: ReadonlyArray<JumpArc>; + readonly laneCount: number; +} + +const EMPTY: JumpArcLayout = {arcs: [], laneCount: 0}; + +// Horizontal distance between lanes and the gap between the innermost lane +// and the code. +const LANE_STEP_PX = 8; +const GUTTER_PAD_PX = 6; + +// Assigns a lane to every branch whose target is an instruction of the +// listing. Shorter arcs are placed first so an inner loop gets a lower lane +// than the control flow enclosing it. +export function layoutJumpArcs( + instructions: ReadonlyArray<{ + readonly relPc: bigint; + readonly targetRelPc?: bigint; + }>, +): JumpArcLayout { + const indexByRelPc = new Map<bigint, number>(); + instructions.forEach((insn, i) => indexByRelPc.set(insn.relPc, i)); + + const pending: Array<Omit<JumpArc, 'lane'>> = []; + instructions.forEach((insn, fromIndex) => { + if (insn.targetRelPc === undefined) return; + const toIndex = indexByRelPc.get(insn.targetRelPc); + if (toIndex === undefined || toIndex === fromIndex) return; + pending.push({fromIndex, toIndex, backEdge: toIndex < fromIndex}); + }); + if (pending.length === 0) return EMPTY; + pending.sort((a, b) => span(a) - span(b)); + + // The row intervals occupied in each lane. An arc takes the lowest lane + // whose intervals it does not overlap. + const lanes: Array<Array<readonly [number, number]>> = []; + const arcs: JumpArc[] = []; + for (const arc of pending) { + const lo = Math.min(arc.fromIndex, arc.toIndex); + const hi = Math.max(arc.fromIndex, arc.toIndex); + let lane = 0; + for (;;) { + const occupied = (lanes[lane] ??= []); + if (occupied.every(([a, b]) => hi < a || lo > b)) { + occupied.push([lo, hi]); + break; + } + lane++; + } + arcs.push({...arc, lane}); + } + return {arcs, laneCount: lanes.length}; +} + +function span(arc: Omit<JumpArc, 'lane'>): number { + return Math.abs(arc.toIndex - arc.fromIndex); +} + +export function jumpGutterWidthPx(layout: JumpArcLayout): number { + return layout.laneCount === 0 + ? 0 + : layout.laneCount * LANE_STEP_PX + GUTTER_PAD_PX; +} + +// The slice of the gutter belonging to one row: the vertical segments of +// the lanes passing through it, and for a branch or target row the +// horizontal connector to the code, with an arrowhead on the target. +export function renderJumpGutterRow( + rowIndex: number, + layout: JumpArcLayout, + rowHeightPx: number, +): m.Children { + const width = jumpGutterWidthPx(layout); + if (width === 0) return undefined; + const mid = rowHeightPx / 2; + const paths: m.Children[] = []; + for (const arc of layout.arcs) { + const lo = Math.min(arc.fromIndex, arc.toIndex); + const hi = Math.max(arc.fromIndex, arc.toIndex); + if (rowIndex < lo || rowIndex > hi) continue; + const x = width - (arc.lane + 1) * LANE_STEP_PX; + const className = arc.backEdge + ? 'pf-source-annotation__arc pf-source-annotation__arc--loop' + : 'pf-source-annotation__arc'; + // Vertical part: full height inside the span, half at either end. + const y0 = rowIndex === lo ? mid : 0; + const y1 = rowIndex === hi ? mid : rowHeightPx; + let d = `M${x},${y0} V${y1}`; + if (rowIndex === arc.fromIndex || rowIndex === arc.toIndex) { + d += ` M${x},${mid} H${width}`; + } + paths.push(m('path', {className, d})); + if (rowIndex === arc.toIndex) { + paths.push( + m('path', { + className: className + ' pf-source-annotation__arc-head', + d: `M${width - 4},${mid - 3} L${width},${mid} L${width - 4},${mid + 3}`, + }), + ); + } + } + return m( + 'svg.pf-source-annotation__gutter', + {width, height: rowHeightPx, viewBox: `0 0 ${width} ${rowHeightPx}`}, + paths, + ); +}
diff --git a/ui/src/components/source_annotation/jump_arrows_unittest.ts b/ui/src/components/source_annotation/jump_arrows_unittest.ts new file mode 100644 index 0000000..d325ce8 --- /dev/null +++ b/ui/src/components/source_annotation/jump_arrows_unittest.ts
@@ -0,0 +1,94 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + jumpGutterWidthPx, + layoutJumpArcs, + renderJumpGutterRow, +} from './jump_arrows'; + +function insn(relPc: number, targetRelPc?: number) { + return { + relPc: BigInt(relPc), + targetRelPc: targetRelPc === undefined ? undefined : BigInt(targetRelPc), + }; +} + +describe('layoutJumpArcs', () => { + it('ignores instructions without an in-listing target', () => { + const layout = layoutJumpArcs([insn(0), insn(4, 0x1000), insn(8, 8)]); + expect(layout.arcs).toEqual([]); + expect(layout.laneCount).toBe(0); + expect(jumpGutterWidthPx(layout)).toBe(0); + }); + + it('packs overlapping arcs into lanes, tightest loop innermost', () => { + // 0: jump to 12 (outer, forward) + // 4: ... + // 8: jump to 4 (inner loop, backward) + // 12: ... + // 16: jump to 20 (does not overlap the others) + // 20: ... + const layout = layoutJumpArcs([ + insn(0, 12), + insn(4), + insn(8, 4), + insn(12), + insn(16, 20), + insn(20), + ]); + expect(layout.laneCount).toBe(2); + const byFrom = new Map(layout.arcs.map((a) => [a.fromIndex, a])); + expect(byFrom.get(2)).toEqual({ + fromIndex: 2, + toIndex: 1, + lane: 0, + backEdge: true, + }); + expect(byFrom.get(4)).toEqual({ + fromIndex: 4, + toIndex: 5, + lane: 0, + backEdge: false, + }); + expect(byFrom.get(0)).toEqual({ + fromIndex: 0, + toIndex: 3, + lane: 1, + backEdge: false, + }); + }); +}); + +describe('renderJumpGutterRow', () => { + it('draws nothing for rows outside every arc', () => { + const layout = layoutJumpArcs([insn(0, 4), insn(4), insn(8)]); + expect(renderJumpGutterRow(2, layout, 24)).toBeUndefined; + }); + + it('draws the connector on the branch and an arrowhead on the target', () => { + const layout = layoutJumpArcs([insn(0, 8), insn(4), insn(8)]); + const count = (row: number) => { + const svg = renderJumpGutterRow(row, layout, 24); + const paths = (svg as {children: unknown[]}).children; + return paths.length; + }; + // Branch row: vertical half plus connector, in one path. + expect(count(0)).toBe(1); + // Middle row: vertical only. + expect(count(1)).toBe(1); + // Target row: connector path plus the arrowhead. + expect(count(2)).toBe(2); + }); +});
diff --git a/ui/src/components/source_annotation/source_annotation_loader.ts b/ui/src/components/source_annotation/source_annotation_loader.ts new file mode 100644 index 0000000..ad3c7d0 --- /dev/null +++ b/ui/src/components/source_annotation/source_annotation_loader.ts
@@ -0,0 +1,229 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {sqliteString} from '../../base/string_utils'; +import type {Engine} from '../../trace_processor/engine'; +import { + LONG, + LONG_NULL, + NUM, + NUM_NULL, + STR, + STR_NULL, +} from '../../trace_processor/query_result'; + +// A function to annotate with sample counts. Identity comes from a callstack +// tree node: the mapping and a sampled address locate the bundled +// disassembly, the name is the fallback when no address is known. +export interface SourceAnnotationTarget { + readonly functionName: string; + readonly mappingName?: string; + readonly mappingId?: number; + // A sampled address in the function, relative to the mapping. + readonly relPc?: bigint; + readonly sourceFile?: string; + // SQL subquery with a `callsite_id` column and one row per sample, scoping + // the counts to the samples shown in the tree the function came from. + readonly samplesSql: string; +} + +export interface AnnotatedInstruction { + readonly relPc: bigint; + // Hex-encoded instruction bytes. + readonly bytes: string; + readonly text: string; + readonly targetRelPc?: bigint; + readonly targetSymbol?: string; + readonly sourceFile?: string; + readonly lineNumber?: number; + readonly selfCount: number; + readonly totalCount: number; +} + +export interface AnnotatedLine { + readonly lineNumber: number; + readonly text: string; + readonly selfCount: number; + readonly totalCount: number; +} + +export interface SourceAnnotation { + readonly functionName: string; + readonly sourceFile?: string; + // Every line of `sourceFile` with its counts, or empty if the file was not + // bundled with the trace. + readonly lines: ReadonlyArray<AnnotatedLine>; + // The function's instructions with their counts, or empty if no + // disassembly was bundled for it. + readonly instructions: ReadonlyArray<AnnotatedInstruction>; + readonly maxLineSelf: number; + readonly maxLineTotal: number; + readonly maxInstructionSelf: number; + readonly maxInstructionTotal: number; +} + +export async function loadSourceAnnotation( + engine: Engine, + target: SourceAnnotationTarget, +): Promise<SourceAnnotation> { + await engine.query('INCLUDE PERFETTO MODULE callstacks.annotate;'); + const functionId = await findFunction(engine, target); + const instructions = + functionId === undefined + ? [] + : await loadInstructions(engine, target.samplesSql, functionId); + const sourceFile = target.sourceFile ?? dominantSourceFile(instructions); + const lines = + sourceFile === undefined + ? [] + : await loadLines(engine, target.samplesSql, sourceFile); + return { + functionName: target.functionName, + sourceFile, + lines, + instructions, + maxLineSelf: Math.max(0, ...lines.map((l) => l.selfCount)), + maxLineTotal: Math.max(0, ...lines.map((l) => l.totalCount)), + maxInstructionSelf: Math.max(0, ...instructions.map((i) => i.selfCount)), + maxInstructionTotal: Math.max(0, ...instructions.map((i) => i.totalCount)), + }; +} + +// The id in disassembly_function of the target, if its disassembly was +// bundled. Prefers the function containing the sampled address; falls back +// to matching the (possibly demangled) name within the mapping. +async function findFunction( + engine: Engine, + target: SourceAnnotationTarget, +): Promise<number | undefined> { + const mappingFilter = + target.mappingId !== undefined + ? `m.id = ${target.mappingId}` + : target.mappingName !== undefined + ? `m.name = ${sqliteString(target.mappingName)}` + : '1'; + const name = sqliteString(target.functionName); + const containsRelPc = + target.relPc !== undefined + ? `${target.relPc} >= df.start_rel_pc AND ${target.relPc} < df.start_rel_pc + df.size` + : '0'; + const result = await engine.query(` + SELECT df.id AS id + FROM disassembly_function df + JOIN stack_profile_mapping m + ON iif(df.build_id IS NOT NULL, df.build_id = m.build_id, df.path = m.name) + WHERE ${mappingFilter} + AND ((${containsRelPc}) OR df.name = ${name} OR demangle(df.name) = ${name}) + ORDER BY (${containsRelPc}) DESC + LIMIT 1 + `); + const it = result.iter({id: NUM}); + return it.valid() ? it.id : undefined; +} + +async function loadInstructions( + engine: Engine, + samplesSql: string, + functionId: number, +): Promise<AnnotatedInstruction[]> { + const result = await engine.query(` + SELECT + rel_pc, bytes, text, target_rel_pc, target_symbol, source_file, + line_number, self_count, total_count + FROM _annotated_disassembly!((${samplesSql}), ${functionId}) + `); + const instructions: AnnotatedInstruction[] = []; + const it = result.iter({ + rel_pc: LONG, + bytes: STR, + text: STR, + target_rel_pc: LONG_NULL, + target_symbol: STR_NULL, + source_file: STR_NULL, + line_number: NUM_NULL, + self_count: NUM, + total_count: NUM, + }); + for (; it.valid(); it.next()) { + instructions.push({ + relPc: it.rel_pc, + bytes: it.bytes, + text: it.text, + targetRelPc: it.target_rel_pc ?? undefined, + targetSymbol: it.target_symbol ?? undefined, + sourceFile: it.source_file ?? undefined, + lineNumber: it.line_number ?? undefined, + selfCount: it.self_count, + totalCount: it.total_count, + }); + } + return instructions; +} + +// The source file most of the function's instructions come from. +function dominantSourceFile( + instructions: ReadonlyArray<AnnotatedInstruction>, +): string | undefined { + const counts = new Map<string, number>(); + for (const insn of instructions) { + if (insn.sourceFile === undefined || insn.sourceFile === '??') continue; + counts.set(insn.sourceFile, (counts.get(insn.sourceFile) ?? 0) + 1); + } + let best: string | undefined; + let bestCount = 0; + for (const [file, count] of counts) { + if (count > bestCount) { + best = file; + bestCount = count; + } + } + return best; +} + +async function loadLines( + engine: Engine, + samplesSql: string, + sourceFile: string, +): Promise<AnnotatedLine[]> { + const file = sqliteString(sourceFile); + const contents = await engine.query( + `SELECT contents FROM source_file WHERE path = ${file}`, + ); + const contentsIt = contents.iter({contents: STR}); + if (!contentsIt.valid()) return []; + const text = contentsIt.contents.split('\n'); + // A trailing newline does not start a new line. + if (text.length > 0 && text[text.length - 1] === '') { + text.pop(); + } + + const counts = await engine.query(` + SELECT line_number, self_count, total_count + FROM _sample_counts_by_source_line!((${samplesSql})) + WHERE source_file = ${file} + `); + const selfCounts = new Map<number, number>(); + const totalCounts = new Map<number, number>(); + const it = counts.iter({line_number: NUM, self_count: NUM, total_count: NUM}); + for (; it.valid(); it.next()) { + selfCounts.set(it.line_number, it.self_count); + totalCounts.set(it.line_number, it.total_count); + } + return text.map((line, i) => ({ + lineNumber: i + 1, + text: line, + selfCount: selfCounts.get(i + 1) ?? 0, + totalCount: totalCounts.get(i + 1) ?? 0, + })); +}
diff --git a/ui/src/components/source_annotation/source_annotation_panel.scss b/ui/src/components/source_annotation/source_annotation_panel.scss new file mode 100644 index 0000000..e7c3c55 --- /dev/null +++ b/ui/src/components/source_annotation/source_annotation_panel.scss
@@ -0,0 +1,93 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.pf-source-annotation { + &__count { + font-variant-numeric: tabular-nums; + + // Shade counts by their share of the hottest row in the listing. + &--heat-1 { + background: color-mix(in srgb, var(--pf-color-accent) 12%, transparent); + } + &--heat-2 { + background: color-mix(in srgb, var(--pf-color-accent) 28%, transparent); + } + &--heat-3 { + background: color-mix(in srgb, var(--pf-color-accent) 45%, transparent); + } + &--heat-4 { + background: color-mix(in srgb, var(--pf-color-accent) 65%, transparent); + } + } + + &__line-number { + color: var(--pf-color-text-muted); + font-variant-numeric: tabular-nums; + } + + &__address, + &__bytes { + font-family: var(--pf-font-monospace); + color: var(--pf-color-text-muted); + } + + &__code { + font-family: var(--pf-font-monospace); + white-space: pre; + } + + // Branch arrow gutter of the assembly view, see jump_arrows.ts. + &__jumps { + line-height: 0; + } + + &__gutter { + display: block; + } + + &__arc { + fill: none; + stroke: var(--pf-color-text-muted); + stroke-width: 1.5; + + &--loop { + stroke: var(--pf-color-accent); + } + } + + // Syntax tokens, see highlight.ts. + &__syntax { + &--keyword, + &--mnemonic { + color: var(--pf-color-primary); + font-weight: 500; + } + &--type { + color: var(--pf-color-accent); + } + &--string { + color: var(--pf-color-success); + } + &--number { + color: var(--pf-color-warning); + } + &--comment { + color: var(--pf-color-text-muted); + font-style: italic; + } + &--register { + color: var(--pf-color-accent); + } + } +}
diff --git a/ui/src/components/source_annotation/source_annotation_panel.ts b/ui/src/components/source_annotation/source_annotation_panel.ts new file mode 100644 index 0000000..8b3e304 --- /dev/null +++ b/ui/src/components/source_annotation/source_annotation_panel.ts
@@ -0,0 +1,359 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {AsyncMemo} from '../../base/async_memo'; +import {classNames} from '../../base/classnames'; +import type {Trace} from '../../public/trace'; +import {DetailsShell} from '../../widgets/details_shell'; +import {EmptyState} from '../../widgets/empty_state'; +import {Grid, GridCell, GridHeaderCell, type GridRow} from '../../widgets/grid'; +import {Spinner} from '../../widgets/spinner'; +import {Tabs} from '../../widgets/tabs'; +import type {TreeExplorerOptionalAction} from '../../widgets/tree_explorer'; +import type {AggTreeExplorerQueryColumn} from '../tree_explorer_fetcher'; +import { + highlightInstruction, + highlightSourceLine, + languageForPath, +} from './highlight'; +import { + type JumpArcLayout, + jumpGutterWidthPx, + layoutJumpArcs, + renderJumpGutterRow, +} from './jump_arrows'; +import { + loadSourceAnnotation, + type AnnotatedInstruction, + type AnnotatedLine, + type SourceAnnotation, + type SourceAnnotationTarget, +} from './source_annotation_loader'; + +import './source_annotation_panel.scss'; + +const ROW_HEIGHT_PX = 24; +const TAB_SOURCE = 'source'; +const TAB_ASSEMBLY = 'assembly'; + +export interface SourceAnnotationPanelAttrs { + readonly trace: Trace; + readonly title: string; + readonly target: SourceAnnotationTarget; +} + +// Shows the source and disassembly of a function with the number of samples +// on each line and instruction. +export class SourceAnnotationPanel implements m.ClassComponent<SourceAnnotationPanelAttrs> { + private readonly memo = new AsyncMemo<SourceAnnotation>(); + private activeTab?: string; + + view({attrs}: m.CVnode<SourceAnnotationPanelAttrs>): m.Children { + const {target} = attrs; + const result = this.memo.use({ + key: { + functionName: target.functionName, + mappingName: target.mappingName, + mappingId: target.mappingId, + relPc: target.relPc?.toString(), + sourceFile: target.sourceFile, + samplesSql: target.samplesSql, + }, + compute: () => loadSourceAnnotation(attrs.trace.engine, target), + }); + const data = result.data; + return m( + DetailsShell, + { + fillHeight: true, + title: attrs.title, + description: data?.sourceFile, + }, + data === undefined ? m(Spinner, {easing: true}) : this.renderTabs(data), + ); + } + + private renderTabs(data: SourceAnnotation): m.Children { + const tabs = []; + if (data.lines.length > 0) { + tabs.push({ + key: TAB_SOURCE, + title: 'Source', + leftIcon: 'code', + content: renderSource(data), + }); + } + if (data.instructions.length > 0) { + tabs.push({ + key: TAB_ASSEMBLY, + title: 'Assembly', + leftIcon: 'memory', + content: renderAssembly(data), + }); + } + if (tabs.length === 0) { + return m(EmptyState, { + icon: 'code_off', + title: 'No source or disassembly bundled for this function', + description: + 'Run `trace_processor bundle` with --symbol-paths pointing at the ' + + 'unstripped binaries to bundle them with the trace.', + }); + } + const activeTabKey = + this.activeTab !== undefined && tabs.some((t) => t.key === this.activeTab) + ? this.activeTab + : tabs[0].key; + return m(Tabs, { + className: 'pf-source-annotation', + tabs, + activeTabKey, + onTabChange: (key) => { + this.activeTab = key; + }, + }); + } +} + +function renderSource(data: SourceAnnotation): m.Children { + return m(Grid, { + className: 'pf-source-annotation__grid', + columns: [ + {key: 'self', header: m(GridHeaderCell, 'Self')}, + {key: 'total', header: m(GridHeaderCell, 'Total')}, + {key: 'line', header: m(GridHeaderCell, 'Line')}, + { + key: 'source', + maxInitialWidthPx: Infinity, + header: m(GridHeaderCell, 'Source'), + }, + ], + rowData: highlightedSourceRows(data), + virtualization: {rowHeightPx: ROW_HEIGHT_PX}, + fillHeight: true, + }); +} + +// Block comments span lines, so the lines are tokenized in order. +function highlightedSourceRows(data: SourceAnnotation): GridRow[] { + const language = languageForPath(data.sourceFile); + let inBlockComment = false; + return data.lines.map((line) => { + const highlighted = highlightSourceLine( + line.text, + language, + inBlockComment, + ); + inBlockComment = highlighted.inBlockComment; + return sourceRow(line, highlighted.children, data); + }); +} + +function sourceRow( + line: AnnotatedLine, + code: m.Children, + data: SourceAnnotation, +): GridRow { + return [ + countCell(line.selfCount, data.maxLineSelf), + countCell(line.totalCount, data.maxLineTotal), + m( + GridCell, + {align: 'right', className: 'pf-source-annotation__line-number'}, + line.lineNumber, + ), + m(GridCell, m('span.pf-source-annotation__code', code)), + ]; +} + +function renderAssembly(data: SourceAnnotation): m.Children { + const jumps = layoutJumpArcs(data.instructions); + const gutterWidth = jumpGutterWidthPx(jumps); + return m(Grid, { + className: 'pf-source-annotation__grid', + columns: [ + {key: 'self', header: m(GridHeaderCell, 'Self')}, + {key: 'total', header: m(GridHeaderCell, 'Total')}, + {key: 'address', header: m(GridHeaderCell, 'Address')}, + {key: 'line', header: m(GridHeaderCell, 'Line')}, + {key: 'bytes', header: m(GridHeaderCell, 'Bytes')}, + // Branch arrows to targets within the function. + ...(gutterWidth > 0 + ? [{key: 'jumps', widthPx: gutterWidth, header: m(GridHeaderCell)}] + : []), + { + key: 'instruction', + maxInitialWidthPx: Infinity, + header: m(GridHeaderCell, 'Instruction'), + }, + ], + rowData: data.instructions.map((insn, index) => + instructionRow(insn, index, jumps, data), + ), + virtualization: {rowHeightPx: ROW_HEIGHT_PX}, + fillHeight: true, + }); +} + +function instructionRow( + insn: AnnotatedInstruction, + index: number, + jumps: JumpArcLayout, + data: SourceAnnotation, +): GridRow { + return [ + countCell(insn.selfCount, data.maxInstructionSelf), + countCell(insn.totalCount, data.maxInstructionTotal), + m( + GridCell, + {className: 'pf-source-annotation__address'}, + `0x${insn.relPc.toString(16)}`, + ), + m( + GridCell, + {align: 'right', className: 'pf-source-annotation__line-number'}, + insn.lineNumber, + ), + m(GridCell, {className: 'pf-source-annotation__bytes'}, insn.bytes), + ...(jumps.laneCount > 0 + ? [ + m( + GridCell, + {padding: false, className: 'pf-source-annotation__jumps'}, + renderJumpGutterRow(index, jumps, ROW_HEIGHT_PX), + ), + ] + : []), + m( + GridCell, + m('span.pf-source-annotation__code', highlightInstruction(insn.text)), + ), + ]; +} + +// A count with a background shade proportional to its share of the hottest +// row, so hot spots stand out at a glance. +function countCell(count: number, max: number): m.Children { + const level = count <= 0 || max <= 0 ? 0 : Math.ceil((4 * count) / max); + return m( + GridCell, + { + align: 'right', + className: classNames( + 'pf-source-annotation__count', + level > 0 && `pf-source-annotation__count--heat-${level}`, + ), + }, + count > 0 ? count : '', + ); +} + +// Opens a SourceAnnotationPanel in an ephemeral tab keyed by the target, so +// re-invoking for the same function reuses the tab. +export function openSourceAnnotationTab( + trace: Trace, + title: string, + target: SourceAnnotationTarget, +): void { + const uri = `source_annotation#${target.mappingName ?? ''}/${target.functionName}/${title}`; + trace.tabs.registerTab({ + uri, + isEphemeral: true, + content: { + getTitle: () => title, + render: () => m(SourceAnnotationPanel, {trace, title, target}), + }, + }); + trace.tabs.showTab(uri); +} + +// Hidden properties identifying the function of a callstack tree node, for +// `sourceAnnotationNodeAction`. Metrics using it must select the columns +// `source_file`, `rel_pc` and `mapping_id` which the `_callstacks_for_*` +// macros of `callstacks.stack_profile` provide. +export const SOURCE_ANNOTATION_PROPERTIES: ReadonlyArray<AggTreeExplorerQueryColumn> = + [ + { + name: 'source_file', + displayName: 'Source File', + mergeAggregation: 'ONE_OR_SUMMARY', + isVisible: () => false, + }, + { + name: 'rel_pc', + displayName: 'Address', + mergeAggregation: 'ONE_OR_SUMMARY', + isVisible: () => false, + }, + { + name: 'mapping_id', + displayName: 'Mapping Id', + mergeAggregation: 'ONE_OR_SUMMARY', + isVisible: () => false, + }, + ]; + +// A node action opening the annotated source and disassembly of the node's +// function, counting the samples in `samplesSql` (see +// SourceAnnotationTarget). +export function sourceAnnotationNodeAction( + trace: Trace, + samplesSql: string, +): TreeExplorerOptionalAction { + return { + name: 'View source & assembly', + icon: 'code', + category: 'DRILL', + description: + 'Show the source and disassembly of this function with the number ' + + 'of samples on each line and instruction.', + execute: ({node, properties}) => { + if (node === undefined) return; + openSourceAnnotationTab( + trace, + node.name, + sourceAnnotationTargetFromProperties(node.name, properties, samplesSql), + ); + }, + }; +} + +// Builds the target for a callstack tree node from the node's properties. +// Aggregated properties of merged nodes read `<value> and N others`; the +// first value is representative enough to locate the function. +export function sourceAnnotationTargetFromProperties( + functionName: string, + properties: ReadonlyMap<string, string>, + samplesSql: string, +): SourceAnnotationTarget { + const first = (key: string): string | undefined => { + const value = properties.get(key)?.split(' and ')[0]; + // Symbolizers report an unknown file as '??'. + if (value === undefined || value === '' || value === '??') { + return undefined; + } + return value; + }; + const mappingId = first('mapping_id'); + const relPc = first('rel_pc'); + return { + functionName, + mappingName: first('mapping_name'), + mappingId: mappingId !== undefined ? Number(mappingId) : undefined, + relPc: relPc !== undefined ? BigInt(relPc) : undefined, + sourceFile: first('source_file'), + samplesSql, + }; +}
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/index.ts b/ui/src/plugins/dev.perfetto.StackSamples/index.ts index ecfc509..f307adc 100644 --- a/ui/src/plugins/dev.perfetto.StackSamples/index.ts +++ b/ui/src/plugins/dev.perfetto.StackSamples/index.ts
@@ -21,6 +21,10 @@ type TreeExplorerQueryMetric, } from '../../components/tree_explorer_fetcher'; import {TreeExplorerPanel} from '../../components/tree_explorer_panel'; +import { + SOURCE_ANNOTATION_PROPERTIES, + sourceAnnotationNodeAction, +} from '../../components/source_annotation/source_annotation_panel'; import type {PerfettoPlugin} from '../../public/plugin'; import { type AreaSelection, @@ -211,7 +215,7 @@ !areaSelectionsEqual(previousSelection, selection); if (changed) { previousSelection = selection; - flamegraphMetrics = computeFlamegraphMetrics(selection, config); + flamegraphMetrics = computeFlamegraphMetrics(trace, selection, config); } if (flamegraphMetrics === undefined) return undefined; return { @@ -228,6 +232,7 @@ } function computeFlamegraphMetrics( + trace: Trace, selection: AreaSelection, config: StackSampleAreaSelectionTabConfig, ): ReadonlyArray<TreeExplorerQueryMetric> | undefined { @@ -265,6 +270,14 @@ const contextFilter = constraints.join(' or '); const timeFilter = `p.ts >= ${selection.start} and p.ts <= ${selection.end}`; + const samplesSql = ` + select p.callsite_id + from stack_sample p + left join stack_sample_task_context tc on tc.id = p.task_context_id + left join thread t on t.utid = tc.utid + where ${timeFilter} and (${contextFilter}) + `; + const sourceAnnotationAction = sourceAnnotationNodeAction(trace, samplesSql); const flamegraphProperties = { unaggregatableProperties: [{name: 'mapping_name', displayName: 'Mapping'}], aggregatableProperties: [ @@ -273,6 +286,7 @@ displayName: 'Source Location', mergeAggregation: 'ONE_OR_SUMMARY' as const, }, + ...SOURCE_ANNOTATION_PROPERTIES, ], }; @@ -296,6 +310,9 @@ name, mapping_name, source_file || ':' || line_number as source_location, + source_file, + rel_pc, + mapping_id, self_value as value from _callstacks_for_callsites_weighted!(( select p.callsite_id, c.value as value @@ -310,6 +327,7 @@ )) `, ...flamegraphProperties, + optionalNodeActions: [sourceAnnotationAction], }); } @@ -323,14 +341,11 @@ name, mapping_name, source_file || ':' || line_number as source_location, + source_file, + rel_pc, + mapping_id, self_count - from _callstacks_for_callsites!(( - select p.callsite_id - from stack_sample p - left join stack_sample_task_context tc on tc.id = p.task_context_id - left join thread t on t.utid = tc.utid - where ${timeFilter} and (${contextFilter}) - )) + from _callstacks_for_callsites!((${samplesSql})) ) `, tableMetrics: [ @@ -342,6 +357,7 @@ ], dependencySql: 'include perfetto module callstacks.stack_profile', ...flamegraphProperties, + optionalActions: [sourceAnnotationAction], nameColumnLabel: 'Symbol', }), );
diff --git a/ui/src/plugins/dev.perfetto.StackSamples/profiling_track.ts b/ui/src/plugins/dev.perfetto.StackSamples/profiling_track.ts index 38ae410..af095c2 100644 --- a/ui/src/plugins/dev.perfetto.StackSamples/profiling_track.ts +++ b/ui/src/plugins/dev.perfetto.StackSamples/profiling_track.ts
@@ -19,6 +19,10 @@ type TreeExplorerQueryMetric, } from '../../components/tree_explorer_fetcher'; import {TreeExplorerPanel} from '../../components/tree_explorer_panel'; +import { + SOURCE_ANNOTATION_PROPERTIES, + sourceAnnotationNodeAction, +} from '../../components/source_annotation/source_annotation_panel'; import {FlamegraphProfile} from '../../components/flamegraph_profile'; import {DetailsShell} from '../../widgets/details_shell'; import {Timestamp} from '../../components/widgets/timestamp'; @@ -116,6 +120,7 @@ colorizer: (row) => getColorForSample(row.callsiteId), detailsPanel: (row) => { const ts = Time.fromRaw(row.ts); + const samplesSql = config.callsiteQuery(ts); const metrics: ReadonlyArray<TreeExplorerQueryMetric> = metricsFromTableOrSubquery({ tableOrSubquery: ` @@ -126,10 +131,11 @@ name, mapping_name, source_file || ':' || line_number as source_location, + source_file, + rel_pc, + mapping_id, self_count - from _callstacks_for_callsites!(( - ${config.callsiteQuery(ts)} - )) + from _callstacks_for_callsites!((${samplesSql})) ) `, tableMetrics: [ @@ -149,7 +155,9 @@ displayName: 'Source Location', mergeAggregation: 'ONE_OR_SUMMARY', }, + ...SOURCE_ANNOTATION_PROPERTIES, ], + optionalActions: [sourceAnnotationNodeAction(trace, samplesSql)], nameColumnLabel: 'Symbol', }); // Use provided state or create initial state once