| // Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:io'; |
| import 'dart:typed_data'; |
| import 'dart:ffi'; |
| |
| import 'package:fixnum/fixnum.dart' hide Int32; |
| |
| import 'package:profiling/src/perf/perf_data.dart'; |
| import 'package:profiling/src/symbols.dart'; |
| import 'package:profiling/src/pprof/generated/profile.pb.dart' as pprof; |
| |
| /// `PERF_RECORD_SAMPLE` with the following optional fields: |
| /// |
| /// `PERF_SAMPLE_IP`, `PERF_SAMPLE_TID`, `PERF_SAMPLE_TIME`, `PERF_SAMPLE_CALLCHAIN`, |
| /// `PERF_SAMPLE_CPU`, `PERF_SAMPLE_PERIOD`, `PERF_SAMPLE_RAW`. |
| /// |
| /// https://github.com/torvalds/linux/blob/3e9bff3bbe1355805de919f688bef4baefbfd436/include/uapi/linux/perf_event.h#L947 |
| final class SampleEvent extends Struct { |
| external EventHeader header; |
| |
| /// Enabled by `PERF_SAMPLE_IP` |
| @Uint64() |
| external int ip; |
| |
| /// Enabled by `PERF_SAMPLE_TID` |
| @Uint32() |
| external int pid; |
| |
| /// Enabled by `PERF_SAMPLE_TID` |
| @Uint32() |
| external int tid; |
| |
| /// Enabled by `PERF_SAMPLE_TIME` |
| @Uint64() |
| external int time; |
| |
| /// Enabled by `PERF_SAMPLE_CPU` |
| @Uint32() |
| external int cpu; |
| |
| /// Enabled by `PERF_SAMPLE_CPU` |
| @Uint32() |
| external int res; |
| |
| /// Enabled by `PERF_SAMPLE_PERIOD` |
| @Uint64() |
| external int period; |
| |
| /// Enabled by `PERF_SAMPLE_CALLCHAIN` |
| @Uint64() |
| external int nr; |
| |
| /// Enabled by `PERF_SAMPLE_CALLCHAIN` |
| @Array.variable() |
| external Array<Uint64> ips; |
| } |
| |
| /// Data recorded by the probe stored in a `PERF_SAMPLE_RAW`. |
| /// |
| /// The `size` field is part of `PERF_SAMPLE_RAW` encoding the rest are |
| /// part of probe data itself. Format for the recorded data can be recovered |
| /// by loading tracepoint information from an optional section identified |
| /// by `HEADER_TRACING_DATA` ([OptionalSection.tracingData]). However encoding |
| /// of that section is extremely bespoke (see `trace-event-read.c` below), so |
| /// instead of fiddling with that we simply hardcode expected format of the |
| /// probe. This obviously needs to be kept in sync with `set_uprobe.dart` |
| /// script. |
| /// |
| /// ``` |
| /// $ sudo cat /sys/kernel/tracing/events/uprobes/alloc/format |
| /// name: alloc |
| /// ID: 1976 |
| /// format: |
| /// field:unsigned short common_type; offset:0; size:2; signed:0; |
| /// field:unsigned char common_flags; offset:2; size:1; signed:0; |
| /// field:unsigned char common_preempt_count; offset:3; size:1; signed:0; |
| /// field:int common_pid; offset:4; size:4; signed:1; |
| /// |
| /// field:unsigned long __probe_ip; offset:8; size:8; signed:0; |
| /// field:s64 addr; offset:16; size:8; signed:1; |
| /// field:s64 top; offset:24; size:8; signed:1; |
| /// field:u32 cid; offset:32; size:4; signed:0; |
| /// print fmt: "(%lx) addr=%Ld top=%Ld cid=%u", REC->__probe_ip, REC->addr, REC->top, REC->cid |
| /// ``` |
| /// |
| /// [^1]: https://github.com/torvalds/linux/blob/3e9bff3bbe1355805de919f688bef4baefbfd436/tools/perf/util/trace-event-read.c#L375 |
| @Packed(1) |
| final class ProbeData extends Struct { |
| @Uint32() |
| external int size; |
| |
| @Uint16() |
| external int commonType; |
| |
| @Uint8() |
| external int commonFlags; |
| |
| @Uint8() |
| external int commonPreemptCount; |
| |
| @Int32() |
| external int commonPid; |
| |
| @Uint64() |
| external int probeIp; |
| |
| @Uint64() |
| external int addr; |
| |
| @Uint64() |
| external int top; |
| |
| @Uint32() |
| external int cid; |
| |
| @override |
| String toString() => |
| 'Probe{addr=${addr.formatAsAddress()},top=${top.formatAsAddress()},cid=$cid}'; |
| } |
| |
| /// Lazily populated mapping between file offsets in a binary and profile |
| /// location ids. |
| /// |
| /// This class handles convertion of the file offset to the corresponding |
| /// symbol name and futher into corresponding location id inside the profile. |
| final class SymbolsIndex { |
| final ProfileBuilder profileBuilder; |
| |
| final Symbols symbols; |
| final List<Int64?> ids; |
| |
| SymbolsIndex(this.profileBuilder, this.symbols) |
| : ids = List<Int64?>.filled(symbols.fileOffsets.length, null); |
| |
| static final lineRe = |
| RegExp(r"^(?<addr>[0-9a-f]+)\s+(?<typ>\w+)\s+(?<name>.*)$"); |
| |
| /// Return location id corresponding to the given [fileOffset]. |
| /// |
| /// This function will lazily allocate new ids as necessary by |
| /// calling [ProfileBuilder.addSymbol]. |
| Int64? symbolId(int fileOffset) { |
| final index = symbols.symbolIndex(fileOffset); |
| if (index != null) { |
| return (ids[index] ??= profileBuilder.addSymbol(symbols.names[index])); |
| } |
| return null; |
| } |
| } |
| |
| final class Mapping { |
| final int baseAddress; |
| final int length; |
| final String path; |
| final int offset; |
| |
| Mapping({ |
| required this.baseAddress, |
| required this.length, |
| required this.path, |
| required this.offset, |
| }); |
| } |
| |
| /// Symbols information for the whole address space. |
| final class AddressSpaceSymbols { |
| /// Base addresses for mapping ranges. |
| /// |
| /// To simplify search we also add ranges that don't have any symbols here. |
| /// Consider for example that we have two mappings `[A, A')` and `[B, B')` |
| /// with symbols (`Sym(A)` and `Sym(B)` respectively). In this case: |
| /// * [baseAddresses] will contain `[0, A, A', B, B']` and |
| /// * [symbolsIndexes] will contain `[null, SA, null, SymB, null]`. |
| final Int64List baseAddresses; |
| |
| /// Symbol indexes corresponding to mappings in [baseAddresses]. |
| final List<SymbolsIndex?> symbolsIndexes; |
| |
| /// File offsets corresponding to mappings in [baseAddresses]. |
| final Int64List fileOffsets; |
| |
| AddressSpaceSymbols._( |
| this.baseAddresses, this.symbolsIndexes, this.fileOffsets); |
| |
| Int64? symbolId(int address) { |
| // We use linear search because we assume the number of mappings |
| // is very small (~2). |
| final limit = baseAddresses.length - 1; |
| for (var i = 0; i < limit; i++) { |
| final start = baseAddresses[i]; |
| final end = baseAddresses[i + 1]; |
| if (start <= address && address < end) { |
| final fileOffset = address - start + fileOffsets[i]; |
| return symbolsIndexes[i]?.symbolId(fileOffset); |
| } |
| } |
| return null; |
| } |
| |
| /// Construct [AddressSpaceSymbols] from [Mapping] records loaded from |
| /// `perf.data`. |
| static AddressSpaceSymbols fromMappings( |
| List<Mapping> mappings, ProfileBuilder profileBuilder) { |
| // Try loading symbols for each mapping and keep those that |
| // actually have symbols. Sort resulting list by base address. |
| final mappingsWithSymbols = <(Mapping, SymbolsIndex)>[]; |
| for (var event in mappings) { |
| final symbolsIndex = profileBuilder.symbolsIndexFor(event.path); |
| if (symbolsIndex != null) { |
| mappingsWithSymbols.add((event, symbolsIndex)); |
| } |
| } |
| mappingsWithSymbols |
| .sort((a, b) => a.$1.baseAddress.compareTo(b.$1.baseAddress)); |
| |
| // Build `AddressSpaceSymbols` from mappings with symbols. |
| // |
| // Note: we need to accomodate for a situation when two mappings are |
| // adjacent. However we assume that number of mappings is rather small |
| // so we don't optimize this code too much. |
| final result = <({int baseAddress, SymbolsIndex? index, int fileOffset})>[]; |
| void addEntry({ |
| required int baseAddress, |
| required SymbolsIndex? index, |
| required int fileOffset, |
| }) { |
| if (result.isNotEmpty && result.last.baseAddress == baseAddress) { |
| // Collapse end of the previous mapping and the start of the new |
| // mapping. |
| if (result.last.index != null) { |
| throw StateError('Unexpected intersection of address ranges'); |
| } |
| result.removeLast(); |
| } |
| result.add( |
| (baseAddress: baseAddress, index: index, fileOffset: fileOffset)); |
| } |
| |
| addEntry(baseAddress: 0, index: null, fileOffset: 0); |
| for (var e in mappingsWithSymbols) { |
| addEntry( |
| baseAddress: e.$1.baseAddress, |
| index: e.$2, |
| fileOffset: e.$1.offset, |
| ); |
| addEntry( |
| baseAddress: e.$1.baseAddress + e.$1.length, |
| index: null, |
| fileOffset: 0, |
| ); |
| } |
| |
| // Split result into individual components. |
| return AddressSpaceSymbols._( |
| Int64List.fromList( |
| result.map((e) => e.baseAddress).toList(growable: false), |
| ), |
| result.map((e) => e.index).toList(growable: false), |
| Int64List.fromList( |
| result.map((e) => e.fileOffset).toList(growable: false), |
| ), |
| ); |
| } |
| } |
| |
| /// A trie node representing a callstack frame. |
| /// |
| /// To minimize the size of the produced `pprof.profile` we collapse all |
| /// matching callstacks into a single `pprof.Sample` entry in the profile. |
| /// This is done by through a simple [trie][1] data structure. |
| /// |
| /// Nodes corresponding to callstacks from original profile will have not-null |
| /// non-zero [totalBytes] associated with them. Path to these nodes should |
| /// be flushed into [pprof.Profile] as individual [pprof.Sample] entries |
| /// at the end of conversion. See [flushTo]. |
| /// |
| /// [1]: https://en.wikipedia.org/wiki/Trie |
| final class CallStackTrieNode { |
| /// [pprof.Profile] location id corresponding to this frame. |
| final Int64 id; |
| |
| /// Total number of bytes allocated by this frame. |
| int totalBytes = 0; |
| |
| /// Callees of this frame. |
| late final Map<Int64, CallStackTrieNode> children = |
| <Int64, CallStackTrieNode>{}; |
| |
| CallStackTrieNode({required this.id}); |
| |
| CallStackTrieNode operator [](Int64 id) => |
| children[id] ??= CallStackTrieNode(id: id); |
| |
| void flushTo(pprof.Profile profile, List<Int64> path) { |
| if (totalBytes != 0) { |
| profile.sample.add( |
| pprof.Sample(locationId: path.reversed)..value.add(Int64(totalBytes)), |
| ); |
| } |
| for (var child in children.values) { |
| path.add(child.id); |
| child.flushTo(profile, path); |
| path.removeLast(); |
| } |
| } |
| } |
| |
| /// Helper for building [pprof.Profile]. |
| /// |
| /// It takes care of indexing symbols and managing their ids. |
| final class ProfileBuilder { |
| final profile = pprof.Profile(); |
| |
| final symbolTable = <String, Int64>{}; |
| final locationIds = <String, Int64>{}; |
| |
| final callStackTrieRoot = CallStackTrieNode(id: Int64(-1)); |
| |
| ProfileBuilder() { |
| addString(""); |
| profile.sampleType.add(pprof.ValueType( |
| type: addString('space'), |
| unit: addString('bytes'), |
| )); |
| } |
| |
| Int64 addString(String str) { |
| var id = symbolTable[str]; |
| if (id != null) { |
| return id; |
| } |
| id = symbolTable[str] = Int64(symbolTable.length); |
| profile.stringTable.add(str); |
| return id; |
| } |
| |
| Int64 addSymbol(String symbol) { |
| var id = locationIds[symbol]; |
| if (id != null) { |
| return id; |
| } |
| id = locationIds[symbol] = Int64(locationIds.length + 1); |
| profile.function.add(pprof.Function_(id: id, name: addString(symbol))); |
| profile.location |
| .add(pprof.Location(id: id, line: [pprof.Line(functionId: id)])); |
| return id; |
| } |
| |
| SymbolsIndex? symbolsIndexFor(String path) { |
| final symbols = Symbols.load(path); |
| if (symbols == null) { |
| return null; |
| } |
| return SymbolsIndex(this, symbols); |
| } |
| |
| pprof.Profile finishProfile() { |
| callStackTrieRoot.flushTo(profile, []); |
| return profile; |
| } |
| } |
| |
| /// Helper for converting raw callstack into its symbolized form. |
| /// |
| /// We assume that callstack for each new sample usually shares its prefix |
| /// (e.g. outermost callers, like `main`) with the previously processed |
| /// sample. This allows us to reuse location ids from the previous sample |
| /// for the large portion of the stack. |
| final class SymbolizedCallStackBuilder { |
| /// Depth of the current stack. |
| int depth = 0; |
| |
| /// Raw addresses for each frame in the caller to callee order. |
| /// |
| /// We do not clear this array between samples allowing us to detect |
| /// situations when we can reuse entries. Only entries `0..depth-1` |
| /// correspond to the current stack. Other entries originate from |
| /// previous samples and might be out of sync with the current sample. |
| /// |
| /// (`0` is the outermost caller, `1` is its callee, etc). |
| final pcs = Int64List(200); |
| |
| /// Trie nodes corresponding to each frame in the stack. |
| /// |
| /// For entries in the `0..depth-2` range `nodes[i]` is parent of |
| /// `nodes[i+1]` . |
| /// |
| final nodes = List<CallStackTrieNode?>.filled(200, null); |
| |
| /// Trie node for the last frame (either `nodes[depth-1]` or root trie node |
| /// if [depth] is `0`). |
| CallStackTrieNode last; |
| |
| /// `true` when the callstack which is currently being built matches |
| /// the prefix of the previous callstack. |
| bool prefixMatches = true; |
| |
| SymbolizedCallStackBuilder(ProfileBuilder builder) |
| : last = builder.callStackTrieRoot; |
| |
| void add(int pc, AddressSpaceSymbols syms) { |
| if (pcs[depth] != pc) { |
| // Mismatch between newly added `pc` and the `pc` we have from the |
| // previous sample. We need to lookup location id for it. |
| final id = syms.symbolId(pc); |
| if (id == null) { |
| // No symbol - drop the frame. |
| return; |
| } |
| |
| pcs[depth] = pc; |
| last = last[id]; |
| |
| // We might still hit the same node in the trie. |
| if (nodes[depth] != last) { |
| nodes[depth] = last; |
| // From here onward we can't reuse `nodes` because the path has |
| // diverged. |
| prefixMatches = false; |
| } |
| } else if (!prefixMatches) { |
| // Address might match - but the path we got here might be different. This |
| // means we can reuse `id` from the node, but not the node itself. |
| final id = nodes[depth]!.id; |
| nodes[depth] = last = last[id]; |
| } else { |
| // This pc *and* the all previous nodes match. We can just reuse |
| // the node. |
| last = nodes[depth]!; |
| } |
| depth++; |
| } |
| |
| void addTo(ProfileBuilder builder, int allocatedBytes) { |
| last.totalBytes += allocatedBytes; |
| } |
| |
| void reset(ProfileBuilder builder) { |
| depth = 0; |
| last = builder.callStackTrieRoot; |
| prefixMatches = true; |
| } |
| } |
| |
| @pragma('vm:never-inline') |
| pprof.Profile buildProfileFromPerfData(String path) { |
| final raf = File(path).openSync(); |
| |
| final profileBuilder = ProfileBuilder(); |
| final perfData = PerfData(raf); |
| |
| // Check that input file has expected format. |
| final allAttrs = perfData.readAttrs(); |
| if (allAttrs.length != 1) { |
| perfData.reportError( |
| 'Expected single perf_event_attrs structure, got ${allAttrs.length}'); |
| } |
| |
| final attrs = allAttrs.first; |
| |
| if (attrs.type != TypeId.tracepoint) { |
| perfData.reportError('Expected to find a file with tracepoint events'); |
| } |
| |
| const expectedSampleFormat = SampleFormat.ip | |
| SampleFormat.tid | |
| SampleFormat.time | |
| SampleFormat.callchain | |
| SampleFormat.cpu | |
| SampleFormat.period | |
| SampleFormat.raw; |
| if (attrs.sampleType != expectedSampleFormat) { |
| perfData.reportError( |
| 'Expected to sample format ${SampleFormat.format(expectedSampleFormat)}' |
| ' got ${SampleFormat.format(attrs.sampleType)}: difference ' |
| '${SampleFormat.format(attrs.sampleType ^ expectedSampleFormat)}'); |
| } |
| |
| final mappings = <Mapping>[]; |
| perfData.readEvents((type, chunk, pos) { |
| if (type == EventType.mmap2) { |
| final event = Struct.create<Mmap2Event>(chunk, pos); |
| mappings.add(Mapping( |
| baseAddress: event.addr, |
| length: event.len, |
| path: event.filename.toStringFromZeroTerminated(), |
| offset: event.pgoffs, |
| )); |
| } else if (type == EventType.sample && mappings.isNotEmpty) { |
| // TODO: we miss one sample here. |
| return false; // Break iteration. |
| } |
| return true; |
| }); |
| |
| final syms = AddressSpaceSymbols.fromMappings(mappings, profileBuilder); |
| final stack = SymbolizedCallStackBuilder(profileBuilder); |
| perfData.readEvents((type, chunk, pos) { |
| if (type == EventType.sample) { |
| final sample = Struct.create<SampleEvent>(chunk, pos); |
| final probeData = Struct.create<ProbeData>( |
| chunk, pos + sizeOf<SampleEvent>() + sample.nr * 8); |
| |
| for (var i = sample.nr - 1; i > 1; i--) { |
| stack.add(sample.ips[i], syms); |
| } |
| if (stack.depth > 0) { |
| // Accumulate [totalBytes] in the last node. |
| stack.last.totalBytes += probeData.top - probeData.addr - 1; |
| } |
| |
| // Reset the stack for the next sample. |
| stack.reset(profileBuilder); |
| } |
| |
| return true; |
| }); |
| |
| print('All data loaded - creating profile.'); |
| return profileBuilder.finishProfile(); |
| } |
| |
| void main(List<String> args) async { |
| final perfDataPath = args[0]; |
| print('loading $perfDataPath'); |
| final profile = buildProfileFromPerfData(perfDataPath); |
| print('created ${profile.sample.length} samples'); |
| |
| print('Serializing proto (pprof.profile)'); |
| final result = profile.writeToBuffer(); |
| print('... ${result.length} bytes'); |
| File('pprof.profile').writeAsBytesSync(result); |
| print('Done'); |
| } |