diff options
| author | Stefan Boberg <[email protected]> | 2026-04-20 21:50:41 +0200 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-04-20 21:50:41 +0200 |
| commit | 2dfb5da16b97a6c12e01977af5b5188522178a4e (patch) | |
| tree | 428aa0aa8e6079c64438931e0fd4f828c613c94d /src/zen/trace/trace_analyze.cpp | |
| parent | Add CompactString utility type (#990) (diff) | |
| download | archived-zen-2dfb5da16b97a6c12e01977af5b5188522178a4e.tar.xz archived-zen-2dfb5da16b97a6c12e01977af5b5188522178a4e.zip | |
zen trace analysis support (#945)
Integrates the **tourist** trace analysis library and builds a full `zen trace` command suite for working with Unreal Engine `.utrace` files.
### Trace analysis library (`thirdparty/tourist/`)
- Adds the tourist library as a third-party dependency with three modules: **foundation** (platform primitives, memory, scheduling), **trace** (UE Trace protocol decoding), and **analysis** (event dispatching and analyzer framework).
- Cross-platform support for Windows, Linux, and macOS.
### `zen trace` CLI commands (`src/zen/cmds/`, `src/zen/trace/`)
- **`zen trace analyze`** — Summarize a `.utrace` file: session metadata, thread inventory, command line + build configuration, CPU profiling scopes, timing, event rates, log messages, and (with symbols) memory allocation metrics including live-allocs dumps, callstack-keyed aggregation, and allocation churn. Optional HTML output for memory reports.
- **`zen trace inspect`** — Dump the event schema (declared types, fields, sizes) from a trace file.
- **`zen trace trim`** — Extract a time-window from a trace into a new `.utrace` file.
- **`zen trace serve`** — Launch a local HTTP server hosting an interactive trace viewer; opens in the default browser.
### Symbolication (`src/zen/trace/symbol_resolver.*`, `thirdparty/raw_pdb/`)
- Pluggable resolver with multiple backends: `pdb` (in-tree raw_pdb), `dbghelp` (Windows), `llvm-symbolizer` (all platforms), `atos` (macOS). An `auto` backend picks the best available tool per platform.
- Microsoft Symbol Server support: downloads PDBs on demand using a redirect-aware HTTP client.
- Local PDB cache keyed by image GUID preserves symbols across binary recompilation.
- Callstack trimming heuristic strips UE internal noise from reports.
- Binary analysis cache (`.ucache_z`) avoids re-resolving the same trace.
### Interactive trace viewer (`src/zen/frontend/html/`, `src/zen/trace/trace_viewer_service.*`)
- Timeline: scope-level detail, horizontal zoom/pan, vertical scrolling, viewport-driven loading with pre-computed LOD for responsive navigation of large traces.
- Thread grouping (collapsible sidebar sections) synthesized from name suffixes, natural sort order, visual distinction between lane threads and OS threads.
- Bookmark and region annotations; region categories with per-category toggles; bookmark marker toggle in the toolbar.
- Filterable Logs tab showing captured `UE_LOG` output.
- Stats tab with per-scope aggregate statistics.
- Memory tab with interactive allocation analysis and an allocation size histogram.
- CsvProfiler event parsing and chart UI.
### Other in-branch supporting changes
- **Cross-platform browser launcher** (`browser_launcher.{h,cpp}`) used by `trace serve`.
- **`ReciprocalU64`** fast 64-bit integer division (zencore/intmath) for trace analyzers.
- **`parallelsort`** cross-platform parallel sort helper (zenutil).
- Frontend zip build rule so the viewer's HTML assets are bundled into `zen.exe`.
- `/Zo` flag for better optimized debug info on Windows release builds.
- `trace-tests.cpp` in the `zen-test` harness (harness itself landed on main via #985).
Diffstat (limited to 'src/zen/trace/trace_analyze.cpp')
| -rw-r--r-- | src/zen/trace/trace_analyze.cpp | 812 |
1 files changed, 812 insertions, 0 deletions
diff --git a/src/zen/trace/trace_analyze.cpp b/src/zen/trace/trace_analyze.cpp new file mode 100644 index 000000000..ff168cd9c --- /dev/null +++ b/src/zen/trace/trace_analyze.cpp @@ -0,0 +1,812 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "trace_analyze.h" + +#include "callstack_formatter.h" +#include "trace_cache.h" +#include "zen.h" + +#include <zencore/basicfile.h> +#include <zencore/fmtutils.h> +#include <zencore/iobuffer.h> +#include <zencore/logging.h> +#include <zencore/scopeguard.h> +#include <zencore/string.h> +#include <zencore/thread.h> +#include <zencore/workthreadpool.h> + +ZEN_THIRD_PARTY_INCLUDES_START +#include <EASTL/hash_map.h> +#include <EASTL/hash_set.h> +#include <EASTL/vector.h> +ZEN_THIRD_PARTY_INCLUDES_END + +#include <algorithm> + +namespace { + +using namespace zen::trace_detail; + +static void +AppendHtmlEscaped(zen::StringBuilderBase& Out, std::string_view Text) +{ + for (char Ch : Text) + { + switch (Ch) + { + case '&': + Out << "&"; + break; + case '<': + Out << "<"; + break; + case '>': + Out << ">"; + break; + case '"': + Out << """; + break; + case '\'': + Out << "'"; + break; + default: + Out.Append(Ch); + break; + } + } +} + +static CallstackFilterOptions +BuildCallstackFilterOptions(const AnalyzeOptions& Options) +{ + CallstackFilterOptions Result; + Result.EnableHeuristic = Options.EnableCallstackHeuristic; + Result.SkipPatterns = Options.CallstackSkipPatterns; + return Result; +} + +static std::string +BuildThreadSummary(const TraceModel& Model, const eastl::fixed_vector<uint32_t, 4, true>& ThreadIds) +{ + std::string Result; + for (uint32_t Tid : ThreadIds) + { + if (!Result.empty()) + { + Result += ", "; + } + auto TIt = std::find_if(Model.Threads.begin(), Model.Threads.end(), [Tid](const ThreadInfoEntry& T) { return T.ThreadId == Tid; }); + if (TIt != Model.Threads.end() && !TIt->Name.empty()) + { + Result += TIt->Name; + } + else + { + Result += fmt::format("tid:{}", Tid); + } + } + return Result; +} + +static void +AppendHtmlCallstack(zen::StringBuilderBase& Out, const AnalyzeOptions& Options, CallstackFormatter& Formatter, uint32_t CallstackId) +{ + const CallstackEntry* Entry = Formatter.FindCallstackEntry(CallstackId); + if (Entry == nullptr || Entry->Frames.empty()) + { + Out << "<div class=\"muted\">No callstack frames recorded.</div>"; + return; + } + + FilteredCallstackView Filtered = Formatter.BuildView(*Entry, BuildCallstackFilterOptions(Options)); + if (Filtered.HiddenPrefixCount > 0) + { + Out << "<div class=\"muted\">Skipped " << uint64_t(Filtered.HiddenPrefixCount) << " leading frame(s)"; + if (Filtered.IncludedThirdPartyBoundary) + { + Out << "; kept boundary third-party callsite"; + } + Out << ".</div>"; + } + + Out << "<ol class=\"frames\">"; + for (const FilteredCallstackFrame& Frame : Filtered.Frames) + { + Out << "<li>"; + AppendHtmlEscaped(Out, Frame.Display); + Out << "</li>"; + } + Out << "</ol>"; +} + +static std::string_view +FindHeapName(const TraceModel& Model, uint32_t HeapId) +{ + for (const HeapInfo& Heap : Model.Heaps) + { + if (Heap.Id == HeapId && !Heap.Name.empty()) + { + return Heap.Name; + } + } + return "unknown"; +} + +static bool +PassesChurnThreshold(const AnalyzeOptions& Options, const CallstackChurnStat& Stat) +{ + return Stat.MeanDistance <= double(Options.ChurnDistanceThreshold); +} + +static uint64_t +CountShownChurnSites(const TraceModel& Model, const AnalyzeOptions& Options, uint64_t Limit = 100) +{ + uint64_t Result = 0; + for (const CallstackChurnStat& Stat : Model.ChurnStats) + { + if (PassesChurnThreshold(Options, Stat) && Result < Limit) + { + ++Result; + } + } + return Result; +} + +class ConsoleAnalyzeWriter +{ +public: + ConsoleAnalyzeWriter(const TraceModel& InModel, + const AnalyzeOptions& InOptions, + const std::filesystem::path& InFilePath, + CallstackFormatter& InFrameFormatter) + : m_Model(InModel) + , m_Options(InOptions) + , m_FilePath(InFilePath) + , m_FrameFormatter(InFrameFormatter) + { + } + + void Write() const + { + AppendSession(); + AppendGeneralSummary(); + AppendEventTypes(); + AppendThreads(); + AppendChannels(); + AppendCpuScopeStats(); + AppendMemorySummary(); + AppendLiveAllocationCallstacks(); + AppendChurnCallstacks(); + } + +private: + void AppendSession() const + { + const SessionInfo& Session = m_Model.Session; + if (!Session.HasSession) + { + return; + } + + ZEN_CONSOLE("Session:"); + if (!Session.Platform.empty()) + { + ZEN_CONSOLE(" Platform: {}", Session.Platform); + } + if (!Session.AppName.empty()) + { + ZEN_CONSOLE(" App: {}", Session.AppName); + } + if (!Session.ProjectName.empty()) + { + ZEN_CONSOLE(" Project: {}", Session.ProjectName); + } + if (!Session.Branch.empty()) + { + ZEN_CONSOLE(" Branch: {}", Session.Branch); + } + if (!Session.BuildVersion.empty()) + { + ZEN_CONSOLE(" Build: {}", Session.BuildVersion); + } + if (Session.ConfigurationType != 0) + { + constexpr const char* kConfigNames[] = {"Unknown", "Debug", "DebugGame", "Development", "Shipping", "Test"}; + uint8_t Idx = Session.ConfigurationType; + const char* Name = (Idx < std::size(kConfigNames)) ? kConfigNames[Idx] : "Unknown"; + ZEN_CONSOLE(" Config: {}", Name); + } + if (Session.Changelist != 0) + { + ZEN_CONSOLE(" CL: {}", Session.Changelist); + } + if (!Session.CommandLine.empty()) + { + ZEN_CONSOLE(" Cmd: {}", Session.CommandLine); + } + ZEN_CONSOLE(""); + } + + void AppendGeneralSummary() const + { + uint64_t DurationUs = (m_Model.TraceEndUs > m_Model.TraceStartUs) ? (m_Model.TraceEndUs - m_Model.TraceStartUs) : 0; + + ZEN_CONSOLE("Trace: {}", m_FilePath); + ZEN_CONSOLE("Size: {}", zen::NiceBytes(m_Model.FileSize)); + ZEN_CONSOLE("Events: {}", zen::ThousandsNum(m_Model.TotalEvents)); + ZEN_CONSOLE("Duration: {}", zen::NiceTimeSpanMs((DurationUs + 500) / 1000)); + ZEN_CONSOLE("Threads: {}", m_Model.Threads.size()); + ZEN_CONSOLE("Modules: {}", m_Model.Modules.size()); + ZEN_CONSOLE("Parsed: {}", zen::NiceTimeSpanMs(m_Model.ParseTimeMs)); + if (m_Model.ParseTimeMs > 0) + { + ZEN_CONSOLE("Rate: {} events/s", zen::ThousandsNum(m_Model.TotalEvents * 1000 / m_Model.ParseTimeMs)); + } + ZEN_CONSOLE(""); + } + + void AppendEventTypes() const + { + if (m_Model.EventTypeCounts.empty()) + { + return; + } + + size_t MaxNameLen = 10; + for (const auto& Entry : m_Model.EventTypeCounts) + { + MaxNameLen = std::max(MaxNameLen, Entry.Name.size()); + } + + ZEN_CONSOLE("{:<{}} {:>14}", "Event Type", MaxNameLen, "Count"); + ZEN_CONSOLE("{:-<{}}", "", MaxNameLen + 16); + for (const auto& Entry : m_Model.EventTypeCounts) + { + ZEN_CONSOLE("{:<{}} {:>14}", Entry.Name, MaxNameLen, zen::ThousandsNum(Entry.Count)); + } + ZEN_CONSOLE(""); + } + + void AppendThreads() const + { + if (m_Model.Threads.empty()) + { + return; + } + + ZEN_CONSOLE("Threads:"); + for (const ThreadInfoEntry& Thread : m_Model.Threads) + { + auto TimelineIt = std::find_if(m_Model.Timelines.begin(), + m_Model.Timelines.end(), + [Tid = Thread.ThreadId](const ThreadTimeline& T) { return T.ThreadId == Tid; }); + uint64_t ScopeCount = (TimelineIt != m_Model.Timelines.end()) ? TimelineIt->Scopes.size() : 0; + + if (!Thread.Name.empty()) + { + ZEN_CONSOLE(" {:>5} {:<32} {} scopes", Thread.ThreadId, Thread.Name, zen::ThousandsNum(ScopeCount)); + } + } + ZEN_CONSOLE(""); + } + + void AppendChannels() const + { + if (m_Model.Channels.empty()) + { + return; + } + + ZEN_CONSOLE("Channels:"); + for (const ChannelInfo& Channel : m_Model.Channels) + { + ZEN_CONSOLE(" {:<32} {}", Channel.Name, Channel.Enabled ? "enabled" : "disabled"); + } + ZEN_CONSOLE(""); + } + + void AppendCpuScopeStats() const + { + if (m_Model.ScopeStats.empty()) + { + return; + } + + ZEN_CONSOLE("CPU Profiling Scopes:"); + ZEN_CONSOLE(""); + ZEN_CONSOLE("{:<48} {:>8} {:>9} {:>9} {:>9} {:>9}", "Scope", "Count", "Min(ms)", "Mean(ms)", "Max(ms)", "SD(ms)"); + ZEN_CONSOLE("{:-<{}}", "", 48 + 8 + 9 + 9 + 9 + 9 + 5); + + constexpr double UsToMs = 1.0 / 1000.0; + for (const CpuScopeStat& Stat : m_Model.ScopeStats) + { + if (Stat.MaxUs < 500) + { + continue; + } + + ZEN_CONSOLE("{:<48.48} {:>8} {:>9.3f} {:>9.3f} {:>9.3f} {:>9.3f}", + Stat.Name, + zen::ThousandsNum(Stat.Count), + double(Stat.MinUs) * UsToMs, + Stat.MeanUs * UsToMs, + double(Stat.MaxUs) * UsToMs, + Stat.StdDevUs * UsToMs); + } + ZEN_CONSOLE(""); + } + + void AppendMemorySummary() const + { + const AllocationSummary& AllocSummary = m_Model.AllocSummary; + if (!AllocSummary.HasMemoryData) + { + return; + } + + ZEN_CONSOLE("Memory Allocations:"); + ZEN_CONSOLE(""); + ZEN_CONSOLE(" Allocs: {}", zen::ThousandsNum(AllocSummary.TotalAllocs)); + ZEN_CONSOLE(" Frees: {}", zen::ThousandsNum(AllocSummary.TotalFrees)); + ZEN_CONSOLE(" Reallocs: {} alloc / {} free", + zen::ThousandsNum(AllocSummary.TotalReallocAllocs), + zen::ThousandsNum(AllocSummary.TotalReallocFrees)); + ZEN_CONSOLE(" Peak: {}", zen::NiceBytes(uint64_t(AllocSummary.PeakBytes))); + ZEN_CONSOLE(" End: {}", zen::NiceBytes(uint64_t(AllocSummary.EndBytes))); + ZEN_CONSOLE(" Live allocs: {}", zen::ThousandsNum(AllocSummary.LiveAllocations)); + + if (!m_Model.HeapStats.empty()) + { + ZEN_CONSOLE(""); + ZEN_CONSOLE(" {:<20} {:>14} {:>14} {:>10} {:>10}", "Heap", "Current", "Peak", "Allocs", "Frees"); + ZEN_CONSOLE(" {:-<{}}", "", 20 + 14 + 14 + 10 + 10 + 4); + + for (const HeapStat& Stat : m_Model.HeapStats) + { + std::string_view HeapName = FindHeapName(m_Model, Stat.HeapId); + + ZEN_CONSOLE(" {:<20.20} {:>14} {:>14} {:>10} {:>10}", + HeapName, + zen::NiceBytes(uint64_t(Stat.CurrentBytes)), + zen::NiceBytes(uint64_t(Stat.PeakBytes)), + zen::ThousandsNum(Stat.AllocCount), + zen::ThousandsNum(Stat.FreeCount)); + } + } + ZEN_CONSOLE(""); + } + + void PrintCallstack(uint32_t CallstackId) const + { + const CallstackEntry* Entry = m_FrameFormatter.FindCallstackEntry(CallstackId); + if (Entry == nullptr) + { + return; + } + + FilteredCallstackView Filtered = m_FrameFormatter.BuildView(*Entry, BuildCallstackFilterOptions(m_Options)); + if (Filtered.HiddenPrefixCount > 0) + { + if (Filtered.IncludedThirdPartyBoundary) + { + ZEN_CONSOLE(" [skipped {} leading frame(s); kept boundary third-party callsite]", Filtered.HiddenPrefixCount); + } + else + { + ZEN_CONSOLE(" [skipped {} leading frame(s)]", Filtered.HiddenPrefixCount); + } + } + for (const FilteredCallstackFrame& Frame : Filtered.Frames) + { + ZEN_CONSOLE(" {}", Frame.Display); + } + } + + void AppendLiveAllocationCallstacks() const + { + if (m_Options.LiveAllocsLimit <= 0 || m_Model.CallstackStats.empty()) + { + return; + } + + size_t Count = std::min(size_t(m_Options.LiveAllocsLimit), m_Model.CallstackStats.size()); + ZEN_CONSOLE("Live Allocation Callstacks (top {} by bytes):", Count); + ZEN_CONSOLE(""); + + for (size_t I = 0; I < Count; ++I) + { + const CallstackAllocStat& Stat = m_Model.CallstackStats[I]; + std::string ThreadInfo = BuildThreadSummary(m_Model, Stat.ThreadIds); + ZEN_CONSOLE(" #{} {} in {} allocation(s) [callstack {}, {}]", + I + 1, + zen::NiceBytes(uint64_t(Stat.LiveBytes)), + zen::ThousandsNum(Stat.LiveCount), + Stat.CallstackId, + ThreadInfo); + PrintCallstack(Stat.CallstackId); + ZEN_CONSOLE(""); + } + } + + void AppendChurnCallstacks() const + { + if (m_Options.ChurnLimit <= 0 || m_Model.ChurnStats.empty()) + { + return; + } + + size_t Emitted = 0; + size_t Limit = size_t(m_Options.ChurnLimit); + ZEN_CONSOLE("Allocation Churn (top {}, event distance <= {}):", Limit, m_Options.ChurnDistanceThreshold); + ZEN_CONSOLE(""); + + for (const CallstackChurnStat& Stat : m_Model.ChurnStats) + { + if (Emitted >= Limit) + { + break; + } + if (!PassesChurnThreshold(m_Options, Stat)) + { + continue; + } + + ZEN_CONSOLE(" #{} {} short-lived allocs ({} total), {} churned, avg distance {:.0f} events [callstack {}]", + Emitted + 1, + zen::ThousandsNum(Stat.ChurnAllocs), + zen::ThousandsNum(Stat.TotalAllocs), + zen::NiceBytes(Stat.ChurnBytes), + Stat.MeanDistance, + Stat.CallstackId); + PrintCallstack(Stat.CallstackId); + ZEN_CONSOLE(""); + ++Emitted; + } + } + + const TraceModel& m_Model; + const AnalyzeOptions& m_Options; + const std::filesystem::path& m_FilePath; + CallstackFormatter& m_FrameFormatter; +}; + +class HtmlReportWriter +{ +public: + HtmlReportWriter(const TraceModel& InModel, + const AnalyzeOptions& InOptions, + const std::filesystem::path& InFilePath, + CallstackFormatter& InFrameFormatter) + : m_Model(InModel) + , m_Options(InOptions) + , m_FilePath(InFilePath) + , m_FrameFormatter(InFrameFormatter) + { + } + + void Write(const std::filesystem::path& OutputPath) + { + AppendDocument(); + zen::WriteFile(OutputPath, zen::IoBuffer(zen::IoBuffer::Wrap, m_Html.Data(), m_Html.Size())); + } + +private: + void AppendDocument() + { + m_Html << "<!doctype html><html><head><meta charset=\"utf-8\"><title>zen trace analyze report</title>"; + AppendStyles(); + m_Html << "</head><body>"; + AppendHeader(); + AppendSummaryCards(); + AppendLeaksSection(); + AppendChurnSection(); + m_Html << "</body></html>"; + } + + void AppendStyles() + { + m_Html << "<style>body{font:14px/1.45 system-ui,-apple-system,Segoe " + "UI,Roboto,sans-serif;margin:24px;color:#1f2937;background:#f8fafc;}"; + m_Html << "h1,h2{margin:0 0 12px;}h1{font-size:28px;}h2{font-size:20px;margin-top:28px;}"; + m_Html << ".meta,.card,details{background:#fff;border:1px solid #dbe2ea;border-radius:10px;box-shadow:0 1px 2px rgba(0,0,0,.04);}"; + m_Html << ".meta,.card{padding:16px;margin-bottom:16px;}.report-table{width:100%;border-collapse:collapse;background:#fff;border:" + "1px solid #dbe2ea;border-radius:10px;overflow:hidden;table-layout:fixed;}"; + m_Html << "th,td{padding:10px 12px;border-bottom:1px solid " + "#e5e7eb;vertical-align:top;text-align:left;}th{background:#f1f5f9;font-weight:600;}tr:last-child td{border-bottom:0;}"; + m_Html + << "th.num,td.num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums;}.muted{color:#64748b;}.pill{display:" + "inline-block;padding:2px 8px;border-radius:999px;background:#e2e8f0;color:#334155;font-size:12px;margin-right:6px;}"; + m_Html << ".col-rank{width:56px;}.col-live-bytes,.col-alloc-count,.col-short-lived,.col-churn-bytes,.col-total-allocs,.col-avg-" + "distance{width:132px;}.col-threads{width:260px;}.col-callstack{width:auto;}"; + m_Html << ".grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:18px;}details{" + "display:block;width:100%;box-sizing:border-box;padding:12px " + "14px;margin:0;}summary{cursor:pointer;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}"; + m_Html << ".callstack-cell{width:100%;}.frames{margin:10px 0 0 " + "20px;padding:0;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;}.frames li{margin:4px " + "0;overflow-wrap:anywhere;word-break:break-word;}code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;}a{" + "color:#2563eb;text-decoration:none;}a:hover{text-decoration:underline;}</style>"; + } + + void AppendHeader() + { + m_Html << "<h1>zen trace analyze memory report</h1>"; + m_Html << "<div class=\"meta\"><div><span class=\"pill\">offline HTML</span><span class=\"pill\">top 100 churn " + "sites</span></div><p><strong>Trace:</strong> <code>"; + AppendHtmlEscaped(m_Html, m_FilePath.string()); + m_Html << "</code></p>"; + if (m_Model.Session.HasSession && !m_Model.Session.AppName.empty()) + { + m_Html << "<p><strong>App:</strong> "; + AppendHtmlEscaped(m_Html, m_Model.Session.AppName); + m_Html << "</p>"; + } + m_Html << "<p class=\"muted\">Generated by zen trace analyze. Churn threshold: "; + AppendHtmlEscaped(m_Html, fmt::format("{} events", m_Options.ChurnDistanceThreshold)); + m_Html << "</p></div>"; + } + + void AppendSummaryCards() + { + uint64_t DurationUs = (m_Model.TraceEndUs > m_Model.TraceStartUs) ? (m_Model.TraceEndUs - m_Model.TraceStartUs) : 0; + m_Html << "<div class=\"grid\">"; + m_Html << "<div class=\"card\"><div class=\"muted\">Trace size</div><div><strong>" << zen::NiceBytes(m_Model.FileSize) + << "</strong></div></div>"; + m_Html << "<div class=\"card\"><div class=\"muted\">Duration</div><div><strong>" << zen::NiceTimeSpanMs((DurationUs + 500) / 1000) + << "</strong></div></div>"; + m_Html << "<div class=\"card\"><div class=\"muted\">Peak memory</div><div><strong>" + << zen::NiceBytes(uint64_t(m_Model.AllocSummary.PeakBytes)) << "</strong></div></div>"; + m_Html << "<div class=\"card\"><div class=\"muted\">End memory</div><div><strong>" + << zen::NiceBytes(uint64_t(m_Model.AllocSummary.EndBytes)) << "</strong></div></div>"; + m_Html << "<div class=\"card\"><div class=\"muted\">Live allocations</div><div><strong>" + << zen::ThousandsNum(m_Model.AllocSummary.LiveAllocations) << "</strong></div></div>"; + m_Html << "<div class=\"card\"><div class=\"muted\">Leak callstacks</div><div><strong>" + << zen::ThousandsNum(m_Model.CallstackStats.size()) << "</strong></div></div>"; + m_Html << "<div class=\"card\"><div class=\"muted\">Churn sites shown</div><div><strong>" + << zen::ThousandsNum(::CountShownChurnSites(m_Model, m_Options)) << "</strong></div></div>"; + m_Html << "</div>"; + } + + void AppendLeaksSection() + { + m_Html << "<h2 id=\"leaks\">Memory leaks (all live-allocation callstacks)</h2>"; + if (m_Model.CallstackStats.empty()) + { + m_Html << "<div class=\"card muted\">No live allocation callstacks were present at the end of the trace.</div>"; + return; + } + + m_Html << "<table class=\"report-table\"><colgroup><col class=\"col-rank\"><col class=\"col-live-bytes\"><col " + "class=\"col-alloc-count\"><col class=\"col-threads\"><col class=\"col-callstack\"></colgroup><thead><tr><th " + "class=\"num\">#</th><th class=\"num\">Live bytes</th><th class=\"num\">Alloc " + "count</th><th>Threads</th><th>Callstack</th></tr></thead><tbody>"; + for (size_t I = 0; I < m_Model.CallstackStats.size(); ++I) + { + const CallstackAllocStat& Stat = m_Model.CallstackStats[I]; + std::string ThreadInfo = BuildThreadSummary(m_Model, Stat.ThreadIds); + m_Html << "<tr><td class=\"num\">" << uint64_t(I + 1) << "</td><td class=\"num\">" << zen::NiceBytes(uint64_t(Stat.LiveBytes)) + << "</td><td class=\"num\">" << zen::ThousandsNum(Stat.LiveCount) << "</td><td>"; + AppendHtmlEscaped(m_Html, ThreadInfo); + m_Html << "</td><td class=\"callstack-cell\"><details><summary>Callstack " << Stat.CallstackId << "</summary>"; + AppendHtmlCallstack(m_Html, m_Options, m_FrameFormatter, Stat.CallstackId); + m_Html << "</details></td></tr>"; + } + m_Html << "</tbody></table>"; + } + + void AppendChurnSection() + { + m_Html << "<h2 id=\"churn\">Allocation churn sites (top 100)</h2>"; + if (m_Model.ChurnStats.empty()) + { + m_Html << "<div class=\"card muted\">No churn statistics were available in this trace.</div>"; + return; + } + + m_Html << "<table class=\"report-table\"><colgroup><col class=\"col-rank\"><col class=\"col-short-lived\"><col " + "class=\"col-churn-bytes\"><col class=\"col-total-allocs\"><col class=\"col-avg-distance\"><col " + "class=\"col-callstack\"></colgroup><thead><tr><th class=\"num\">#</th><th class=\"num\">Short-lived allocs</th><th " + "class=\"num\">Churn bytes</th><th class=\"num\">Total allocs</th><th class=\"num\">Avg " + "distance</th><th>Callstack</th></tr></thead><tbody>"; + size_t Emitted = 0; + for (const CallstackChurnStat& Stat : m_Model.ChurnStats) + { + if (Emitted >= 100) + { + break; + } + if (!PassesChurnThreshold(m_Options, Stat)) + { + continue; + } + m_Html << "<tr><td class=\"num\">" << uint64_t(Emitted + 1) << "</td><td class=\"num\">" << zen::ThousandsNum(Stat.ChurnAllocs) + << "</td><td class=\"num\">" << zen::NiceBytes(Stat.ChurnBytes) << "</td><td class=\"num\">" + << zen::ThousandsNum(Stat.TotalAllocs) << "</td><td class=\"num\">" << fmt::format("{:.0f} events", Stat.MeanDistance) + << "</td><td class=\"callstack-cell\"><details><summary>Callstack " << Stat.CallstackId << "</summary>"; + AppendHtmlCallstack(m_Html, m_Options, m_FrameFormatter, Stat.CallstackId); + m_Html << "</details></td></tr>"; + ++Emitted; + } + m_Html << "</tbody></table>"; + } + + const TraceModel& m_Model; + const AnalyzeOptions& m_Options; + const std::filesystem::path& m_FilePath; + CallstackFormatter& m_FrameFormatter; + zen::ExtendableStringBuilder<32768> m_Html; +}; + +static void +WriteAnalyzeHtmlReport(const TraceModel& Model, + const AnalyzeOptions& Options, + const std::filesystem::path& FilePath, + CallstackFormatter& FrameFormatter) +{ + std::filesystem::path OutputPath = std::filesystem::absolute(Options.HtmlReportPath); + if (OutputPath.empty()) + { + return; + } + + std::error_code Ec; + std::filesystem::path ParentPath = OutputPath.parent_path(); + if (!ParentPath.empty()) + { + std::filesystem::create_directories(ParentPath, Ec); + } + + HtmlReportWriter Writer(Model, Options, FilePath, FrameFormatter); + Writer.Write(OutputPath); + ZEN_CONSOLE("HTML report: {}", OutputPath.string()); +} + +} // namespace + +namespace zen::trace_detail { + +void +RunAnalyze(const std::filesystem::path& FilePath, const AnalyzeOptions& Options) +{ + std::filesystem::path CachePath = FilePath; + CachePath.replace_extension(".ucache_z"); + + TraceModel Model; + std::unique_ptr<SymbolResolver> Symbols; + bool LoadedFromCache = false; + + // Try loading from cache + if (!Options.NoCache) + { + std::optional<CachedAnalysis> Cached = TryLoadAnalyzeCache(CachePath, FilePath); + if (Cached) + { + Model = std::move(Cached->Model); + Symbols = std::move(Cached->Symbols); + LoadedFromCache = true; + } + } + + if (!LoadedFromCache) + { + WorkerThreadPool ThreadPool(gsl::narrow<int>(GetHardwareConcurrency())); + Model = BuildTraceModel(FilePath, ThreadPool); + + if (Options.Symbols != SymbolBackend::Off) + { + Symbols = CreateSymbolResolver(Options.Symbols); + for (const ModuleInfo& Mod : Model.Modules) + { + Symbols->LoadModule(Mod); + } + } + } + + CallstackFormatter FrameFormatter(Model, Symbols.get()); + ConsoleAnalyzeWriter ConsoleWriter(Model, Options, FilePath, FrameFormatter); + ConsoleWriter.Write(); + + if (!Options.HtmlReportPath.empty()) + { + WriteAnalyzeHtmlReport(Model, Options, FilePath, FrameFormatter); + } + + // Write cache on fresh parse + if (!LoadedFromCache && !Options.NoCache) + { + // Build the complete symbol map for the cache. Start with whatever + // the formatter already resolved during display, then resolve every + // remaining callstack address in parallel. + eastl::hash_map<uint64_t, std::string> AllSymbols = FrameFormatter.GetResolvedCache(); + + // Collect unique addresses that still need resolving. + eastl::hash_set<uint64_t> Needed; + for (const CallstackEntry& CS : Model.Callstacks) + { + for (const ResolvedFrame& Frame : CS.Frames) + { + if (AllSymbols.find(Frame.Address) == AllSymbols.end()) + { + Needed.insert(Frame.Address); + } + } + } + + if (!Needed.empty() && Symbols) + { + // Flatten to a vector so we can partition into chunks. + eastl::vector<uint64_t> Addresses(Needed.begin(), Needed.end()); + Needed.clear(); + + uint32_t ThreadCount = gsl::narrow<uint32_t>(GetHardwareConcurrency()); + WorkerThreadPool ResolvePool(gsl::narrow<int>(ThreadCount)); + + // Each worker resolves a chunk and writes into its own local map. + eastl::vector<eastl::hash_map<uint64_t, std::string>> PerThread(ThreadCount); + uint32_t ChunkSize = uint32_t((Addresses.size() + ThreadCount - 1) / ThreadCount); + + Latch Done(ThreadCount); + for (uint32_t T = 0; T < ThreadCount; ++T) + { + uint32_t Begin = T * ChunkSize; + uint32_t End = std::min(Begin + ChunkSize, uint32_t(Addresses.size())); + if (Begin >= End) + { + Done.CountDown(); + continue; + } + + ResolvePool.ScheduleWork( + [&Addresses, &PerThread, &Model, &Symbols, &Done, T, Begin, End]() { + auto _ = MakeGuard([&Done]() { Done.CountDown(); }); + for (uint32_t I = Begin; I < End; ++I) + { + uint64_t Addr = Addresses[I]; + std::string Symbol = Symbols->Resolve(Addr); + if (!Symbol.empty()) + { + PerThread[T].emplace(Addr, std::move(Symbol)); + } + } + }, + WorkerThreadPool::EMode::EnableBacklog); + } + Done.Wait(); + + // Merge per-thread results. + for (auto& Map : PerThread) + { + for (auto& [Addr, Sym] : Map) + { + AllSymbols.emplace(Addr, std::move(Sym)); + } + } + } + + // Fill in module-name fallbacks for any addresses not resolved by the + // symbol resolver (same logic as CallstackFormatter::Describe). + for (const CallstackEntry& CS : Model.Callstacks) + { + for (const ResolvedFrame& Frame : CS.Frames) + { + if (AllSymbols.find(Frame.Address) != AllSymbols.end()) + { + continue; + } + std::string Fallback; + if (Frame.ModuleIndex != ~0u && Frame.ModuleIndex < Model.Modules.size()) + { + Fallback = fmt::format("{} + 0x{:X}", Model.Modules[Frame.ModuleIndex].Name, Frame.Offset); + } + else + { + Fallback = fmt::format("0x{:X}", Frame.Address); + } + AllSymbols.emplace(Frame.Address, std::move(Fallback)); + } + } + + WriteAnalyzeCache(CachePath, FilePath, Model, AllSymbols); + } +} + +} // namespace zen::trace_detail |