From 59693ae54ca6f169b69f21227856df2a6fd4f2c8 Mon Sep 17 00:00:00 2001 From: JJenkx Date: Wed, 21 Oct 2026 22:46:23 -0400 Subject: [PATCH 1/2] feat(tools): symbolize the Windows samplers' stacks The Windows samplers (APS5_SAMPLE_WORKER, APS5_SAMPLE_COMMITTER and APS5_SAMPLE_THREADS) write every frame as module+0xRVA, and BUILD.md left naming them to the reader ("for example with nm on the .prx"). Doing that by hand means finding each module's image base, choosing between COFF symbols and the export table, and keeping the files cumulative totals in mind. tools/symbolize_samples.py reads a collapsed-stack file or the address file, opens each module it finds in --libs as a PE32+ image, and names every frame module!function: - From the COFF symbol table that the MinGW-built .prx and .dll keep: external and static symbols in code sections, at the section's RVA plus the symbol value. Addresses outside a code section, or before the first symbol, stay module+0xRVA. - From the export table, prefixed with "~", for a module without symbols, such as the runtime DLLs. - A module whose SizeOfImage or TimeDateStamp differs from the sampler's "# module" line is left unsymbolized, with a warning: its addresses would name the wrong functions. --since subtracts an earlier copy of the same file, which keeps only the samples taken between the two copies (the files hold totals since start-up). --out writes the named stacks for speedscope or flamegraph.pl. --demangle uses c++filt when it is on PATH. It needs no binutils otherwise. test_symbolize_samples (registered with CTest) builds small PE32+ images with COFF symbols and with an export table, and covers short, string-table and static names, the ignored section and data symbols, addresses outside code, the export fallback, the size and timestamp check, --since with --out, the address-file format and rejected lines. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 2 + docs/dev/BUILD.md | 4 +- tools/symbolize_samples.py | 260 ++++++++++++++++++++++++++ tools/tests/test_symbolize_samples.py | 151 +++++++++++++++ 4 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 tools/symbolize_samples.py create mode 100644 tools/tests/test_symbolize_samples.py diff --git a/CMakeLists.txt b/CMakeLists.txt index b4bb291af5..f66df6f627 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -240,6 +240,8 @@ if(BUILD_TESTING) set_tests_properties(pad_timeline PROPERTIES TIMEOUT 20) add_test(NAME trace_digest COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_trace_digest.py) + add_test(NAME symbolize_samples COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_symbolize_samples.py) add_test(NAME conventions_bom_shebang COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_bom_shebang.py) set_tests_properties(conventions_bom_shebang PROPERTIES TIMEOUT 20) diff --git a/docs/dev/BUILD.md b/docs/dev/BUILD.md index 7778788a16..61cb860139 100644 --- a/docs/dev/BUILD.md +++ b/docs/dev/BUILD.md @@ -196,11 +196,11 @@ On a device that supports `VK_EXT_device_fault`, the first `VK_ERROR_DEVICE_LOST ## Worker sampler -Set `APS5_SAMPLE_WORKER=` to sample queue worker 0 every millisecond (in practice every timer tick). Every 20 s and at exit it writes ``, one `module offset self inclusive` line per sampled address, and `.collapsed.txt`, the worker's whole stacks in the collapsed format that speedscope.app and flamegraph.pl read: one `frame;...;frame count` line per distinct stack, root first, each frame as `module+0xRVA`. A walk that ends before the thread's root starts with `[truncated]`. The `#` lines give the samples, the walk depth (64 frames), the truncated walks, the mean and longest time the worker was suspended, and the size and PE timestamp of each module, to check the binary a file is symbolized against. A frame with `.pdata` is stepped by `RtlVirtualUnwind`; any other frame by its DWARF record in `.ehfram`, the unwind data the libraries are built with. Those steps read only inside the worker's stack, and a module is indexed after the worker resumes, the first time a walk meets it. +Set `APS5_SAMPLE_WORKER=` to sample queue worker 0 every millisecond (in practice every timer tick). Every 20 s and at exit it writes ``, one `module offset self inclusive` line per sampled address, and `.collapsed.txt`, the worker's whole stacks in the collapsed format that speedscope.app and flamegraph.pl read: one `frame;...;frame count` line per distinct stack, root first, each frame as `module+0xRVA`. A walk that ends before the thread's root starts with `[truncated]`. The `#` lines give the samples, the walk depth (64 frames), the truncated walks, the mean and longest time the worker was suspended, and the size and PE timestamp of each module, to check the binary a file is symbolized against. A frame with `.pdata` is stepped by `RtlVirtualUnwind`; any other frame by its DWARF record in `.ehfram`, the unwind data the libraries are built with. Those steps read only inside the worker's stack, and a module is indexed after the worker resumes, the first time a walk meets it. `python tools/symbolize_samples.py .collapsed.txt --libs ` names each frame from the COFF symbols of the module that ran, or from its export table when it has none, and leaves a module unsymbolized when its size or timestamp differs from the `# module` line; `--since ` keeps only the samples taken after that copy, `--out` writes the named stacks and `--demangle` uses `c++filt`. `APS5_SAMPLE_COMMITTER=` samples queue 0's draw committer the same way and writes the same two files. The committer records the draws the worker hands over (`APS5_PIPELINED_DRAWS`), so when the worker's stacks wait in `DrawPipeline::Enqueue`, its stacks show what bounds the frame. It starts with the first pipelined draw; with `APS5_PIPELINED_DRAWS=0` nothing is sampled. -`APS5_SAMPLE_THREADS=` samples every thread of the process every 2 ms and writes the 40 hottest addresses of each busy thread to ``, 12 frames deep and through `.pdata` only. The samplers exist only on Windows; the addresses are symbolized offline, for example with `nm` on the `.prx`. +`APS5_SAMPLE_THREADS=` samples every thread of the process every 2 ms and writes the 40 hottest addresses of each busy thread to ``, 12 frames deep and through `.pdata` only. The samplers exist only on Windows; the addresses are symbolized offline with `tools/symbolize_samples.py`. ## Performance trace diff --git a/tools/symbolize_samples.py b/tools/symbolize_samples.py new file mode 100644 index 0000000000..e0254f0fa4 --- /dev/null +++ b/tools/symbolize_samples.py @@ -0,0 +1,260 @@ +import argparse +import bisect +import collections +import shutil +import struct +import subprocess +import sys +from pathlib import Path + +DESCRIPTION = """Symbolize the output of the Windows samplers (APS5_SAMPLE_WORKER, APS5_SAMPLE_COMMITTER and +APS5_SAMPLE_THREADS) against the modules that ran. Each sampled frame is written as module+0xRVA; this reads the +COFF symbol table of each module found in --libs (the MinGW-built .prx and .dll files keep one), or its export +table when it has none, and names every frame module!function. + +The '# module name size timestamp' header lines the samplers write are compared with the SizeOfImage and +TimeDateStamp of the file that is found; a module whose file does not match is left unsymbolized, since its +addresses would name the wrong functions. + +The sampler rewrites its files every 20 s with totals since start-up. --since takes an earlier copy of the same +file and subtracts it, which keeps only the samples taken between the two copies (a steady-state window). + +Input: a collapsed-stack file (.collapsed.txt, 'frame;...;frame count' lines) or the address file +('module offset self inclusive' lines). Output: the hottest functions by self and by inclusive samples, and with +--out a symbolized collapsed-stack file for speedscope.app or flamegraph.pl.""" + +IMAGE_SCN_CNT_CODE = 0x20 +IMAGE_SYM_CLASS_EXTERNAL = 2 +IMAGE_SYM_CLASS_STATIC = 3 + + +class PeModule: + def __init__(self, path): + data = Path(path).read_bytes() + if data[:2] != b"MZ": + raise ValueError(f"{path}: not a PE file") + pe = struct.unpack_from("> 4) != 2 and value == 0: + continue + if name[:4] == b"\0\0\0\0": + at = strings + struct.unpack_from(" 0}) + + +def summarize(symbolizer, stacks, addresses, top): + self_counts = collections.Counter() + inclusive = collections.Counter() + total = 0 + if stacks: + for frames, count in stacks.items(): + names = [symbolizer.frame(frame) for frame in frames.split(";")] + total += count + self_counts[names[-1]] += count + for name in set(names): + inclusive[name] += count + else: + for (module, offset), count in addresses.items(): + total += count + self_counts[symbolizer.frame(f"{module}+{offset}")] += count + lines = [f"{total} samples"] + for title, counter in (("self", self_counts), ("inclusive", inclusive)): + if not counter: + continue + lines.append(f"hottest by {title}:") + for name, count in counter.most_common(top): + lines.append(f" {count:8d} {100.0 * count / total if total else 0.0:5.1f}% {name}") + return lines + + +def main(argv=None): + parser = argparse.ArgumentParser(description=DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("samples", help="collapsed-stack file or address file written by a Windows sampler") + parser.add_argument("--libs", action="append", required=True, help="directory holding the modules that ran (repeatable)") + parser.add_argument("--since", help="an earlier copy of the same file, subtracted to keep the samples taken after it") + parser.add_argument("--out", help="write the symbolized collapsed stacks to this file") + parser.add_argument("--top", type=int, default=30, help="functions listed per table (default 30)") + parser.add_argument("--demangle", action="store_true", help="demangle C++ names with c++filt when it is on PATH") + args = parser.parse_args(argv) + headers, stacks, addresses = load(args.samples) + if args.since: + earlier_headers, earlier_stacks, earlier_addresses = load(args.since) + stacks = subtract(stacks, earlier_stacks) + addresses = subtract(addresses, earlier_addresses) + headers = {**earlier_headers, **headers} + symbolizer = Symbolizer(args.libs, headers, args.demangle, warn=lambda text: print("warning: " + text, file=sys.stderr)) + for line in summarize(symbolizer, stacks, addresses, args.top): + print(line) + if args.out: + if not stacks: + parser.error("--out needs a collapsed-stack file") + with open(args.out, "w", encoding="utf-8") as output: + for frames, count in stacks.items(): + output.write(";".join(symbolizer.frame(frame) for frame in frames.split(";")) + f" {count}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_symbolize_samples.py b/tools/tests/test_symbolize_samples.py new file mode 100644 index 0000000000..c5c1c15a29 --- /dev/null +++ b/tools/tests/test_symbolize_samples.py @@ -0,0 +1,151 @@ +import contextlib +import io +import struct +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import symbolize_samples + +TIMESTAMP = 0x6AD92AC4 +SIZE_OF_IMAGE = 0x3000 + + +def symbol(name, value, section, kind, storage, aux=0): + return struct.pack("<8sIhHBB", name, value, section, kind, storage, aux) + + +def build_pe(path, with_symbols=True, with_exports=False): + image = bytearray(0xC00) + image[0:2] = b"MZ" + struct.pack_into(" Date: Thu, 22 Oct 2026 00:36:07 -0400 Subject: [PATCH 2/2] fix(tools): bound symbol lookups to their section, keep inclusive counts and refuse unrecorded modules Review of #9572 found three gaps: - PeModule.name() checked that an RVA was in some code section, then bisected one list of every symbol, so an address in a later code section before that section's first symbol took the last symbol of an earlier section. Each code section now keeps its own sorted list. - Address files keep both counters ('module offset self inclusive'): the inclusive count is kept and subtracted by --since, and the summary lists functions by inclusive samples as well, summed over each function's addresses (an upper bound when a function is on one stack more than once). The total comes from the '# N samples' line or from the 'thread samples ' headings of APS5_SAMPLE_THREADS, which the loader previously misread as a stack line; --thread keeps one thread. - The address writers of both samplers wrote no '# module' records, so those files skipped the size and timestamp check. Both now write them (through the helper the collapsed-stack writer uses), and the tool leaves a module unsymbolized when the samples have no record for it, unless --unverified is given. Five new cases cover a second code section, inclusive totals, --since on an address file, thread headings with --thread, and the missing record with and without --unverified. Co-Authored-By: Claude Opus 5.5 --- .../Execution/src/WorkerSampler.cpp | 20 +++- docs/dev/BUILD.md | 4 +- tools/symbolize_samples.py | 110 ++++++++++++------ tools/tests/test_symbolize_samples.py | 73 ++++++++++-- 4 files changed, 154 insertions(+), 53 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/WorkerSampler.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/WorkerSampler.cpp index 7bbca06017..82a909461c 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/WorkerSampler.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/WorkerSampler.cpp @@ -162,18 +162,29 @@ struct Sampler { return frame; } + static void writeModules(std::FILE* file, const std::map& loaded) { + for (const auto& [base, name] : loaded) { + const auto* dos = reinterpret_cast(base); + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + std::fprintf(file, "# module %s 0x%lx 0x%lx\n", name.c_str(), static_cast(nt->OptionalHeader.SizeOfImage), static_cast(nt->FileHeader.TimeDateStamp)); + } + } + void write() { std::lock_guard lock(mutex); std::FILE* file = std::fopen(path.c_str(), "w"); if (file == nullptr) return; std::fprintf(file, "# %llu samples\n", static_cast(samples)); + std::map loaded; for (const auto& [address, count] : counts) { HMODULE module = nullptr; char name[MAX_PATH] = "?"; if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(address), &module)) GetModuleFileNameA(module, name, sizeof(name)); const char* base = std::strrchr(name, '\\'); + if (module != nullptr) loaded.emplace(reinterpret_cast(module), base ? base + 1 : name); std::fprintf(file, "%s 0x%llx %llu %llu\n", base ? base + 1 : name, static_cast(address - reinterpret_cast(module)), static_cast(count.first), static_cast(count.second)); } + writeModules(file, loaded); std::fclose(file); if (keepStacks) writeStacks(); } @@ -194,11 +205,7 @@ struct Sampler { const double tickUs = 1e6 / static_cast(frequency.QuadPart); std::fprintf(file, "# samples=%llu depth=%d truncated=%llu suspended_us_mean=%.1f suspended_us_max=%.1f\n", static_cast(samples), depth, static_cast(stacks.Truncated()), samples != 0 ? static_cast(suspendedTicks) * tickUs / static_cast(samples) : 0.0, static_cast(longestSuspendedTicks) * tickUs); - for (const auto& [base, name] : loaded) { - const auto* dos = reinterpret_cast(base); - const auto* nt = reinterpret_cast(base + dos->e_lfanew); - std::fprintf(file, "# module %s 0x%lx 0x%lx\n", name.c_str(), static_cast(nt->OptionalHeader.SizeOfImage), static_cast(nt->FileHeader.TimeDateStamp)); - } + writeModules(file, loaded); const auto text = stacks.Format([&](std::uint64_t address) { return names.at(address); }); const bool written = std::fwrite(text.data(), 1, text.size(), file) == text.size(); if (std::fclose(file) != 0 || !written) return; @@ -245,6 +252,7 @@ struct ProcessSampler { std::FILE* file = std::fopen(path.c_str(), "w"); if (file == nullptr) return; std::fprintf(file, "# %llu rounds\n", static_cast(rounds)); + std::map loaded; for (auto& [id, thread] : threads) { std::lock_guard lock(thread.mutex); std::vector>> hot(thread.counts.begin(), thread.counts.end()); @@ -259,9 +267,11 @@ struct ProcessSampler { char name[MAX_PATH] = "?"; if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(address), &module)) GetModuleFileNameA(module, name, sizeof(name)); const char* base = std::strrchr(name, '\\'); + if (module != nullptr) loaded.emplace(reinterpret_cast(module), base ? base + 1 : name); std::fprintf(file, " %s 0x%llx %llu %llu\n", base ? base + 1 : name, static_cast(address - reinterpret_cast(module)), static_cast(hot[i].second.first), static_cast(hot[i].second.second)); } } + Sampler::writeModules(file, loaded); std::fclose(file); } }; diff --git a/docs/dev/BUILD.md b/docs/dev/BUILD.md index 61cb860139..63390e032d 100644 --- a/docs/dev/BUILD.md +++ b/docs/dev/BUILD.md @@ -196,11 +196,11 @@ On a device that supports `VK_EXT_device_fault`, the first `VK_ERROR_DEVICE_LOST ## Worker sampler -Set `APS5_SAMPLE_WORKER=` to sample queue worker 0 every millisecond (in practice every timer tick). Every 20 s and at exit it writes ``, one `module offset self inclusive` line per sampled address, and `.collapsed.txt`, the worker's whole stacks in the collapsed format that speedscope.app and flamegraph.pl read: one `frame;...;frame count` line per distinct stack, root first, each frame as `module+0xRVA`. A walk that ends before the thread's root starts with `[truncated]`. The `#` lines give the samples, the walk depth (64 frames), the truncated walks, the mean and longest time the worker was suspended, and the size and PE timestamp of each module, to check the binary a file is symbolized against. A frame with `.pdata` is stepped by `RtlVirtualUnwind`; any other frame by its DWARF record in `.ehfram`, the unwind data the libraries are built with. Those steps read only inside the worker's stack, and a module is indexed after the worker resumes, the first time a walk meets it. `python tools/symbolize_samples.py .collapsed.txt --libs ` names each frame from the COFF symbols of the module that ran, or from its export table when it has none, and leaves a module unsymbolized when its size or timestamp differs from the `# module` line; `--since ` keeps only the samples taken after that copy, `--out` writes the named stacks and `--demangle` uses `c++filt`. +Set `APS5_SAMPLE_WORKER=` to sample queue worker 0 every millisecond (in practice every timer tick). Every 20 s and at exit it writes ``, one `module offset self inclusive` line per sampled address, and `.collapsed.txt`, the worker's whole stacks in the collapsed format that speedscope.app and flamegraph.pl read: one `frame;...;frame count` line per distinct stack, root first, each frame as `module+0xRVA`. A walk that ends before the thread's root starts with `[truncated]`. The `#` lines give the samples, the walk depth (64 frames), the truncated walks, the mean and longest time the worker was suspended, and the size and PE timestamp of each module, to check the binary a file is symbolized against. A frame with `.pdata` is stepped by `RtlVirtualUnwind`; any other frame by its DWARF record in `.ehfram`, the unwind data the libraries are built with. Those steps read only inside the worker's stack, and a module is indexed after the worker resumes, the first time a walk meets it. `python tools/symbolize_samples.py .collapsed.txt --libs ` names each frame from the COFF symbols of the module that ran, or from its export table when it has none, and leaves a module unsymbolized when its size or timestamp differs from its `# module` line or when it has none (`--unverified` symbolizes it anyway); `--since ` keeps only the samples taken after that copy, `--out` writes the named stacks and `--demangle` uses `c++filt`. Given ``, it lists functions by self and by inclusive samples, where a function's inclusive count is the sum of its addresses' and overcounts a function that is on one stack more than once. `APS5_SAMPLE_COMMITTER=` samples queue 0's draw committer the same way and writes the same two files. The committer records the draws the worker hands over (`APS5_PIPELINED_DRAWS`), so when the worker's stacks wait in `DrawPipeline::Enqueue`, its stacks show what bounds the frame. It starts with the first pipelined draw; with `APS5_PIPELINED_DRAWS=0` nothing is sampled. -`APS5_SAMPLE_THREADS=` samples every thread of the process every 2 ms and writes the 40 hottest addresses of each busy thread to ``, 12 frames deep and through `.pdata` only. The samplers exist only on Windows; the addresses are symbolized offline with `tools/symbolize_samples.py`. +`APS5_SAMPLE_THREADS=` samples every thread of the process every 2 ms and writes the 40 hottest addresses of each busy thread to ``, 12 frames deep and through `.pdata` only, under a `thread samples ` line per thread and with the same `# module` lines. The samplers exist only on Windows; the addresses are symbolized offline with `tools/symbolize_samples.py`, which adds up all threads or, with `--thread `, keeps one. ## Performance trace diff --git a/tools/symbolize_samples.py b/tools/symbolize_samples.py index e0254f0fa4..f61b586e53 100644 --- a/tools/symbolize_samples.py +++ b/tools/symbolize_samples.py @@ -19,9 +19,14 @@ addresses would name the wrong functions. The sampler rewrites its files every 20 s with totals since start-up. --since takes an earlier copy of the same file and subtracts it, which keeps only the samples taken between the two copies (a steady-state window). -Input: a collapsed-stack file (.collapsed.txt, 'frame;...;frame count' lines) or the address file -('module offset self inclusive' lines). Output: the hottest functions by self and by inclusive samples, and with ---out a symbolized collapsed-stack file for speedscope.app or flamegraph.pl.""" +A module with no such record is also left unsymbolized, since nothing shows which build it was; --unverified +symbolizes it anyway against the file found. + +Input: a collapsed-stack file (.collapsed.txt, 'frame;...;frame count' lines) or an address file +('module offset self inclusive' lines, under 'thread samples ' headings for APS5_SAMPLE_THREADS; --thread +keeps one thread). Output: the hottest functions by self and by inclusive samples, and with --out a symbolized +collapsed-stack file for speedscope.app or flamegraph.pl. For an address file, a function's inclusive count is the +sum of its addresses' counts, which overcounts the samples in which it is on the stack more than once.""" IMAGE_SCN_CNT_CODE = 0x20 IMAGE_SYM_CLASS_EXTERNAL = 2 @@ -54,8 +59,12 @@ class PeModule: if self.exported and export_rva: symbols = self.export_symbols(data, export_rva, export_size) symbols.sort() - self.code = [(start, start + size) for start, size, _, characteristics in self.sections if characteristics & IMAGE_SCN_CNT_CODE] - self.addresses = [address for address, _ in symbols] + self.code = [] + for start, size, _, characteristics in self.sections: + if not characteristics & IMAGE_SCN_CNT_CODE: + continue + inside = [(address, name) for address, name in symbols if start <= address < start + size] + self.code.append((start, start + size, [address for address, _ in inside], [name for _, name in inside])) self.names = [name for _, name in symbols] def offset(self, rva): @@ -106,16 +115,15 @@ class PeModule: return result def name(self, rva): - if not any(start <= rva < end for start, end in self.code): - return None - index = bisect.bisect_right(self.addresses, rva) - 1 - if index < 0: - return None - return ("~" if self.exported else "") + self.names[index] + for start, end, addresses, names in self.code: + if start <= rva < end: + index = bisect.bisect_right(addresses, rva) - 1 + return None if index < 0 else ("~" if self.exported else "") + names[index] + return None class Symbolizer: - def __init__(self, libs, headers, demangle=False, warn=print): + def __init__(self, libs, headers, demangle=False, warn=print, unverified=False): self.files = {} for directory in libs: for path in Path(directory).iterdir(): @@ -126,6 +134,7 @@ class Symbolizer: self.demangle = demangle and shutil.which("c++filt") is not None self.demangled = {} self.warn = warn + self.unverified = unverified def module(self, name): if name in self.modules: @@ -137,6 +146,9 @@ class Symbolizer: module = PeModule(path) except (ValueError, struct.error) as error: self.warn(f"{name}: {error}") + if module is not None and name not in self.headers and not self.unverified: + self.warn(f"{name}: the samples have no '# module' record for it, so {path} may be another build; its frames stay unsymbolized (--unverified symbolizes them anyway)") + module = None if module is not None and name in self.headers and self.headers[name] != (module.size, module.timestamp): size, timestamp = self.headers[name] self.warn(f"{name}: the sampled module had size {size:#x} and timestamp {timestamp:#x}, but {path} has {module.size:#x} and {module.timestamp:#x}; its frames stay unsymbolized") @@ -179,23 +191,42 @@ def read_headers(lines): return headers -def load(path): +class Samples: + def __init__(self): + self.headers = {} + self.stacks = collections.Counter() + self.self_counts = collections.Counter() + self.inclusive = collections.Counter() + self.total = collections.Counter() + + +def load(path, thread=None): lines = Path(path).read_text(encoding="utf-8-sig").splitlines() - headers = read_headers(lines) - stacks = collections.Counter() - addresses = collections.Counter() + samples = Samples() + samples.headers = read_headers(lines) + current = None for number, line in enumerate(lines, 1): + parts = line.split() + if len(parts) == 3 and parts[0] == "#" and parts[2] == "samples" and parts[1].isdigit(): + samples.total["samples"] += int(parts[1]) + continue if line.startswith("#") or not line.strip(): continue - parts = line.split() + if len(parts) == 4 and parts[0] == "thread" and parts[2] == "samples" and parts[1].isdigit() and parts[3].isdigit(): + current = parts[1] + if thread is None or current == thread: + samples.total["threads"] += int(parts[3]) + continue if len(parts) == 4 and parts[1].startswith("0x") and parts[2].isdigit() and parts[3].isdigit(): - addresses[(parts[0], parts[1])] += int(parts[2]) + if thread is None or current == thread: + samples.self_counts[(parts[0], parts[1])] += int(parts[2]) + samples.inclusive[(parts[0], parts[1])] += int(parts[3]) continue frames, _, count = line.rpartition(" ") if not frames or not count.isdigit(): - raise ValueError(f"{path}:{number}: expected 'frame;...;frame count' or 'module offset self inclusive'") - stacks[frames] += int(count) - return headers, stacks, addresses + raise ValueError(f"{path}:{number}: expected 'frame;...;frame count', 'module offset self inclusive' or 'thread samples '") + samples.stacks[frames] += int(count) + return samples def subtract(later, earlier): @@ -204,21 +235,31 @@ def subtract(later, earlier): return collections.Counter({key: count for key, count in result.items() if count > 0}) -def summarize(symbolizer, stacks, addresses, top): +def window(later, earlier): + result = Samples() + result.headers = {**earlier.headers, **later.headers} + for name in ("stacks", "self_counts", "inclusive", "total"): + setattr(result, name, subtract(getattr(later, name), getattr(earlier, name))) + return result + + +def summarize(symbolizer, samples, top): self_counts = collections.Counter() inclusive = collections.Counter() total = 0 - if stacks: - for frames, count in stacks.items(): + if samples.stacks: + for frames, count in samples.stacks.items(): names = [symbolizer.frame(frame) for frame in frames.split(";")] total += count self_counts[names[-1]] += count for name in set(names): inclusive[name] += count else: - for (module, offset), count in addresses.items(): - total += count + for (module, offset), count in samples.self_counts.items(): self_counts[symbolizer.frame(f"{module}+{offset}")] += count + for (module, offset), count in samples.inclusive.items(): + inclusive[symbolizer.frame(f"{module}+{offset}")] += count + total = samples.total["threads"] or samples.total["samples"] or sum(samples.self_counts.values()) lines = [f"{total} samples"] for title, counter in (("self", self_counts), ("inclusive", inclusive)): if not counter: @@ -237,21 +278,20 @@ def main(argv=None): parser.add_argument("--out", help="write the symbolized collapsed stacks to this file") parser.add_argument("--top", type=int, default=30, help="functions listed per table (default 30)") parser.add_argument("--demangle", action="store_true", help="demangle C++ names with c++filt when it is on PATH") + parser.add_argument("--thread", help="keep only this thread of an APS5_SAMPLE_THREADS file") + parser.add_argument("--unverified", action="store_true", help="also symbolize modules the samples have no '# module' record for") args = parser.parse_args(argv) - headers, stacks, addresses = load(args.samples) + samples = load(args.samples, args.thread) if args.since: - earlier_headers, earlier_stacks, earlier_addresses = load(args.since) - stacks = subtract(stacks, earlier_stacks) - addresses = subtract(addresses, earlier_addresses) - headers = {**earlier_headers, **headers} - symbolizer = Symbolizer(args.libs, headers, args.demangle, warn=lambda text: print("warning: " + text, file=sys.stderr)) - for line in summarize(symbolizer, stacks, addresses, args.top): + samples = window(samples, load(args.since, args.thread)) + symbolizer = Symbolizer(args.libs, samples.headers, args.demangle, warn=lambda text: print("warning: " + text, file=sys.stderr), unverified=args.unverified) + for line in summarize(symbolizer, samples, args.top): print(line) if args.out: - if not stacks: + if not samples.stacks: parser.error("--out needs a collapsed-stack file") with open(args.out, "w", encoding="utf-8") as output: - for frames, count in stacks.items(): + for frames, count in samples.stacks.items(): output.write(";".join(symbolizer.frame(frame) for frame in frames.split(";")) + f" {count}\n") return 0 diff --git a/tools/tests/test_symbolize_samples.py b/tools/tests/test_symbolize_samples.py index c5c1c15a29..5e000527dc 100644 --- a/tools/tests/test_symbolize_samples.py +++ b/tools/tests/test_symbolize_samples.py @@ -17,7 +17,7 @@ def symbol(name, value, section, kind, storage, aux=0): return struct.pack("<8sIhHBB", name, value, section, kind, storage, aux) -def build_pe(path, with_symbols=True, with_exports=False): +def build_pe(path, with_symbols=True, with_exports=False, second_code=False): image = bytearray(0xC00) image[0:2] = b"MZ" struct.pack_into("