mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-18 00:58:30 +09:00
327 lines
19 KiB
C++
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
|