Files
MobileGL/MobileGL/MG_Util/Metrics/PipeStats.cpp
T
swung0x48 06b61bd84e [MG_Util, MG_Remote, tools] (P6): the wire counters and the spawn server's stats latch
Gate 8's last two numbers (SEG_STAGE bytes/frame, records/frame post-
chunking) had no producer at all: the summary line's byte classes are
backend-to-driver bytes and maxrec is a max, not a per-frame. Two push-
guarded counters answer them at their single choke points - ByteClass::
StageSegmentBytes at PipeWireCodec's StageAllocate (the only allocation
behind StageBytes, so every buffer walk, texture slab and CSO archive
lands there and emitters cannot double-count) and CallClass::WireRecords
on EncodeRecord's committed path (post-chunking an emitter no longer
knows how many records its one call produced). A staged-blob histogram
rides along. G1 holds: symbols 0/0/0/0, .text byte-identical, pull build
carries no MG_Remote symbols.

The spawn server never counted anything: PipeStats::Init() is a step of
MobileGL::Initialize(), which ServerMain never reaches, so every Enabled()
site in that process - including ServerLoop's own ServerWaits/ServerParks
publish - stayed false and the server log carried no summary line at all
(a zero that means 'another process' printed in the shape of one that
means 'never waited' is the milder misreading; the line was simply
absent). ServerMain now arms and closes PipeStats at its role boundary -
after MG_ConfigLoader::Init (Init latches from the parsed config), after
the server-role latch (the log sink's role), and before ::_exit(0).
Red-once: 0 server summary lines before, 29 after, SSIM 1.0 unchanged.

MOBILEGL_PIPE_STATS_FILE now derives per role through the log sink's
exported RoleLogPath rule - run.client.json / run.server.json, the bare
name resolves to no file - because both roles reach FormatJson() and ofstream
trunc made the second writer replace the first. G1 caught three separate
leaks on the way (a string temporary in the monolith body, one resized
Shutdown() from unequal #if arms, two rewritten MGLOG_W literals in
.rodata); the pull arm is statement-for-statement the original.

Device tooling for the paired A/B lands with it: pin_device.sh gains a
measured 2f7cbe2e entry (this board clamps the GPU at 1050 MHz -
thermal_pwrlevel writes 0 and reads back 1, max_gpuclk is read-only;
absolute times are not comparable with the 1100 MHz-era rows), and
tools/device_bench/p6/ carries the session collector and renderer beside
run_android_retrace_local.py's MOBILEGL_TRACE_PACKAGE override.

Unit 2367/2367 (PipeStats 25/25), spawn integration 102/102.
2026-09-22 03:37:51 -04:00

801 lines
46 KiB
C++

// MobileGL - MobileGL/MG_Util/Metrics/PipeStats.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "PipeStats.h"
#include <Config.h>
#include <MG_Util/Debug/Log.h>
#include <fstream>
// ---------------------------------------------------------------------------------------
// SITE INVENTORY - what these counters DO and DO NOT cover.
//
// This list is the contract. A byte class that reads 0 while a real copy runs uncounted is
// worse than a missing counter, because the zero is then read as an answer, so every path
// that moves bytes and is NOT wired is named here by file and function.
//
// Byte classes
// stage-buffer ESPRYT (DirectGLES Managers.cpp): RespecifyStorageNow's
// glBufferData, FlushPendingRangesNow's three shapes (map-write,
// glBufferSubData, upload-ring stage), and the pool-recycle reseed
// in SyncBufferObject.
// MAGMA (DirectVulkan VkBufferManager.cpp): every host->device copy
// of a buffer object's contents - SwapStorageAndUploadAll, the
// StagedRangeCopy staging fill, the in-place uploads in OnRespecify /
// OnSubData / OnFlushMappedRange, the AcquirePersistentMap seed, the
// AcquireResidentSlice initial upload and the AcquireStreamedSlice
// arena fill.
// NOT covered IN A MONOLITH BUILD: bytes an app writes THROUGH a
// persistent map. Those never pass through either backend (D4/D-B4) -
// see persistent-map-push.
// COVERED UNDER SPLIT, AND DELIBERATELY OVERLAPPING WITH
// persistent-map-push (P5 b1, R-6). At adoption tier T2 there is no
// adoption, so a pushed block IS an ordinary resource_subdata: it
// reaches Ops_H_SubData, is queued into pendingRanges and is staged
// here like any other write. The same bytes are therefore in BOTH
// classes, on purpose - stage-buffer answers "what did the backend
// move", persistent-map-push answers "what did the client have to ship
// because the acquisition was declined", and subtracting one from the
// other would make the first under-report the thing it exists to
// measure. Read them as two questions about the same bytes, never as a
// partition, and do not add them.
// stage-texture ESPRYT (Managers.cpp texture upload): the bytes of whichever of
// the three upload shapes ran (rect list / union box / whole level).
// MAGMA (VkTextureManager.cpp): the packed staging slice of an
// upload batch item set.
// NOT covered: Espryt's compressed-texture path, and both backends'
// readback (device->host) paths, which are a different direction and
// want their own class when the reverse channel of section 7 exists.
// stage-ubo-global ESPRYT (DirectGLES.cpp): the default-uniform-block image, both the
// UBO-ring memcpy and the glBufferSubData fallback.
// MAGMA (UniformManager::ResolveDynamicUboDescriptor): the same
// image, counted after the per-frame slice memo, so a frame that
// re-uses the slice correctly contributes nothing.
// stage-ubo-named DirectVulkan UniformManager::ResolveUniformBufferPayload - the
// bytes Magma repacks into its own UBO ring, counted AFTER the
// zero-copy direct-bind decision (a direct bind repacks nothing).
// Espryt contributes nothing by construction (D-B8).
// stage-vertex-client ESPRYT: BackendVertexArrayObject::SyncClientSideAttributesFor-
// DrawArrays (both the Float64-narrowing and the verbatim shapes)
// and the VBO-backed Float64->Float32 narrowing scratch upload.
// MAGMA: VkBufferManager::UploadTransient(BufferKind::Vertex), which
// is the single chokepoint for the converted-vertex-stream and
// client-array staging.
// stage-index-client ESPRYT: the primitive-restart substitution buffer, and MultiDraw's
// rewritten (rebased) index stream.
// MAGMA: VkBufferManager::UploadTransient(BufferKind::Index).
// stage-indirect-cmd ESPRYT MultiDraw.cpp: the DrawElementsIndirectCommand array staged
// for the indirect tiers, and the compute tier's per-draw info
// array. Kept out of stage-index-client because these are draw
// PARAMETERS - the population that becomes MGPipe command-record
// payload, not resource bytes.
// NOT covered: Magma builds no such array (it issues one vkCmdDraw*
// per sub-draw), so this class is Espryt-only by construction.
// persistent-map-push WIRED IN P5 (b1), and only a split build can ever move it. The one
// site is BufferObject::PushMappedSpanBlock, i.e. the client shipping
// one MOBILEGL_IPC_PERSISTENT_BLOCK_KB block of a persistently mapped
// span because MGPipeApplyMapPersistent declined the adoption (R-6,
// tier T2). Zero in every monolith build, and that zero is CORRECT
// rather than missing: a persistent map there is a permanent address
// space donation (D4/D-B4), the application writes straight into GPU
// memory, and there is no push to count. A split run where this stays
// 0 has NOT reached T2.
// Read it against map-persistent-roundtrips (mpr), which it is
// ANTI-CORRELATED with: mpr counts acquisition ATTEMPTS - one per
// storage definition, the same number in both modes - and this counts
// the bytes the client had to ship because the attempt was declined.
// DOUBLE-COUNTED WITH stage-buffer, deliberately: see that entry
// above. The two are different questions about the same bytes under
// split, and adding them is wrong.
// residual-value-block Placeholder, always 0 until P2 (plan section 6.3).
// stage-segment-bytes P6 gate 8, and the only class in this list that is neither a backend's
// copy nor a client's blob declaration: it counts what the WIRE put into
// SEG_STAGE. One site, PipeWireEncoder::StageAllocate, which is the single
// allocation call behind PipeWireEncoder::StageBytes - so every writer
// (the buffer content walks, the texture slabs, the CSO archives, the
// storage-block names) is covered by construction rather than by being on
// a list. Align8 slack and the allocator's wrap skip are real occupancy
// and are deliberately NOT counted; see the ByteClass comment. It is a
// per-frame average by construction, so the per-blob SHAPE - the thing
// that says whether the chunk budget is cutting where it should - is the
// staged-blob histogram in the JSON dump, not this number.
//
// Call classes
// draws DirectGLES PrepareForDraw and DirectVulkan SetupDraw's entry. A
// dispatch is not a draw and is not counted.
// wire-records P6 gate 8: one per record the wire encoder COMMITTED, on
// PipeWireEncoder::EncodeRecord's successful path. Post-chunking by
// construction - an application call whose content is cut at
// MGPipeStageChunkBytes() produces several. A kRecPad ring filler is not a
// record and is not counted; an emission Reserve refused is counted once,
// when the retry succeeds. The emitter-side Client*Emissions counters
// answer a different question and the two together are what show what the
// suppressors absorbed.
// accessor-calls STATIC TALLIES at the instrumented entry points, NOT a wrapper
// around all 293 pGLContext-> sites. Each instrumented function adds
// the number of GLContext accessor calls that its OWN body executed
// on the path taken, and each tally sits AFTER the last early return
// that would skip those reads. Covered: PrepareForDraw's own reads,
// SyncRenderState, CaptureDrawTextureSyncKeys/CurrentUnitBindings-
// Epoch, SyncNeccessaryTextures' walk, TrySetupDrawFastPath,
// GetOrCreatePipeline and ApplyDynamicDrawStateTail. NOT covered:
// the reads inside the callees those functions invoke (buffer/VAO/
// FBO/program sync, the pipeline payload builder's ~40 reads on a
// memo miss), and every non-draw entry point. The number is
// therefore a LOWER BOUND on the per-draw accessor count, and it is
// the bound over exactly the six gates section 2.3.1 tabulates.
// texture-* Per (target, level) emission, both backends.
//
// Gates: the six of section 2.3.1, each counted exactly once per probe.
//
// READING acc/draw. The accessor tally covers the instrumented functions wherever they
// run, and three of them (SyncRenderState, the texture-key capture, SyncNeccessaryTextures)
// are also reached from NON-draw call sites - Clear, readbacks, the DSA by-name entry
// points - which the `draws` counter deliberately does not count. So acc/draw is the
// per-draw steady-state number section 2.3.1 asks for only in a DRAW-DOMINATED window; in a
// window dominated by clears and readbacks it is inflated by exactly those non-draw
// probes, and the gate hit/miss pairs are the honest reading there.
// ---------------------------------------------------------------------------------------
namespace MobileGL::MG_Util::PipeStats {
Bool g_pipeStatsEnabled = false;
namespace {
constexpr Uint32 kByteClassCount = static_cast<Uint32>(ByteClass::Count);
constexpr Uint32 kCallClassCount = static_cast<Uint32>(CallClass::Count);
constexpr Uint32 kGateCount = static_cast<Uint32>(Gate::Count);
using Counter = std::atomic<Uint64>;
Counter g_frameBytes[kByteClassCount];
Counter g_totalBytes[kByteClassCount];
Counter g_frameCalls[kCallClassCount];
Counter g_totalCalls[kCallClassCount];
Counter g_frameGateHit[kGateCount];
Counter g_totalGateHit[kGateCount];
Counter g_frameGateMiss[kGateCount];
Counter g_totalGateMiss[kGateCount];
Counter g_totalPayloadBuckets[kPayloadHistogramBuckets];
Counter g_frameCount{0};
#if MOBILEGL_PIPE_PUSH
// P6 gate 8's second histogram, and push-only for the same reason its accessor is: its
// only sampler is the wire encoder, which a pull build does not compile.
Counter g_totalStagedBlobBuckets[kStagedBlobHistogramBuckets];
#endif
// Window bases: the run totals as of the previous summary line. Only ever touched
// from OnPresent()/Shutdown() (the present thread), so plain integers.
Uint64 g_windowBaseBytes[kByteClassCount] = {};
Uint64 g_windowBaseCalls[kCallClassCount] = {};
Uint64 g_windowBaseGateHit[kGateCount] = {};
Uint64 g_windowBaseGateMiss[kGateCount] = {};
Uint64 g_windowBaseFrames = 0;
Bool g_shutdownDone = false;
#if MOBILEGL_PIPE_PUSH
// The gauges' storage. Relaxed atomics like every other counter here: the publisher is
// the GL thread at a frame boundary and the reader is whoever formats the line, which
// under split can be the apply thread.
Counter g_gauges[static_cast<Uint32>(Gauge::Count)] = {};
constexpr Uint32 kGaugeCount = static_cast<Uint32>(Gauge::Count);
#endif
// Frames per summary line, latched by Init() from MOBILEGL_PIPE_STATS_PERIOD.
Uint64 g_summaryPeriod = kDefaultSummaryFramePeriod;
inline void Bump(Counter& counter, Uint64 amount) {
counter.fetch_add(amount, std::memory_order_relaxed);
}
inline Uint64 Read(const Counter& counter) { return counter.load(std::memory_order_relaxed); }
// Bucket 0 is "0 bytes", bucket n>0 holds [2^(n-1), 2^n). Saturates at the last
// bucket so a pathological record cannot index out of the array.
Uint32 PayloadBucketOf(Uint64 bytes) {
if (bytes == 0) {
return 0;
}
Uint32 bucket = 1;
while (bucket + 1 < kPayloadHistogramBuckets && bytes >= (Uint64{1} << bucket)) {
++bucket;
}
return bucket;
}
// Two decimals without <iomanip>. Every per-frame and per-draw field in the summary
// goes through this: the numbers are small (a per-draw accessor count in the 10-25
// band, a per-frame byte count that sizes SEG_STAGE), so truncating integer division
// loses up to a whole unit on exactly the figures the package exists to produce.
// A zero denominator is "n/a" rather than a division by a faked 1.
String FormatFixed2(Uint64 numerator, Uint64 denominator) {
if (denominator == 0) {
return "n/a";
}
const Uint64 hundredths = (numerator * 100 + denominator / 2) / denominator;
return std::to_string(hundredths / 100) + "." + (hundredths % 100 < 10 ? "0" : "") +
std::to_string(hundredths % 100);
}
const char* const kByteClassNames[kByteClassCount] = {
"stage-buffer", "stage-texture", "stage-ubo-global",
"stage-ubo-named", "stage-vertex-client", "stage-index-client",
"stage-indirect-cmd", "persistent-map-push", "residual-value-block",
#if MOBILEGL_PIPE_PUSH
"cso-blob-bytes", "stage-segment-bytes",
#endif
};
const char* const kCallClassNames[kCallClassCount] = {
"draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect",
"tex-upload-jobs",
#if MOBILEGL_PIPE_PUSH
"render-state-cso-mints", "render-state-cso-binds", "map-persistent-roundtrips",
"framebuffer-emissions", "sampler-view-emissions", "sampler-state-emissions",
"shader-image-emissions", "client-tex-upload-emissions", "tex-remint-pulls",
"residual-pulls", "server-verb-boundaries", "wire-records",
#endif
};
const char* const kGateNames[kGateCount] = {
"espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch",
"magma-draw-fastpath", "magma-pipeline-memo", "magma-dynamic-tail",
};
// Tracy needs a stable string literal per series, and a gate is TWO series: plotting
// only the misses (which is what the first cut did) hides the denominator, and a
// gate's whole point is the ratio.
const char* const kGateHitPlotNames[kGateCount] = {
"espryt-render-state-hit", "espryt-texture-sync-list-hit", "espryt-unit-bindings-epoch-hit",
"magma-draw-fastpath-hit", "magma-pipeline-memo-hit", "magma-dynamic-tail-hit",
};
const char* const kGateMissPlotNames[kGateCount] = {
"espryt-render-state-miss", "espryt-texture-sync-list-miss", "espryt-unit-bindings-epoch-miss",
"magma-draw-fastpath-miss", "magma-pipeline-memo-miss", "magma-dynamic-tail-miss",
};
// Short forms, so the per-120-frame line stays one terminal line wide.
// "csob-blob" and not "csob": the cso[] bracket below already prints csob= for the
// render-state CSO BIND count, and two different numbers under one grep is how a
// recorded baseline stops meaning anything.
const char* const kByteClassShort[kByteClassCount] = {"buf", "tex", "ubog", "ubon", "vtxc",
"idxc", "icmd", "pmap", "resid",
#if MOBILEGL_PIPE_PUSH
"csob-blob", "seg",
#endif
};
const char* const kGateShort[kGateCount] = {"ers", "etl", "eub", "mfp", "mpm", "mdt"};
void ResetCounters() {
for (Uint32 i = 0; i < kByteClassCount; ++i) {
g_frameBytes[i].store(0, std::memory_order_relaxed);
g_totalBytes[i].store(0, std::memory_order_relaxed);
g_windowBaseBytes[i] = 0;
}
for (Uint32 i = 0; i < kCallClassCount; ++i) {
g_frameCalls[i].store(0, std::memory_order_relaxed);
g_totalCalls[i].store(0, std::memory_order_relaxed);
g_windowBaseCalls[i] = 0;
}
for (Uint32 i = 0; i < kGateCount; ++i) {
g_frameGateHit[i].store(0, std::memory_order_relaxed);
g_totalGateHit[i].store(0, std::memory_order_relaxed);
g_frameGateMiss[i].store(0, std::memory_order_relaxed);
g_totalGateMiss[i].store(0, std::memory_order_relaxed);
g_windowBaseGateHit[i] = 0;
g_windowBaseGateMiss[i] = 0;
}
for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) {
g_totalPayloadBuckets[i].store(0, std::memory_order_relaxed);
}
g_frameCount.store(0, std::memory_order_relaxed);
g_windowBaseFrames = 0;
#if MOBILEGL_PIPE_PUSH
for (Uint32 i = 0; i < kStagedBlobHistogramBuckets; ++i) {
g_totalStagedBlobBuckets[i].store(0, std::memory_order_relaxed);
}
#endif
#if MOBILEGL_PIPE_PUSH
for (Uint32 i = 0; i < kGaugeCount; ++i) {
g_gauges[i].store(0, std::memory_order_relaxed);
}
#endif
}
void EmitSummaryLine() {
const String line = FormatWindowLine();
// MGLOG_I on purpose, against the project's usual "MGLOG_D for anything
// non-critical" rule: the line has to survive an INFO build (that is the only
// build a device ever runs), it is emitted at most once per 120 frames, and it
// exists at all only when the operator set MOBILEGL_PIPE_STATS=1. It is an
// opt-in measurement channel, not per-frame noise.
MGLOG_I("%s", line.c_str());
AdvanceSummaryWindow();
}
void WriteJsonDump() {
#if MOBILEGL_BUILD_DISAGGREGATED
// ---- THE DUMP PATH IS ROLE-DERIVED, AND THIS BLOCK IS WHY IT IS INSIDE A GUARD ----
//
// Same reason as the log path it mirrors: under `spawn` there are two processes with
// two independent sets of counters, each reaching PipeStats::Shutdown(), and one
// configured path would have the second truncate and overwrite the first. A reader
// would then hold one role's numbers under a name that claims to be the run's, which is
// worse than a missing file because nothing about it looks wrong.
//
// THE BASE NAME RESOLVES TO NO FILE, deliberately. `<base>.client.json` and
// `<base>.server.json` are what exist; opening the bare configured name fails loudly.
// That is the log sink's own design ("with both names moved, an un-updated reader gets
// ENOENT and says so") and it is the right trade here too: a caller that has not been
// updated gets an error it must look at, rather than a file that is silently half a run.
//
// THE RULE IS NOT COPIED. MG_Util/Debug/Log.cpp owns `<stem>.<role><ext>` and exports
// it; a second implementation is a second thing to keep in step, and the one that falls
// behind opens a path nothing writes and reads as "the counters never dumped".
//
// ---- AND G1 IS WHY THE PULL ARM BELOW IS THE ORIGINAL, STATEMENT FOR STATEMENT ----
//
// A monolith build has exactly ONE role, so deriving the path there is a no-op - but
// "a no-op" is not "no bytes", and the gate caught three separate leaks as this was
// written. They are worth recording because none is guessable from the code:
//
// 1. deriving UNCONDITIONALLY put a std::string temporary and a call into the
// monolith body: .text +24 bytes.
// 2. guarding it but keeping ONE shared `String` local in both arms still reds: the
// two arms are different code even when they compute the same value, and
// `Shutdown()` came out one byte different.
// 3. rewriting the two MGLOG_W format strings to name both the base and the derived
// path moved .rodata by 64 bytes, because a literal is a literal in whichever
// branch it sits and .rodata is measured too.
//
// So the rule for this file is stronger than "guard the new code": the OLD code must be
// left alone, character for character, and every addition lives inside the `#if`.
const String path = RoleDerivedJsonDumpPathForTesting();
if (path.empty()) {
return;
}
#else
// The monolith arm, byte for byte as it was. Do not "tidy" this into the arm above.
const String& path = MG_Config::Features.PipeStatsFile;
if (path.empty()) {
return;
}
#endif
std::ofstream out(path, std::ios::out | std::ios::trunc);
if (!out) {
MGLOG_W("PipeStats: could not open MOBILEGL_PIPE_STATS_FILE='%s' for writing",
path.c_str());
return;
}
out << FormatJson();
out.flush();
if (!out) {
MGLOG_W("PipeStats: failed writing MOBILEGL_PIPE_STATS_FILE='%s'", path.c_str());
return;
}
MGLOG_I("MGPipe stats: wrote JSON dump to %s", path.c_str());
}
} // namespace
void Init() {
ResetCounters();
g_shutdownDone = false;
g_pipeStatsEnabled = MG_Config::Features.PipeStats;
g_summaryPeriod = MG_Config::Features.PipeStatsPeriod == 0
? kDefaultSummaryFramePeriod
: static_cast<Uint64>(MG_Config::Features.PipeStatsPeriod);
if (g_pipeStatsEnabled) {
#if MOBILEGL_BUILD_DISAGGREGATED
// THE ROLE-DERIVED PATH IS WHAT IS PRINTED, not the base name. An operator reading
// this banner wants to know which file the run will write, and under split the base
// name is not it - printing the base would send them to a path that is deliberately
// never opened (see WriteJsonDump).
//
// THE PULL ARM OF THIS `#if` IS THE ORIGINAL LINE, and the split arm repeats the same
// format literal rather than sharing one, for the reason WriteJsonDump's header spells
// out: a shared literal or a shared temporary is a byte the monolith build gains for a
// branch it can never take. Two copies of one format string in two arms of one `#if` is
// the price G1 charges for adding anything to this file at all.
const String dumpPath = RoleDerivedJsonDumpPathForTesting();
MGLOG_I("MGPipe stats: counters ON (MOBILEGL_PIPE_STATS), summary every %llu frames%s%s",
static_cast<unsigned long long>(g_summaryPeriod),
dumpPath.empty() ? "" : ", JSON dump to ", dumpPath.c_str());
#else
MGLOG_I("MGPipe stats: counters ON (MOBILEGL_PIPE_STATS), summary every %llu frames%s%s",
static_cast<unsigned long long>(g_summaryPeriod),
MG_Config::Features.PipeStatsFile.empty() ? "" : ", JSON dump to ",
MG_Config::Features.PipeStatsFile.c_str());
#endif
}
}
Uint64 SummaryFramePeriod() { return g_summaryPeriod; }
void Shutdown() {
if (!g_pipeStatsEnabled || g_shutdownDone) {
return;
}
g_shutdownDone = true;
EmitSummaryLine();
WriteJsonDump();
}
void AddBytes(ByteClass byteClass, Uint64 bytes) {
const Uint32 index = static_cast<Uint32>(byteClass);
Bump(g_frameBytes[index], bytes);
Bump(g_totalBytes[index], bytes);
}
void AddCalls(CallClass callClass, Uint64 count) {
const Uint32 index = static_cast<Uint32>(callClass);
Bump(g_frameCalls[index], count);
Bump(g_totalCalls[index], count);
}
#if MOBILEGL_PIPE_PUSH
// A STORE, NOT A BUMP, and the difference is the whole reason these are a separate kind.
// The publisher hands over its OWN run total (a maximum, or a count it has been keeping
// since the session opened), so accumulating deltas here would double every reading; and a
// maximum is not additive at all. Publishing the same value twice is a no-op, which is
// what makes it safe to call at every frame boundary.
void PublishGauge(Gauge gauge, Uint64 value) {
g_gauges[static_cast<Uint32>(gauge)].store(value, std::memory_order_relaxed);
}
Uint64 GaugeValue(Gauge gauge) { return Read(g_gauges[static_cast<Uint32>(gauge)]); }
#endif
void CountGate(Gate gate, Bool hit) {
const Uint32 index = static_cast<Uint32>(gate);
if (hit) {
Bump(g_frameGateHit[index], 1);
Bump(g_totalGateHit[index], 1);
} else {
Bump(g_frameGateMiss[index], 1);
Bump(g_totalGateMiss[index], 1);
}
}
void RecordDrawPayloadBytes(Uint64 bytes) { Bump(g_totalPayloadBuckets[PayloadBucketOf(bytes)], 1); }
#if MOBILEGL_PIPE_PUSH
// P6 gate 8's staged-blob distribution. A count, not a sum: the bytes already have a home
// in stage-segment-bytes, and what this exists to answer is the SHAPE (how many blobs there
// were and how big each one was), which a total cannot express.
void RecordStagedBlobBytes(Uint64 bytes) {
Bump(g_totalStagedBlobBuckets[PayloadBucketOf(bytes)], 1);
}
#endif
void OnPresent() {
// Every frame accumulator is EXCHANGED for zero, and the exchanged value is what gets
// plotted. A read followed by a store(0) would lose any Bump that lands in between -
// buffer and texture staging reach these counters from more than one thread - from
// the plot AND from every frame; an exchange hands every add to exactly one frame.
// Without Tracy the value is taken and dropped: the clear is still the point.
//
// One plot per counter, the frame's value. Tracy keeps the series by name, and the
// names are the static literals above, which is what TracyPlot requires. A gate is
// two series - hits and misses - because the ratio is the deliverable and a miss
// count alone cannot be read.
//
// The payload histogram is deliberately NOT plotted: it is a run-total distribution
// over draws (section 4.5.7), not a per-frame scalar, and Tracy has no histogram
// series. It reaches the operator through the JSON dump.
const auto take = [](Counter& counter) { return counter.exchange(0, std::memory_order_relaxed); };
for (Uint32 i = 0; i < kByteClassCount; ++i) {
const Uint64 value = take(g_frameBytes[i]);
(void)value;
#ifdef TRACY_ENABLE
TracyPlot(kByteClassNames[i], static_cast<Int64>(value));
#endif
}
for (Uint32 i = 0; i < kCallClassCount; ++i) {
const Uint64 value = take(g_frameCalls[i]);
(void)value;
#ifdef TRACY_ENABLE
TracyPlot(kCallClassNames[i], static_cast<Int64>(value));
#endif
}
for (Uint32 i = 0; i < kGateCount; ++i) {
const Uint64 hits = take(g_frameGateHit[i]);
const Uint64 misses = take(g_frameGateMiss[i]);
(void)hits;
(void)misses;
#ifdef TRACY_ENABLE
TracyPlot(kGateHitPlotNames[i], static_cast<Int64>(hits));
TracyPlot(kGateMissPlotNames[i], static_cast<Int64>(misses));
#endif
}
const Uint64 frames = g_frameCount.fetch_add(1, std::memory_order_relaxed) + 1;
if (frames % g_summaryPeriod == 0) {
EmitSummaryLine();
}
}
Uint64 FrameBytes(ByteClass byteClass) { return Read(g_frameBytes[static_cast<Uint32>(byteClass)]); }
Uint64 TotalBytes(ByteClass byteClass) { return Read(g_totalBytes[static_cast<Uint32>(byteClass)]); }
Uint64 FrameCalls(CallClass callClass) { return Read(g_frameCalls[static_cast<Uint32>(callClass)]); }
Uint64 TotalCalls(CallClass callClass) { return Read(g_totalCalls[static_cast<Uint32>(callClass)]); }
Uint64 TotalGateHits(Gate gate) { return Read(g_totalGateHit[static_cast<Uint32>(gate)]); }
Uint64 TotalGateMisses(Gate gate) { return Read(g_totalGateMiss[static_cast<Uint32>(gate)]); }
Uint64 TotalPayloadBucket(Uint32 bucket) {
return bucket < kPayloadHistogramBuckets ? Read(g_totalPayloadBuckets[bucket]) : 0;
}
#if MOBILEGL_PIPE_PUSH
Uint64 TotalStagedBlobBucket(Uint32 bucket) {
return bucket < kStagedBlobHistogramBuckets ? Read(g_totalStagedBlobBuckets[bucket]) : 0;
}
#endif
Uint64 FrameCount() { return Read(g_frameCount); }
const char* NameOf(ByteClass byteClass) { return kByteClassNames[static_cast<Uint32>(byteClass)]; }
const char* NameOf(CallClass callClass) { return kCallClassNames[static_cast<Uint32>(callClass)]; }
const char* NameOf(Gate gate) { return kGateNames[static_cast<Uint32>(gate)]; }
String FormatWindowLine() {
// Window values: everything since the previous summary. A run total over a workload
// whose shape changes (load, then steady state) hides exactly the number P2 wants.
const Uint64 frames = Read(g_frameCount);
const Uint64 windowFrames = frames - g_windowBaseFrames;
// A window with no Present in it (teardown before the first frame, or a slice whose
// whole workload runs off-screen) has NO per-frame reading. Printing the window
// totals under a "/f" label there is how a 47x overstatement of the SEG_STAGE sizing
// input got printed as a per-frame figure; the label changes instead.
const Bool perFrame = windowFrames != 0;
Uint64 bytes[kByteClassCount];
for (Uint32 i = 0; i < kByteClassCount; ++i) {
bytes[i] = Read(g_totalBytes[i]) - g_windowBaseBytes[i];
}
Uint64 calls[kCallClassCount];
for (Uint32 i = 0; i < kCallClassCount; ++i) {
calls[i] = Read(g_totalCalls[i]) - g_windowBaseCalls[i];
}
Uint64 gateHit[kGateCount];
Uint64 gateMiss[kGateCount];
for (Uint32 i = 0; i < kGateCount; ++i) {
gateHit[i] = Read(g_totalGateHit[i]) - g_windowBaseGateHit[i];
gateMiss[i] = Read(g_totalGateMiss[i]) - g_windowBaseGateMiss[i];
}
const Uint64 draws = calls[static_cast<Uint32>(CallClass::Draws)];
const Uint64 accessorCalls = calls[static_cast<Uint32>(CallClass::AccessorCalls)];
String line = "MGPipe stats:";
line += " frames=" + std::to_string(frames);
line += " window=" + std::to_string(windowFrames);
line += " draws=" + std::to_string(draws);
line += " draws/f=" + FormatFixed2(draws, windowFrames);
line += " acc=" + std::to_string(accessorCalls);
// Same rule as the per-frame fields: a window with no draw in it has no per-draw
// number, and "0.00" next to a non-zero acc= is the same lie in a smaller font.
line += " acc/draw=" + FormatFixed2(accessorCalls, draws);
// "bytes/f[...]" only when there IS a frame to divide by; otherwise the bracket is
// labelled "bytes[...]" and carries the window totals verbatim.
line += perFrame ? " bytes/f[" : " bytes[";
for (Uint32 i = 0; i < kByteClassCount; ++i) {
if (i != 0) {
line += " ";
}
line += kByteClassShort[i];
line += "=";
line += perFrame ? FormatFixed2(bytes[i], windowFrames) : std::to_string(bytes[i]);
}
line += "] tex[emit=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadEmissions)]);
line += " box=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadBoxEmissions)]);
line += " rect=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadRectEmissions)]);
line += " jobs=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadJobs)]);
#if MOBILEGL_PIPE_PUSH
// Push-only, like the two counters themselves: in a pull build there is no CSO to
// mint, and a "csom=0 csob=0" that can never be anything else is noise on the one
// line an operator greps.
line += "] cso[csom=" + std::to_string(calls[static_cast<Uint32>(CallClass::RenderStateCsoMints)]);
line += " csob=" + std::to_string(calls[static_cast<Uint32>(CallClass::RenderStateCsoBinds)]);
// P3a's persistent-map acquisition attempts, on the same bracket and for the same
// reason: it is push-only, and a window with an unexpected mpr= is the one number
// that says an adoption is happening per draw rather than per storage definition.
line += " mpr=" + std::to_string(calls[static_cast<Uint32>(CallClass::MapPersistentRoundtrips)]);
// P4a's four suppressor-visible emission counts and the client-side upload twin, on a
// bracket of their own so one grep reads the whole family. Every one of them is
// post-suppressor: a set that was resolved and then not sent does not appear here, and
// that is what makes fbe/sve/sse/sie the suppressors' hit rates rather than their call
// rates. ctu is the CLIENT's count of the same texture records Espryt's tex[emit=]
// counts on the server - the two agreeing is the whole reason both are printed.
line += "] emit[fbe=" + std::to_string(calls[static_cast<Uint32>(CallClass::FramebufferEmissions)]);
line += " sve=" + std::to_string(calls[static_cast<Uint32>(CallClass::SamplerViewEmissions)]);
line += " sse=" + std::to_string(calls[static_cast<Uint32>(CallClass::SamplerStateEmissions)]);
line += " sie=" + std::to_string(calls[static_cast<Uint32>(CallClass::ShaderImageEmissions)]);
line += " ctu=" +
std::to_string(calls[static_cast<Uint32>(CallClass::ClientTextureUploadEmissions)]);
// trp is the texture-remint pull count (ROADMAP open question 2): every one is a texture
// Espryt had already allocated and then had to re-mint image-bindable, replaying its
// levels from the client's shadow, because ImageBindableHint reached it too late.
line += " trp=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureRemintPulls)]);
// rsp is P5's residual-pull count: reads, on the server side, of a PipeInputs field no
// pushed record supplies, answered out of the client's residual fill under the verb
// barrier. It is the SIZE OF THE P6/P7/P8 DEBT and it is published on this line rather
// than only at teardown because the number an operator needs is per frame: a debt that
// tracks the draw count is a pull inside a loop, and one that tracks the frame count is
// a pull per verb. Zero in every monolith lane by construction.
line += " rsp=" + std::to_string(calls[static_cast<Uint32>(CallClass::ResidualPulls)]);
// P5e (gl), ID-115: `vbs` is the server's verb-boundary stamp count, and it is on this
// line so that "strict was armed on this entry" is a number a lane can read rather than
// an inference from an absence. Everything strict checks is downstream of that stamp and
// the monolith arm never stamps, so a green lane with vbs=0 says only that the mechanism
// never ran. It is deliberately NOT rsp: rsp reaches zero when the phase SUCCEEDS, so a
// positive control built on it would fail on the day the debt is paid.
line += " vbs=" +
std::to_string(calls[static_cast<Uint32>(CallClass::ServerVerbBoundaries)]);
// P6 GATE 8's SECOND NUMBER (CONTRACT-P6.md §9 item 8): records/frame AFTER chunking, i.e.
// the count of records the wire encoder actually committed this window rather than the
// number of times an emitter was asked for one. The gap between the two is the whole
// point of the counter: once the stage chunk budget landed, one application call can
// produce several records, so `wrec` is what a frame really costs SEG_CMD and the
// emitter-side counts can no longer answer it. It follows the line's ordinary
// per-frame/window rule - the run total when the window held no Present - like every
// field that is not one of the gauges, and the label says which it is.
//
// `wrec/f` AND NOT `wrec` ALONE, so the deliverable reads off the line directly. Both are
// printed because the pair is self-checking: a per-frame figure whose window total is not
// its numerator times the frame count is a formatting bug, and a per-frame figure over a
// window with no Present in it is the 47x overstatement PipeStatsTest already pins.
line += " wrec=" + std::to_string(calls[static_cast<Uint32>(CallClass::WireRecords)]);
line += " wrec/f=" + (perFrame
? FormatFixed2(calls[static_cast<Uint32>(CallClass::WireRecords)],
windowFrames)
: std::to_string(calls[static_cast<Uint32>(CallClass::WireRecords)]));
// P5's three wire gauges, and THEY ARE RUN TOTALS on a line whose every other field is
// a window - see the Gauge enum for the argument. `maxrec` is R-10's proof obligation
// (BRIEF 8 item 3): the largest single record this run wrote, in BYTES, beside the cap
// it has to stay under so nobody has to multiply MOBILEGL_IPC_RING_MB by hand. `maxcap`
// reads 0 when this process has no wire producer, which is what a monolith lane prints
// and is NOT the same statement as "the cap is zero".
//
// ringwraps / ringwaits are R-9's: SEG_CMD wrap pads and SEG_STAGE waits on retiredSeq.
// A small-ring lane whose ringwraps stays 0 ran the default lane's workload under a
// different environment block, which is exactly what exit gate E3(e) was recorded as
// NOT having proved (joint-v1.md 6).
line += " maxrec=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::MaxRecordBytes)]));
line += " maxcap=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::MaxRecordBytesCap)]));
line += " ringwraps=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::RingWraps)]));
line += " ringpads=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::RingWrapPads)]));
line += " ringwaits=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::RingWaits)]));
// P5d round 3's wait ledger, on a bracket of its own so one grep reads the whole
// family, and RUN TOTALS like the gauges above it for the same reason: both sides
// publish a monotone counter of their own, and a windowed difference of two counters
// published by two threads at two different moments is not a quantity either of them
// ever held. `srv`/`cli` are entries into Doorbell::Wait; `srvpark`/`clipark` are the
// subset that stopped spinning and blocked. Read them as a ratio: parks near zero means
// the spin budget is covering the handoff, parks tracking the waits means it is not and
// every verb is paying a futex round trip.
line += "] wait[srv=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::ServerWaits)]));
line += " srvpark=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::ServerParks)]));
line += " cli=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::ClientWaits)]));
line += " clipark=" + std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::ClientParks)]));
#endif
line += "] gates[";
for (Uint32 i = 0; i < kGateCount; ++i) {
if (i != 0) {
line += " ";
}
line += kGateShort[i];
line += "=";
line += std::to_string(gateHit[i]);
line += "/";
line += std::to_string(gateMiss[i]);
}
line += "]";
return line;
}
void AdvanceSummaryWindow() {
for (Uint32 i = 0; i < kByteClassCount; ++i) {
g_windowBaseBytes[i] = Read(g_totalBytes[i]);
}
for (Uint32 i = 0; i < kCallClassCount; ++i) {
g_windowBaseCalls[i] = Read(g_totalCalls[i]);
}
for (Uint32 i = 0; i < kGateCount; ++i) {
g_windowBaseGateHit[i] = Read(g_totalGateHit[i]);
g_windowBaseGateMiss[i] = Read(g_totalGateMiss[i]);
}
g_windowBaseFrames = Read(g_frameCount);
}
String FormatJson() {
String json = "{\n";
json += " \"frames\": " + std::to_string(Read(g_frameCount)) + ",\n";
json += " \"bytes\": {\n";
for (Uint32 i = 0; i < kByteClassCount; ++i) {
json += " \"";
json += kByteClassNames[i];
json += "\": " + std::to_string(Read(g_totalBytes[i]));
json += (i + 1 == kByteClassCount) ? "\n" : ",\n";
}
json += " },\n \"calls\": {\n";
for (Uint32 i = 0; i < kCallClassCount; ++i) {
json += " \"";
json += kCallClassNames[i];
json += "\": " + std::to_string(Read(g_totalCalls[i]));
json += (i + 1 == kCallClassCount) ? "\n" : ",\n";
}
json += " },\n \"gates\": {\n";
for (Uint32 i = 0; i < kGateCount; ++i) {
json += " \"";
json += kGateNames[i];
json += "\": {\"hit\": " + std::to_string(Read(g_totalGateHit[i])) +
", \"miss\": " + std::to_string(Read(g_totalGateMiss[i])) + "}";
json += (i + 1 == kGateCount) ? "\n" : ",\n";
}
#if MOBILEGL_PIPE_PUSH
// The gauges, under their long names. Run totals here as on the summary line.
json += " },\n \"wire\": {\n";
json += " \"max-record-bytes\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::MaxRecordBytes)])) + ",\n";
json += " \"max-record-bytes-cap\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::MaxRecordBytesCap)])) + ",\n";
json += " \"ring-wraps\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::RingWraps)])) + ",\n";
json += " \"ring-wrap-pads\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::RingWrapPads)])) + ",\n";
json += " \"ring-waits\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::RingWaits)])) + ",\n";
// The wait ledger under its long names, same run totals as the summary line's wait[].
json += " \"server-waits\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::ServerWaits)])) + ",\n";
json += " \"server-parks\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::ServerParks)])) + ",\n";
json += " \"client-waits\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::ClientWaits)])) + ",\n";
json += " \"client-parks\": " +
std::to_string(Read(g_gauges[static_cast<Uint32>(Gauge::ClientParks)])) + "\n";
#endif
json += " },\n \"cmd-bytes-per-draw-histogram\": [";
for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) {
if (i != 0) {
json += ", ";
}
json += std::to_string(Read(g_totalPayloadBuckets[i]));
}
#if MOBILEGL_PIPE_PUSH
// P6 gate 8's two per-frame numbers have their JSON homes where every other ByteClass and
// CallClass already does - the `bytes` and `calls` blocks above, under their long names
// (stage-segment-bytes, wire-records) - so there is nothing to add for them here. The
// staged-blob DISTRIBUTION does need one: it is the deliverable MEASUREMENTS.md:342 says
// has never been measured, and like the draw-payload histogram above it is a run-total
// distribution, which is exactly what Tracy cannot plot and this dump exists to carry.
// Bucket 0 is "0 bytes" and bucket n>0 is [2^(n-1), 2^n): the same edges PayloadBucketOf
// gives both histograms, so the two are read the same way.
json += "],\n \"staged-blob-bytes-histogram\": [";
for (Uint32 i = 0; i < kStagedBlobHistogramBuckets; ++i) {
if (i != 0) {
json += ", ";
}
json += std::to_string(Read(g_totalStagedBlobBuckets[i]));
}
#endif
json += "]\n}\n";
return json;
}
void SetEnabledForTesting(Bool enabled) { g_pipeStatsEnabled = enabled; }
void ResetForTesting() { ResetCounters(); }
#if MOBILEGL_BUILD_DISAGGREGATED
String RoleDerivedJsonDumpPathForTesting() {
// The SAME expression WriteJsonDump and the Init banner use, reached through the same
// helper - so a change that stopped deriving at either site has to change this too, and the
// test reds. That is the whole point: see the header for why asserting on RoleLogPath
// directly was not a control.
if (MG_Config::Features.PipeStatsFile.empty()) {
return String();
}
return MG_Util::Debug::RoleLogPath(MG_Config::Features.PipeStatsFile.c_str(),
MG_Util::Debug::CurrentThreadRole());
}
#endif
} // namespace MobileGL::MG_Util::PipeStats