284e9e91c8
This CL adds basic infrastructure and tooling to perform allocation profiling using uprobes. See runtime/tools/profiling/README.md for more details. TEST=manually tested, requires root access Cq-Include-Trybots: luci.dart.try:vm-aot-mac-release-arm64-try Change-Id: Id68d181740dbf227a12d8cdba84b11a9518e75a7 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/382405 Commit-Queue: Slava Egorov <vegorov@google.com> Reviewed-by: Martin Kustermann <kustermann@google.com>
538 lines
16 KiB
Dart
538 lines
16 KiB
Dart
// 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');
|
|
}
|