Files
MobileGL/MobileGL/MG_Util/Metrics/PipeStats.h
T

327 lines
19 KiB
C++

// MobileGL - MobileGL/MG_Util/Metrics/PipeStats.h
// 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
#pragma once
#include <Includes.h>
// MGPipe boundary counters (plan B section 11 "P0 - hygiene, measurement, gates and
// skeleton", and the corollary in section 2.3.1).
//
// WHAT THIS IS FOR. The disaggregation plan has to size two things it cannot size by
// reading the tree: how many BYTES cross the frontend/backend boundary per frame (that
// sizes SEG_STAGE and the command segment), and how many accessor CALLS and memo-gate
// probes the backends actually execute per draw (that decides whether pushing state is
// cheaper than pulling it at all). Section 2.3.1 makes the second one the load-bearing
// number: the static call-site counts everyone quoted - Espryt 124 / Magma 169 - are NOT
// the dynamic per-draw cost, because every one of those paths is memo-gated, and the real
// steady state is believed to be 10-25 accessor calls per backend per draw. Without a
// dynamic counter the P2 verdict stays a guess.
//
// COST WHEN OFF. g_pipeStatsEnabled is a plain global Bool latched once at Init() from
// MG_Config::Features.PipeStats (MOBILEGL_PIPE_STATS). Every counting site in the two
// backends is written as
//
// if (MG_Util::PipeStats::Enabled()) MG_Util::PipeStats::Add...(...);
//
// so with the feature off a site costs one load of a hot global plus one never-taken,
// perfectly-predicted branch, and none of the counter state is touched. The counters
// themselves are relaxed atomics rather than plain integers because texture and buffer
// staging can be reached from more than one thread; relaxed adds cost nothing extra on the
// off path, which never reaches them. The off-path cost is not a guess: see the paired
// A/B in the branch's evidence.
//
// WHAT IS COUNTED AND WHAT IS NOT: see the site inventory in PipeStats.cpp. That inventory
// is the contract - it names every path that is NOT wired, because a byte class that reads
// zero while a real copy runs uncounted is worse than a missing counter.
namespace MobileGL::MG_Util::PipeStats {
// Byte classes. Every one of these names a population of bytes that would have to be
// MOVED across the boundary once the backend no longer shares an address space with
// the frontend, which is why they are grouped this way rather than by call site.
enum class ByteClass : Uint32 {
// Buffer object contents flushed to the driver: glBufferData / glBufferSubData /
// map-write ranges / the persistent upload ring (Espryt), and every host->device
// copy of a buffer object's contents (Magma).
StageBuffer = 0,
// Texel bytes handed to glTexSubImage & friends / packed into the Vulkan upload
// staging slice, whichever upload shape was chosen.
StageTexture,
// The default-uniform-block ("global UBO") image, uploaded at most once per program
// per frame.
StageUboGlobal,
// Named uniform-block bytes that a backend has to repack itself, i.e. Magma's UBO
// ring. Espryt binds the frontend buffer straight to the driver and contributes
// nothing here - which is exactly the asymmetry D-B8 is about.
StageUboNamed,
// Client-memory vertex arrays uploaded into a scratch VBO / transient arena slice on
// the draw path.
StageVertexClient,
// Client-memory / rewritten index data staged on the draw path.
StageIndexClient,
// Draw-parameter bytes a backend synthesises and stages for the draw itself: the
// indirect-command array and the compute path's per-draw info array. These are the
// bytes that become MGPipe command-record payload once the boundary is explicit,
// which is why they are not folded into the index class.
StageIndirectCmd,
// Bytes pushed because a persistently mapped range was published to the backend.
PersistentMapPush,
// PLACEHOLDER (plan section 6.3): the residual value block does not exist yet. The
// class is minted now so the counter names never churn; it stays at 0 until P2.
ResidualValueBlock,
#if MOBILEGL_PIPE_PUSH
// P4a's, and THE PUSH GUARD IS NEW ON THIS ENUM: CallClass has had one since P2 and
// ByteClass has never had one, so the block is opened here rather than the member
// simply appended. Without it the pull build's two counter arrays, the name table, the
// short-name table and FormatWindowLine all resize for a class that could never leave
// zero - and the pull build has to stay symbol-identical.
//
// The bytes of every CSO BLOB the client declares in a frame: P3a's vertex-elements
// blobs (which MEASUREMENTS.md recorded as a client-side array that was never
// measured, and left to P4a to give the summary line a class for), P4a's sampler
// parameter blobs, and P4a's program archives. It is the number that says what a
// transport would actually have to move for the CSO families, as opposed to what the
// records themselves cost.
CsoBlobBytes,
#endif
Count
};
// Call classes: the dynamic per-draw cost section 2.3.1 says P2 cannot be decided
// without.
enum class CallClass : Uint32 {
// Draws that reached an instrumented backend draw-preparation entry point. The
// denominator for every "per draw" number below.
Draws = 0,
// GLContext accessor calls actually EXECUTED on the instrumented paths. Counted in
// static tallies at the ~10 hot entry points, not by wrapping all 293 call sites -
// see the inventory in PipeStats.cpp for exactly what is and is not in this number.
AccessorCalls,
// Texture upload emissions: one per (upload target, level) that actually shipped
// texels. The eventual resource_subdata record count.
TextureUploadEmissions,
// Emissions that took the union-box shape (one driver upload job).
TextureUploadBoxEmissions,
// Emissions that took the refined rect-list shape (N driver upload jobs). The
// box/rect split is the thing SSIM cannot see and the +6 ms/frame Mali cliff came
// from, so it is counted separately from the byte total.
TextureUploadRectEmissions,
// Driver upload jobs issued by those emissions: 1 per box emission, N per rect-list
// emission.
TextureUploadJobs,
#if MOBILEGL_PIPE_PUSH
// P2's two, and they are PUSH-ONLY on purpose: a render-state CSO exists only in a
// push build, and the pull build has to stay symbol-identical (G1) - growing this
// enum there would resize the counter arrays, the name table and FormatWindowLine
// for a pair of counters that could never leave zero.
//
// Render-state CSOs MINTED: a pipeline-subset hash that missed the CsoCache and had
// to be created. The Blaze3D blend toggle is the shape this exists to answer for -
// enable/draw/disable/draw forever must mint 2 and then never mint again - and it is
// half of what a P13 retune of the 64-entry capacity reads.
RenderStateCsoMints,
// Render-state CSOs BOUND: one per bind_render_state, mint or reuse. mints/binds is
// the cache's hit rate, and it is the number the CSO content-addressing negative
// control moves.
RenderStateCsoBinds,
// P3a's, and push-only for the same reason as the two above.
//
// EVERY map_persistent EMISSION, i.e. every acquisition ATTEMPT - a mint or a decline
// - because every one of them needs an answer from the resource owner. Counted that
// way on purpose: "round trips actually taken" is 0 by construction in a monolith and
// could never go red, which is not a counter, it is a decoration. Counted as attempts
// the number is identical in both modes, it is exactly "one per storage definition",
// and a regression that acquires per DRAW instead of per definition shows up on the
// first window. Counted at the client emitter, behind the usual Enabled() predicate;
// no timer anywhere.
MapPersistentRoundtrips,
// P4a's five, push-only for the same reason as the three above, and every one of them
// counts a record that ACTUALLY WENT OUT - post-suppressor - because the number an
// operator needs is the traffic, not the number of times the emitter was asked.
//
// The four set counters are how the suppressors' hit rates become readable at all: a
// suppressor that stopped suppressing is invisible in the pixels and shows up here as
// a per-frame count that tracks the draw count instead of the state changes.
FramebufferEmissions,
SamplerViewEmissions,
SamplerStateEmissions,
ShaderImageEmissions,
// The CLIENT-side twin of Espryt's TextureUploadEmissions, which counts the same
// records on the server. Two published numbers rather than one is the whole point:
// SSIM is completely blind to the box-versus-rect upload shape, and the Mali cliff it
// hides is ~+6 ms/frame, so an emission-shape divergence has to be a difference of two
// numbers rather than something only a GPU can see.
ClientTextureUploadEmissions,
// THE TEXTURE-REMINT PULL RATE (ROADMAP open question 2; P4a final review M-A). Counted
// by Espryt once per transition in which a texture that ALREADY HAD backend storage is
// re-minted image-bindable and its defined levels are replayed from the client's shadow
// (RequireImageBindableStorage) - the reach-back a split cannot make (D-M) and the one
// ImageBindableHint exists to prevent. A texture whose hint arrived before its first
// sync is allocated image-bindable up front and never counts. `trp=` on the summary
// line; the number that decides MOBILEGL_PIPE_TEXEL_RETAIN_MB's default.
TextureRemintPulls,
// P5's, and push-only for the same reason as the nine above.
//
// `rsp` - THE SIZE OF THE P6/P7/P8 DEBT. One per read, on the server side, of a
// BARRIER-PULLED PipeInputs field (CONTRACT-P5.md table 2): a field no pushed record
// supplies, which the server answers by reading the value the CLIENT's residual fill
// left in the single shared gPipeInputs while the verb barrier holds both threads
// apart. That is correct only because of the barrier, which is what makes the barrier
// load-bearing rather than cautious - so the count is the debt, and a phase that
// retires a family of fields is expected to move it down.
//
// Counted at the ONE place that decides what a stale read means
// (MG_Backend/MGPipe/PipeInputs.cpp), so the 56 checked accessors and the seven sticky
// forwards cannot drift apart on it. It is zero in every monolith lane by
// construction: nothing arms it but a server verb-boundary stamp.
ResidualPulls,
#endif
Count
};
#if MOBILEGL_PIPE_PUSH
// P5's GAUGES, and they are a THIRD KIND of counter rather than three more CallClass rows.
//
// A ByteClass and a CallClass are SUMS this module owns and a call site increments. These
// three are neither: they are the wire producer's own running readings - a MAXIMUM and two
// RUN TOTALS that live on MG_Remote's encoder, which this module cannot see and must not
// link against (MG_Util is below MG_Remote, and the pull build has no MG_Remote at all).
// The owner publishes its current value at the frame boundary and this module prints the
// last one it was given. Summing them here would be wrong twice: a maximum is not additive,
// and the encoder already holds the run total, so adding deltas would double-count.
//
// THEY ARE RUN TOTALS ON A WINDOWED LINE, deliberately and against the file's own habit.
// Everything else on the summary line covers "since the previous line" because a run total
// over a workload whose shape changes hides the number P2 wants. These three are the
// opposite: "the largest record this run ever wrote" and "did the ring ever wrap" are
// questions about the RUN, and a windowed maximum would read 0 in every window that did not
// happen to contain the biggest record - which is the shape of a proof obligation that
// cannot fail. The label says so in the line itself (`maxrec=` is bytes, not bytes/frame).
//
// PUSH-ONLY for the reason every counter added since P2 is: the pull build must stay
// symbol-identical (gate G1), and a gauge whose only publisher is MG_Remote could never
// leave zero there.
enum class Gauge : Uint32 {
// R-10's PROOF OBLIGATION. The largest single record the wire encoder has written, in
// bytes, and the cap it must stay under - RingProducer::MaxRecordBytes() ==
// MOBILEGL_IPC_RING_MB / 2. P5 does no chunking and has to prove it needs none; before
// this pair existed the only consumers of PipeWireEncoder::MaxRecordBytesSeen() were
// codec unit tests, so BRIEF 8 item 3 had no measurement from any real workload
// (joint-v1.md 5, "Maximum record bytes: NO MEASUREMENT").
MaxRecordBytes = 0,
MaxRecordBytesCap,
// R-9's three producer readings. `RingWraps` is SEG_CMD going ROUND - the head crossing
// a multiple of the capacity - which is the event exit gate E3(e)'s small-ring lane
// asserts, because it is guaranteed once the workload writes more bytes than the ring
// holds. `RingWrapPads` is the kRecPad fillers laid when a record would have STRADDLED
// that boundary, which is R-9's "a pad does not advance seq" path and is RECORDED, not
// asserted: a uniform record stride over a power-of-two ring lands on the boundary
// exactly and never straddles it (measured). `RingWaits` is SEG_STAGE allocations that
// had to wait on the consumer's retiredSeq. See PipeWireCodec.h for why the command
// ring contributes no wait count while the verb barrier is armed.
RingWraps,
RingWrapPads,
RingWaits,
Count
};
// Publishes the owner's current reading. Cheap and unconditional on the caller's side:
// the call sites are per-frame, not per-record.
void PublishGauge(Gauge gauge, Uint64 value);
Uint64 GaugeValue(Gauge gauge);
#endif
// Memo gates. Each is a place where a backend decides "nothing moved, skip the work".
// Hit == the gate short-circuited; Miss == it fell through and did the work. The six
// are exactly the ones section 2.3.1 tabulates.
enum class Gate : Uint32 {
// DirectGLES.cpp SyncRenderState: the render-state-version early-out.
EsprytRenderState = 0,
// DirectGLES.cpp SyncNeccessaryTextures: the six-value sync-list key compare.
EsprytTextureSyncList,
// DirectGLES.cpp CurrentUnitBindingsEpoch: the (context, max unit, bind generation)
// shutter over the unit walk.
EsprytUnitBindingsEpoch,
// VulkanRenderer.cpp TrySetupDrawFastPath: the whole snapshot fast path.
MagmaDrawFastPath,
// VulkanRenderer.cpp GetOrCreatePipeline: the pipeline memo.
MagmaPipelineMemo,
// VulkanRenderer.cpp ApplyDynamicDrawStateTail: the version+extent tail gate.
MagmaDynamicTail,
Count
};
// Per-draw command payload size histogram (plan section 4.5.7: SEG_CMD has to be sized
// off the DISTRIBUTION, not off a per-frame total). PLACEHOLDER in P0: MGPipe emits no
// records yet, so nothing in the backends calls RecordDrawPayloadBytes. The bucketing
// and the reporting are implemented and unit-tested so that the first generator to
// emit records only has to add the one call.
inline constexpr Uint32 kPayloadHistogramBuckets = 24;
// Frames between two summary lines when MOBILEGL_PIPE_STATS=1.
inline constexpr Uint64 kDefaultSummaryFramePeriod = 120;
// The period Init() latched from MOBILEGL_PIPE_STATS_PERIOD (kDefaultSummaryFramePeriod
// when unset); never 0.
Uint64 SummaryFramePeriod();
// The latch. Read directly by Enabled() so the off path is a global load and a
// predicted branch - do not turn this into a function call.
extern Bool g_pipeStatsEnabled;
inline Bool Enabled() { return g_pipeStatsEnabled; }
// Latches g_pipeStatsEnabled from MG_Config::Features.PipeStats and clears every
// counter. Called from MobileGL::Initialize() right after the config load.
void Init();
// Final summary line plus, if MOBILEGL_PIPE_STATS_FILE names a path, the JSON dump.
// Called from MobileGL's teardown. Idempotent.
void Shutdown();
void AddBytes(ByteClass byteClass, Uint64 bytes);
void AddCalls(CallClass callClass, Uint64 count);
void CountGate(Gate gate, Bool hit);
void RecordDrawPayloadBytes(Uint64 bytes);
// Frame boundary: publishes the frame's values to Tracy (when TRACY_ENABLE), folds them
// into the run totals, clears the frame accumulators, and every kSummaryFramePeriod
// frames emits the summary line. Called from each backend's Present().
void OnPresent();
// --- introspection, for the unit test and the JSON dump -------------------------
Uint64 FrameBytes(ByteClass byteClass);
Uint64 TotalBytes(ByteClass byteClass);
Uint64 FrameCalls(CallClass callClass);
Uint64 TotalCalls(CallClass callClass);
Uint64 TotalGateHits(Gate gate);
Uint64 TotalGateMisses(Gate gate);
Uint64 TotalPayloadBucket(Uint32 bucket);
Uint64 FrameCount();
const char* NameOf(ByteClass byteClass);
const char* NameOf(CallClass callClass);
const char* NameOf(Gate gate);
// The compact fixed-format one-liner MGLOG_I prints, covering the CURRENT window (see
// AdvanceSummaryWindow). PURE: calling it twice returns the same text and changes no
// counter, so a probe, a test or a second reporting channel can format the window
// without stealing it from the log.
String FormatWindowLine();
// Closes the current window: the run totals as of now become the base the next
// FormatWindowLine() subtracts. Emitting the line and advancing the window are separate
// on purpose - the pair used to be one function whose name promised a formatter.
void AdvanceSummaryWindow();
// The teardown dump. Run totals only: a per-frame JSON stream is a different tool.
String FormatJson();
// Test hooks, used by no shipping path. Init() clears the counters through an internal
// ResetCounters() rather than by calling ResetForTesting().
void SetEnabledForTesting(Bool enabled);
void ResetForTesting();
} // namespace MobileGL::MG_Util::PipeStats