Compare commits

...
Author SHA1 Message Date
BZLZHH d3ec9b8179 [Docs] (docs/Performance): add a skill for reading Mali GPU counters 2026-11-01 19:51:20 +08:00
BZLZHH 05dfff9100 [Perf] (MG_Backend/DirectVulkan, MG_IntegrationTest/Scenarios): defer window clears while a window pass is open
- A whole-window clear with a window pass open ends that pass and folds
  into the next one, instead of running a load-clear-store pass of its own
- A present drops the depth/stencil half of a pending window clear
2026-11-01 19:20:48 +08:00
BZLZHH 6c59b0391c [Perf] (MG_Backend/DirectVulkan): defer window clears into the next window pass
- A whole-window glClear with no pass open on the window waits for the next
  window pass, which loads it with LOAD_OP_CLEAR; other uses run it first
- The first window pass after a present loads undefined content as DONT_CARE
2026-11-01 13:42:44 +08:00
BZLZHH d6fd101d2a [Build] (CMakeLists.txt, docs/Disaggregated): default to the split in-process build 2026-10-31 20:59:18 +08:00
BZLZHH 259acb2c57 Merge pull request #21 from MobileGL-Dev/feat/disaggregated
[Feat] (Disaggregated): replace MobileGL's monolithic architecture with a disaggregated client/server one
2026-10-31 20:49:41 +08:00
12 changed files with 602 additions and 95 deletions
+14 -14
View File
@@ -14,20 +14,20 @@ option(MOBILEGL_ENABLE_TRACY "Enable tracy for profiling"
option(MOBILEGL_BUILD_TRACE_REPLAY "Build desktop apitrace replay runner" OFF)
option(MOBILEGL_TRACE_ANGLE_VARIANTS "Enable signed trace-APK ANGLE variant loading" OFF)
option(MOBILEGL_IOS "Build MobileGL for iOS instead of macOS when APPLE is set" OFF)
# The disaggregated (two-process) shape. OFF is the shipping default and OFF
# must stay byte-comparable to a tree without MG_Remote at all: nothing under
# MobileGL/MG_Remote/ is compiled, no include path is added, and no library is
# linked, so `nm --defined-only libMobileGL.so | grep -i MG_Remote` is empty.
# That emptiness is one of the two byte-level equalities the plan's validation
# gates keep (section 10.3).
option(MOBILEGL_BUILD_DISAGGREGATED "Build the MG_Remote transport layer (two-process shape)" OFF)
# The CI / debugging shape (ARCHITECTURE.md §16): both roles in ONE process, talking over the
# same SEG_CMD ring and the same G3 codec a spawned server would use. It IMPLIES
# MOBILEGL_BUILD_DISAGGREGATED (below) and additionally admits the role-isolation shims that
# only make sense when the two roles share an address space. It is a SUPERSET, never a
# substitute: MOBILEGL_TRANSPORT=inproc is what selects the shape at run time, and this option
# only decides whether the shims are compiled in.
option(MOBILEGL_BUILD_DISAGGREGATED_INPROC "Compile the in-process (one-process, two-role) split shims; implies MOBILEGL_BUILD_DISAGGREGATED" OFF)
# The disaggregated (two-process) shape, ON by default: the split is the development default and
# the shipping library configures with -DMOBILEGL_BUILD_DISAGGREGATED=OFF. That OFF build must
# stay byte-comparable to a tree without MG_Remote at all: nothing under MobileGL/MG_Remote/ is
# compiled, no include path is added, and no library is linked, so
# `nm --defined-only libMobileGL.so | grep -i MG_Remote` is empty. That emptiness is one of the
# two byte-level equalities the plan's validation gates keep (section 10.3).
option(MOBILEGL_BUILD_DISAGGREGATED "Build the MG_Remote transport layer (two-process shape)" ON)
# The in-process split shape (ARCHITECTURE.md §16), ON by default with the option above: both
# roles in ONE process, talking over the same SEG_CMD ring and the same G3 codec a spawned server
# would use. It IMPLIES MOBILEGL_BUILD_DISAGGREGATED (below) and additionally admits the
# role-isolation shims that only make sense when the two roles share an address space. It is a
# SUPERSET, never a substitute: MOBILEGL_TRANSPORT=inproc is what selects the shape at run time,
# and this option only decides whether the shims are compiled in.
option(MOBILEGL_BUILD_DISAGGREGATED_INPROC "Compile the in-process (one-process, two-role) split shims; implies MOBILEGL_BUILD_DISAGGREGATED" ON)
option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery-chain executable (Android only)" OFF)
option(MOBILEGL_BUILD_EXTMEM_PROBE "Package tools/spikes/extmem_probe as libMobileGLExtmemProbe.so for the trace APK's exec hook (P11 A3, Android only)" OFF)
option(MOBILEGL_EXTMEM_PROBE_AS_SERVER "Spike only (P11 B2 step 1): also package the probe as libMobileGLServer.so, the real server as libMobileGLServerReal.so" OFF)
@@ -132,6 +132,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
private:
VkTextureManager& m_manager;
};
// True inside a PendingClearHold: the image being resolved is an attachment of the pass
// being prepared, which takes pending clears as its load op.
Bool PendingClearsHeld() const { return m_pendingClearHold != 0; }
// A whole-subresource clear that was recorded but has not run yet (VulkanRenderer::DeferWireClear).
struct WirePendingClear {
Uint32 level = 0, layer = 0, layers = 1;
@@ -620,7 +620,30 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 level = 0, layer = 0, layers = 1;
};
Vector<WireFoldClear> m_wireFoldClears;
// Attachments of the pass being prepared whose content is undefined: a default-framebuffer
// image first used since its present. Their load op becomes DONT_CARE (WireClearVariant).
Vector<Uint32> m_wireDiscardLoads;
static Bool WireImageCanDeferClear(const WireImage& image);
// A whole-image clear of the default framebuffer, held for the next pass that draws into
// the window (its load op) the way WirePendingClear holds a texture's. Any other use of the
// window images, and the present, run it first (MaterializeDefaultWirePendingClear).
struct DefaultWirePendingClear {
VkSwapchainKHR swapchain = VK_NULL_HANDLE;
Uint32 swapchainImageIndex = 0;
Bool color = false;
VkImageAspectFlags depthStencil = 0;
ClearAttachmentPayload colorPayload{};
ClearAttachmentPayload depthStencilPayload{};
Bool Pending() const { return color || depthStencil != 0; }
};
DefaultWirePendingClear m_defaultWirePendingClear;
GLbitfield DeferDefaultWireClear(const MG_Pipe::MGPFramebufferState& fbo, const ClearAttachmentPayload& payload,
GLint drawbuffer);
void MaterializeDefaultWirePendingClear();
void FoldDefaultWirePendingClear(RenderPassEntry& begun, Uint64& variantKey, VkClearValue* merged);
// Records a clear of `area` of one wire image in a single-attachment pass of its own.
void RecordWireImageClear(WireImage& image, VkImageAspectFlags aspect, const ClearAttachmentPayload& payload,
const VkRect2D& area, Bool textureSurface);
static void NoteWirePendingClear(const WireImage& image, VkImageAspectFlags aspects, const VkClearValue& value);
VkRenderPass WireClearVariant(RenderPassEntry& begun, Uint64 variantKey);
Bool BeginWireDrawPassFolding(FrameContext::FrameData& frame, RenderPassEntry& begun, Uint64 variantKey,
@@ -16,6 +16,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Present has its own submit path. Tag its last wire pass before that
// submission, rather than retaining it until a draw in the next frame.
RetireWireDrawPass();
// A window color clear no pass drew after still has to reach the presented image. Its
// depth/stencil half is dropped: a swap leaves those undefined (see the content flags
// reset below). A frame that cannot be presented drops the whole clear.
if (m_swapchainObject.GetHandle() == VK_NULL_HANDLE || m_presentSuspended) {
m_defaultWirePendingClear = {};
} else {
m_defaultWirePendingClear.depthStencil = 0;
MaterializeDefaultWirePendingClear();
}
#endif
if (m_swapchainObject.GetHandle() == VK_NULL_HANDLE || m_presentSuspended) {
// No usable swapchain: the window was zero-area at initialization, or
@@ -9,6 +9,14 @@
#include "VulkanRendererInternal.h"
namespace MobileGL::MG_Backend::DirectVulkan {
namespace {
// A wire pass variant key holds two clear bits per attachment from bit 0 and, from this
// bit on, one bit per attachment whose content is undefined (WireClearVariant).
constexpr Uint32 kWireDiscardBit = 32;
static_assert(2 * (MG_Pipe::kMGPipeMaxColorAttachments + 1) <= kWireDiscardBit,
"the clear bits of a wire pass variant key overlap its discard bits");
} // namespace
// GL_EXT_fragment_shading_rate's pipeline rate and combiner ops for the draw about to be
// recorded. The packed rate is log2(width) << 2 | log2(height), which is exactly the extent
// VK_KHR_fragment_shading_rate takes, and the combiner op indices follow
@@ -445,6 +453,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
WireDrawScratch& scratch, Bool passContinues, Bool& continuing) {
continuing = true;
m_wireFoldClears.clear();
m_wireDiscardLoads.clear();
if (passContinues) {
auto& store = MG_Record::ServerStagedTexture();
if (!m_wirePassMarksValid || m_wirePassMarksClearGeneration != store.GpuDirtyClearGeneration()) {
@@ -536,6 +545,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (!image.isDefault && surface.Kind == MG_Pipe::kMGPipeSurfaceKindTexture)
scratch.gpuWriteMarks.push_back({image.storage, image.level, image.layer, image.layers});
if (image.isDefault) {
// A present leaves the default framebuffer undefined (VulkanRenderer::Present):
// the first pass after it has nothing to load.
const Bool defined =
aspect == VK_IMAGE_ASPECT_COLOR_BIT
? m_swapchainObject.IsImageContentDefined(image.swapchainImageIndex)
: m_swapchainObject.IsDepthStencilContentDefined(image.swapchainImageIndex);
if (!defined) m_wireDiscardLoads.push_back(static_cast<Uint32>(attachments.size() - 1));
if (aspect == VK_IMAGE_ASPECT_COLOR_BIT)
m_swapchainObject.SetImageContentDefined(image.swapchainImageIndex, true);
else
@@ -1361,9 +1377,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (entry.first == variantKey) return entry.second;
const Uint32 attachmentCount = static_cast<Uint32>(begun.wireAttachments.size());
auto descriptions = begun.wireAttachments;
// Bits 2a and 2a+1 clear attachment a (color or depth, and stencil); bit kWireDiscardBit + a
// marks its content undefined, so whatever is not cleared is not loaded either.
for (Uint32 a = 0; a < attachmentCount; ++a) {
if (variantKey >> (2 * a) & 1) descriptions[a].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
if (variantKey >> (2 * a + 1) & 1) descriptions[a].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
const Bool discard = variantKey >> (kWireDiscardBit + a) & 1;
if (variantKey >> (2 * a) & 1)
descriptions[a].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
else if (discard)
descriptions[a].loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
if (variantKey >> (2 * a + 1) & 1)
descriptions[a].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
else if (discard)
descriptions[a].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
}
VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
@@ -1382,6 +1407,139 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return variant;
}
// The default framebuffer's half of the deferred clears: a whole-window glClear waits for the
// next pass that draws into the window, which loads it with LOAD_OP_CLEAR
// (BeginWireDrawPassFolding) instead of storing a cleared window and loading it back. A pass
// open on the window ends first, since the in-pass clear does not take the window
// (MagmaInPassClearDefault); a present drops the depth/stencil half (Present). Returns the
// buffers it did not take.
GLbitfield VulkanRenderer::DeferDefaultWireClear(const MG_Pipe::MGPFramebufferState& fbo,
const ClearAttachmentPayload& payload, GLint drawbuffer) {
GLbitfield remaining = payload.mask & (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
if (!remaining || !fbo.IsDefault || drawbuffer >= 0 || m_wirePreparationDepth != 0) return remaining;
if (GLScissorTestClips()) return remaining;
GLbitfield handled = 0;
Bool color = false;
VkImageAspectFlags depthStencil = 0;
if (remaining & GL_COLOR_BUFFER_BIT) {
const Int32 slot = fbo.DrawBuffers[0];
const auto mask = MG_Pipe::gPipeInputs->GetColorMaskIndexed(0);
const Bool none = slot < 0 || (!mask.r() && !mask.g() && !mask.b() && !mask.a());
if (none)
handled |= GL_COLOR_BUFFER_BIT;
else if (mask.r() && mask.g() && mask.b() && mask.a() &&
payload.colorEncoding == ClearColorEncoding::Float) {
handled |= GL_COLOR_BUFFER_BIT;
color = true;
}
}
// A window without a depth/stencil buffer clears nothing there.
const VkFormat depthStencilFormat = m_swapchainObject.GetDepthStencilFormat();
const Bool hasDepth = depthStencilFormat != VK_FORMAT_UNDEFINED;
const Bool packed = vkuFormatIsDepthAndStencil(depthStencilFormat);
if (remaining & GL_DEPTH_BUFFER_BIT) {
handled |= GL_DEPTH_BUFFER_BIT;
if (hasDepth && MG_Pipe::gPipeInputs->GetDepthMask()) depthStencil |= VK_IMAGE_ASPECT_DEPTH_BIT;
}
if (remaining & GL_STENCIL_BUFFER_BIT) {
const Uint32 mask = MG_Pipe::gPipeInputs->GetStencilState(StencilFace::Front).WriteMask & 255u;
if (mask == 0 || !packed)
handled |= GL_STENCIL_BUFFER_BIT;
else if (mask == 255u) {
handled |= GL_STENCIL_BUFFER_BIT;
depthStencil |= VK_IMAGE_ASPECT_STENCIL_BIT;
}
}
if (!color && depthStencil == 0) return remaining & ~handled;
if (VkRenderPassManager::GetActiveRenderPass() != nullptr && m_wireDrawPass && m_wireDrawPassKey.isDefault)
RetireWireDrawPass();
// The index the next drawing pass on the window will address (WireDraw.cpp's pass key).
const Uint32 index = DefaultFramebufferWriteIndex(true);
auto& pending = m_defaultWirePendingClear;
if (pending.Pending() &&
(pending.swapchain != m_swapchainObject.GetHandle() || pending.swapchainImageIndex != index))
MaterializeDefaultWirePendingClear();
pending.swapchain = m_swapchainObject.GetHandle();
pending.swapchainImageIndex = index;
if (color) {
pending.color = true;
pending.colorPayload = payload;
m_swapchainObject.SetImageContentDefined(index, true);
}
if (depthStencil & VK_IMAGE_ASPECT_DEPTH_BIT) pending.depthStencilPayload.depth = payload.depth;
if (depthStencil & VK_IMAGE_ASPECT_STENCIL_BIT) pending.depthStencilPayload.stencil = payload.stencil;
if (depthStencil) {
pending.depthStencil |= depthStencil;
m_swapchainObject.SetDepthStencilContentDefined(index, true);
}
return remaining & ~handled;
}
void VulkanRenderer::MaterializeDefaultWirePendingClear() {
if (!m_defaultWirePendingClear.Pending()) return;
const DefaultWirePendingClear pending = m_defaultWirePendingClear;
m_defaultWirePendingClear = {};
// A recreated swapchain took the images the clear was for.
if (pending.swapchain != m_swapchainObject.GetHandle() ||
pending.swapchainImageIndex >= m_swapchainObject.GetImageCount())
return;
const VkExtent2D extent = m_swapchainObject.GetExtent();
const VkRect2D area{{0, 0}, extent};
WireImage image{};
image.isDefault = true;
image.extent = extent;
image.swapchainImageIndex = pending.swapchainImageIndex;
if (pending.color) {
image.aspect = VK_IMAGE_ASPECT_COLOR_BIT;
image.image = m_swapchainObject.GetImage(pending.swapchainImageIndex);
image.format = m_swapchainObject.GetSurfaceFormat().format;
image.layout = m_swapchainObject.GetImageLayout(pending.swapchainImageIndex);
RecordWireImageClear(image, VK_IMAGE_ASPECT_COLOR_BIT, pending.colorPayload, area, false);
}
if (pending.depthStencil && m_swapchainObject.GetDepthStencilImage(pending.swapchainImageIndex)) {
image.aspect = pending.depthStencil;
image.image = m_swapchainObject.GetDepthStencilImage(pending.swapchainImageIndex);
image.format = m_swapchainObject.GetDepthStencilFormat();
image.layout = m_swapchainObject.GetDepthStencilImageLayout(pending.swapchainImageIndex);
RecordWireImageClear(image, pending.depthStencil, pending.depthStencilPayload, area, false);
}
}
// A pending window clear (DeferDefaultWireClear) becomes the load op of the window pass that
// begins: its attachments are matched by image, since a continued pass key is not walked again.
// A clear the caller already put in `variantKey` is newer and wins.
void VulkanRenderer::FoldDefaultWirePendingClear(RenderPassEntry& begun, Uint64& variantKey, VkClearValue* merged) {
auto& pending = m_defaultWirePendingClear;
if (!pending.Pending() || !m_wireDrawPassKey.isDefault || m_wireDrawPassKey.swapchain != pending.swapchain ||
m_wireDrawPassKey.swapchainImageIndex != pending.swapchainImageIndex)
return;
const Uint32 attachmentCount =
static_cast<Uint32>(std::min<SizeT>(begun.wireAttachments.size(), m_wireDrawPassKey.attachments.size()));
const VkImage colorImage = m_swapchainObject.GetImage(pending.swapchainImageIndex);
const VkImage depthStencilImage = m_swapchainObject.GetDepthStencilImage(pending.swapchainImageIndex);
for (Uint32 a = 0; a < attachmentCount && a < MG_Pipe::kMGPipeMaxColorAttachments + 1; ++a) {
const auto& attachment = m_wireDrawPassKey.attachments[a];
if (pending.color && attachment.image == colorImage) {
if (!(variantKey >> (2 * a) & 1)) {
variantKey |= 1ull << (2 * a);
merged[a].color = MakeVkClearColorValue(pending.colorPayload, false);
}
pending.color = false;
} else if (pending.depthStencil && attachment.image == depthStencilImage) {
if ((pending.depthStencil & VK_IMAGE_ASPECT_DEPTH_BIT) && !(variantKey >> (2 * a) & 1)) {
variantKey |= 1ull << (2 * a);
merged[a].depthStencil.depth = pending.depthStencilPayload.depth;
}
if ((pending.depthStencil & VK_IMAGE_ASPECT_STENCIL_BIT) && !(variantKey >> (2 * a + 1) & 1)) {
variantKey |= 1ull << (2 * a + 1);
merged[a].depthStencil.stencil = pending.depthStencilPayload.stencil;
}
pending.depthStencil = 0;
}
}
// An aspect the pass does not attach stays pending: none of its draws write it.
}
// P15: EVERY DRAW PASS BEGINS HERE. The pending clears of its attachments
// (m_wireFoldClears, gathered by PrepareWireDrawPass) become their load op and leave the
// images; a clear the caller already put in `variantKey` (its own glClear, newer than any
@@ -1426,6 +1584,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
m_wireFoldClears.clear();
FoldDefaultWirePendingClear(begun, variantKey, merged);
for (const Uint32 a : m_wireDiscardLoads)
if (a < attachmentCount) variantKey |= 1ull << (kWireDiscardBit + a);
m_wireDiscardLoads.clear();
if (variantKey == 0) return VkRenderPassManager::BeginRenderPass(frame.commandBuffer, begun);
return VkRenderPassManager::BeginRenderPass(frame.commandBuffer, begun, WireClearVariant(begun, variantKey),
merged, attachmentCount);
@@ -658,6 +658,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
WireImage out{};
out.aspect = aspect;
if (fbo.IsDefault) {
// Only an attachment of the pass being prepared takes a pending window clear as its
// load op; every other use runs it first.
if (m_defaultWirePendingClear.Pending() && !m_textureManager->PendingClearsHeld())
MaterializeDefaultWirePendingClear();
out.isDefault = true;
out.extent = m_swapchainObject.GetExtent();
out.swapchainImageIndex =
@@ -1109,6 +1113,87 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return remaining & ~handled;
}
void VulkanRenderer::RecordWireImageClear(WireImage& image, VkImageAspectFlags aspect,
const ClearAttachmentPayload& payload, const VkRect2D& area,
Bool textureSurface) {
const Bool color = aspect == VK_IMAGE_ASPECT_COLOR_BIT;
const auto layout =
color ? VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL : VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
TransitionWireImage(image, layout);
VkImageViewCreateInfo viewInfo{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
viewInfo.image = image.image;
viewInfo.viewType = image.viewType;
viewInfo.format = image.format;
viewInfo.subresourceRange = {
color ? VK_IMAGE_ASPECT_COLOR_BIT
: (image.format == VK_FORMAT_D24_UNORM_S8_UINT || image.format == VK_FORMAT_D32_SFLOAT_S8_UINT
? VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT
: image.aspect),
image.level, 1, image.layer, image.layers};
VkImageView view = VK_NULL_HANDLE;
VK_VERIFY(vkCreateImageView(m_device, &viewInfo, nullptr, &view));
VkAttachmentDescription attachment{};
attachment.format = image.format;
attachment.samples = image.samples;
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_STORE;
attachment.initialLayout = layout;
attachment.finalLayout = layout;
VkAttachmentReference ref{0, layout};
VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = color ? 1 : 0;
subpass.pColorAttachments = color ? &ref : nullptr;
subpass.pDepthStencilAttachment = color ? nullptr : &ref;
VkRenderPassCreateInfo passInfo{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO};
passInfo.attachmentCount = 1;
passInfo.pAttachments = &attachment;
passInfo.subpassCount = 1;
passInfo.pSubpasses = &subpass;
VkRenderPass pass = VK_NULL_HANDLE;
VK_VERIFY(vkCreateRenderPass(m_device, &passInfo, nullptr, &pass));
VkFramebufferCreateInfo fbInfo{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO};
fbInfo.renderPass = pass;
fbInfo.attachmentCount = 1;
fbInfo.pAttachments = &view;
fbInfo.width = image.extent.width;
fbInfo.height = image.extent.height;
fbInfo.layers = image.layers;
VkFramebuffer fb = VK_NULL_HANDLE;
VK_VERIFY(vkCreateFramebuffer(m_device, &fbInfo, nullptr, &fb));
VkRenderPassBeginInfo begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
begin.renderPass = pass;
begin.framebuffer = fb;
begin.renderArea = area;
auto clearPayload = payload;
if (color) PreCompensateSrgbClearColor(clearPayload, image.format);
VkClearAttachment clear{};
clear.aspectMask = aspect;
clear.clearValue.color = MakeVkClearColorValue(clearPayload, false);
if (!color) clear.clearValue.depthStencil = {payload.depth, payload.stencil};
VkClearRect rect{area, 0, image.layers};
auto& frame = m_frameContext.GetCurrent();
EndActiveRenderPassOn(frame.commandBuffer);
vkCmdBeginRenderPass(frame.commandBuffer, &begin, VK_SUBPASS_CONTENTS_INLINE);
vkCmdClearAttachments(frame.commandBuffer, 1, &clear, 1, &rect);
vkCmdEndRenderPass(frame.commandBuffer);
WireRetiredObjects retired{};
retired.framebuffers.push_back(fb);
retired.renderPasses.push_back(pass);
retired.imageViews.push_back(view);
RetireWireObjects(Move(retired));
if (!image.isDefault && textureSurface)
m_textureManager->MarkWireTextureGpuWritten(image.storage, image.level, image.layer, image.layers);
if (image.isDefault) {
if (color)
m_swapchainObject.SetImageContentDefined(image.swapchainImageIndex, true);
else
m_swapchainObject.SetDepthStencilContentDefined(image.swapchainImageIndex, true);
}
}
void VulkanRenderer::ClearWireFramebuffer(const MG_Pipe::MGPFramebufferState& fbo,
const ClearAttachmentPayload& clearRequest, GLint drawbuffer) {
if (MG_Pipe::gPipeInputs->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) return;
@@ -1126,6 +1211,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
payload.mask = ClearWireFramebufferInPass(fbo, payload, drawbuffer);
if (payload.mask) payload.mask = ClearWireAttachmentsOfOpenPass(fbo, payload, drawbuffer);
if (payload.mask) payload.mask = DeferWireClear(fbo, payload, drawbuffer);
if (payload.mask) payload.mask = DeferDefaultWireClear(fbo, payload, drawbuffer);
if (payload.mask) payload.mask = ClearWireFramebufferInPass(fbo, payload, drawbuffer);
if (!payload.mask) return;
RetireWireDrawPass();
@@ -1177,82 +1263,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_swapchainObject.GetPreTransform(), &area.offset, &area.extent))
MagmaWireFatal("clear-surface-transform");
}
const Bool color = aspect == VK_IMAGE_ASPECT_COLOR_BIT;
const auto layout =
color ? VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL : VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
TransitionWireImage(image, layout);
VkImageViewCreateInfo viewInfo{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
viewInfo.image = image.image;
viewInfo.viewType = image.viewType;
viewInfo.format = image.format;
viewInfo.subresourceRange = {
color ? VK_IMAGE_ASPECT_COLOR_BIT
: (image.format == VK_FORMAT_D24_UNORM_S8_UINT || image.format == VK_FORMAT_D32_SFLOAT_S8_UINT
? VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT
: image.aspect),
image.level, 1, image.layer, image.layers};
VkImageView view = VK_NULL_HANDLE;
VK_VERIFY(vkCreateImageView(m_device, &viewInfo, nullptr, &view));
VkAttachmentDescription attachment{};
attachment.format = image.format;
attachment.samples = image.samples;
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_STORE;
attachment.initialLayout = layout;
attachment.finalLayout = layout;
VkAttachmentReference ref{0, layout};
VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = color ? 1 : 0;
subpass.pColorAttachments = color ? &ref : nullptr;
subpass.pDepthStencilAttachment = color ? nullptr : &ref;
VkRenderPassCreateInfo passInfo{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO};
passInfo.attachmentCount = 1;
passInfo.pAttachments = &attachment;
passInfo.subpassCount = 1;
passInfo.pSubpasses = &subpass;
VkRenderPass pass = VK_NULL_HANDLE;
VK_VERIFY(vkCreateRenderPass(m_device, &passInfo, nullptr, &pass));
VkFramebufferCreateInfo fbInfo{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO};
fbInfo.renderPass = pass;
fbInfo.attachmentCount = 1;
fbInfo.pAttachments = &view;
fbInfo.width = image.extent.width;
fbInfo.height = image.extent.height;
fbInfo.layers = image.layers;
VkFramebuffer fb = VK_NULL_HANDLE;
VK_VERIFY(vkCreateFramebuffer(m_device, &fbInfo, nullptr, &fb));
VkRenderPassBeginInfo begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
begin.renderPass = pass;
begin.framebuffer = fb;
begin.renderArea = area;
auto clearPayload = payload;
if (color) PreCompensateSrgbClearColor(clearPayload, image.format);
VkClearAttachment clear{};
clear.aspectMask = aspect;
clear.clearValue.color = MakeVkClearColorValue(clearPayload, false);
if (!color) clear.clearValue.depthStencil = {payload.depth, payload.stencil};
VkClearRect rect{area, 0, image.layers};
auto& frame = m_frameContext.GetCurrent();
EndActiveRenderPassOn(frame.commandBuffer);
vkCmdBeginRenderPass(frame.commandBuffer, &begin, VK_SUBPASS_CONTENTS_INLINE);
vkCmdClearAttachments(frame.commandBuffer, 1, &clear, 1, &rect);
vkCmdEndRenderPass(frame.commandBuffer);
WireRetiredObjects retired{};
retired.framebuffers.push_back(fb);
retired.renderPasses.push_back(pass);
retired.imageViews.push_back(view);
RetireWireObjects(Move(retired));
if (!image.isDefault && surface.Kind == MG_Pipe::kMGPipeSurfaceKindTexture)
m_textureManager->MarkWireTextureGpuWritten(image.storage, image.level, image.layer, image.layers);
if (image.isDefault) {
if (color)
m_swapchainObject.SetImageContentDefined(image.swapchainImageIndex, true);
else
m_swapchainObject.SetDepthStencilContentDefined(image.swapchainImageIndex, true);
}
RecordWireImageClear(image, aspect, payload, area, surface.Kind == MG_Pipe::kMGPipeSurfaceKindTexture);
};
if (payload.mask & GL_COLOR_BUFFER_BIT) {
for (Uint32 i = 0; i < MG_Pipe::kMGPipeMaxColorAttachments; ++i) {
@@ -357,4 +357,35 @@ void main() { o0 = texture(uPalette, vec2(0.5, 0.5)); }
glDeleteProgram(paletteProgram);
}
// A depth clear of the DEFAULT framebuffer while a pass is open on it (a game clearing depth
// before its hand or GUI): the in-pass clear does not take the window, so the clear ends the
// pass and becomes the load op of the next one, which the farther draw must see.
TEST_F(InPassClearScenario, ADefaultFramebufferDepthClearBetweenTestedDrawsDecidesTheSecondDraw) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width(), height = gl.Height();
Build();
for (int frame = 0; frame < 2; ++frame) {
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_BLEND);
glDepthMask(GL_TRUE);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClearDepth(1.0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
Quad(1.0f, 0.0f, 0.0f, 1.0f, -0.5f);
glClear(GL_DEPTH_BUFFER_BIT);
Quad(0.0f, 1.0f, 0.0f, 1.0f, 0.0f);
glDisable(GL_DEPTH_TEST);
EXPECT_EQ(FirstGLError(), 0u);
const Image image = ReadPixels(width, height);
EXPECT_TRUE(Near(image.At(width / 2, height / 2), 0, 255, 0, 255))
<< "frame " << frame << ": the depth clear between the draws let the farther draw pass";
gl.EndFrame();
}
}
} // namespace MGITest
@@ -18,9 +18,9 @@ MobileGL/MG_Remote/ only with MOBILEGL_BUILD_DISAGGREGATED: CONTRACT-*.
SurfaceControlFrame, ServerDisplay, InProcessServer, DisplayServerJni
```
- `MOBILEGL_BUILD_DISAGGREGATED` (default OFF) adds `MG_Remote/**`; when OFF, `MG_Config::Transport` is a `constexpr Monolith`. `MOBILEGL_BUILD_DISAGGREGATED_INPROC` implies the former and adds role isolation: the two roles are separated by the apply thread and the control mailbox, rather than by adding TLS to the 1494 `pGLContext->` read points.
- `MOBILEGL_BUILD_DISAGGREGATED` (default ON) adds `MG_Remote/**`; when OFF, `MG_Config::Transport` is a `constexpr Monolith`. `MOBILEGL_BUILD_DISAGGREGATED_INPROC` implies the former and adds role isolation: the two roles are separated by the apply thread and the control mailbox, rather than by adding TLS to the 1494 `pGLContext->` read points.
- `MOBILEGL_TRANSPORT = monolith | inproc | spawn` is the **topology** (G1-relevant); the control plane and data plane are selected by the two variables in §11.9. **A split build with `MOBILEGL_TRANSPORT` unset is the monolith control arm**.
- Three build flavors (since P13): default (the monolith record arm, with `MG_Remote` not compiled, the shipping shape), verify, and split; Android has two APK flavors (monolith / split). The pull build and the `MOBILEGL_PIPE_PUSH` CMake option are deleted in P13.
- Three build flavors (since P13): default (the split shape, `MG_Remote` compiled, the in-process shims), verify, and monolith (the shipping shape: configure with `-DMOBILEGL_BUILD_DISAGGREGATED=OFF`, `MG_Remote` not compiled); Android has two APK flavors (monolith / split). The pull build and the `MOBILEGL_PIPE_PUSH` CMake option are deleted in P13.
- Modules (P13 W7): `SOURCE_FILES` is split into seven OBJECT libraries according to the link ratchet's PARTITION, and `MobileGL` / `MobileGL_s` are assembled from them; in a split build `MobileGL_server_linkcheck` links only the SERVER and SHARED modules with `--no-undefined`, so a frontend reference outside the baseline fails at build time (`scripts/ci/server_link_check.py`). The record arm's server-side code is in `MG_Backend/Record/` (`RecordVerbSink`, `StagedShadow`, `StagedTextureStore`), and client verb emission is in `MG_Impl/Pipe/Verb/`, no longer depending on `MG_Remote`.
- Test wiring: the ctest `ENVIRONMENT` is built with `mgl_itest_join_environment(...)`; every split / spawn / tcp entry has its own log path, and logs are split per role (`<base>.client.log` / `<base>.server.log`); the spawn and tcp lanes match the split name set (`scripts/ci/spawn_lane_parity.py`), and each entry records its arm proof.
- CI (`.github/workflows/test.yml`, `apk.yml`): `pipe-gates` (G1–G8, the generator self-test, the symbol report, dirty-surface, field ownership, G5, the documentation-reference lint), `flatc-check`, the include closure, the build / integration / retrace lanes of each flavor, APK + AVD. The `feat/disaggregated` triggers in both workflows are temporary and are removed before merging into `dev`.
@@ -40,7 +40,7 @@ Every lane in `MG_IntegrationTest/CMakeLists.txt` must be able to go red for the
## Appendix A: switches
CMake: `MOBILEGL_BUILD_DISAGGREGATED` (OFF), `MOBILEGL_BUILD_DISAGGREGATED_INPROC` (OFF, implies the former), `MOBILEGL_PIPE_VERIFY` (OFF, never ships), `MOBILEGL_BUILD_SERVER_SPIKE` (Android spike only), `MOBILEGL_FLATC_EXECUTABLE` (serves `flatc-check` only). `MOBILEGL_PIPE_PUSH` and `MOBILEGL_PIPE_LEGACY_MEMOS` are deleted in P13 (an OFF in an old cache only warns and is ignored).
CMake: `MOBILEGL_BUILD_DISAGGREGATED` (ON), `MOBILEGL_BUILD_DISAGGREGATED_INPROC` (ON, implies the former), `MOBILEGL_PIPE_VERIFY` (OFF, never ships), `MOBILEGL_BUILD_SERVER_SPIKE` (Android spike only), `MOBILEGL_FLATC_EXECUTABLE` (serves `flatc-check` only). `MOBILEGL_PIPE_PUSH` and `MOBILEGL_PIPE_LEGACY_MEMOS` are deleted in P13 (an OFF in an old cache only warns and is ignored).
At runtime, MGPipe (`MobileGL/Config.h`, `ConfigLoader.cpp`):
+158
View File
@@ -0,0 +1,158 @@
---
name: mali-gpu-counters
description: Capture and compare Mali GPU hardware counters (fragment/vertex cycles, tiles, ZS, bandwidth, tile write elimination) on a rooted Android phone through perfetto and the vendor's libgpudataproducer, to find where one renderer spends more GPU time than another. Use when a GPU-bound workload is slower on Magma (DirectVulkan) than on the device's GLES driver, or slower after a change, and you need to know which part of the GPU pipeline the gap is in.
---
# Mali GPU performance counters
Use this to answer "where does Magma spend the GPU time that the system GLES driver (or the previous build) doesn't?" on a Mali phone. Two captures, each normalized per frame, show which pipeline stage is responsible: vertex/tiling, fragment shading, depth testing, or memory bandwidth. Do this before guessing at shader or pass-structure fixes.
The workflow was built and verified on a MediaTek MT6893 (Mali-G77 MC9, r32 driver, TrebleDroid). The vendor library path and counter IDs can differ on other devices; step 2 shows how to list them.
## Prerequisites
- A rooted phone (`adb root` or `su`). Check that `/vendor/lib64/libgpudataproducer.so` exists. This is the vendor library behind Android GPU Inspector's counters.
- The workload must be **GPU-bound**. Check `/sys/kernel/ged/hal/gpu_utilization` on MediaTek (first field ≥ 90%). Otherwise counter differences say nothing about FPS.
- Fixed clocks, so cycles are comparable across runs. On MT6893:
- GPU: `echo 836000 > /proc/gpufreq/gpufreq_opp_freq`.
- CPU: `echo 7 1 > /proc/ppm/policy_status`, then `echo <cluster> <kHz>` into `/proc/ppm/policy/userlimit_{min,max}_cpu_freq`.
- The sysfs `scaling_*_freq` files are read-only there.
- Restore everything afterwards.
- Host: the NDK, `protoc`, and a venv with `pip install perfetto` (TraceProcessor downloads its shell binary on first use).
## 1. Start the counter producer
`gpuprod.c` dlopens the vendor library and calls its exported `start()`, which registers the perfetto data source `gpu.counters`.
```sh
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android30-clang -O2 -o gpuprod gpuprod.c
adb push gpuprod /data/local/tmp/ && adb shell chmod 755 /data/local/tmp/gpuprod
adb shell 'su -c "getenforce"' # note the mode to restore later
adb shell 'su -c "setenforce 0; nohup /data/local/tmp/gpuprod > /data/local/tmp/gpuprod.log 2>&1 &"'
adb shell perfetto --query | grep gpu.counters # must list /data/local/tmp/gpuprod
```
It needs SELinux permissive. **Afterwards**, kill the producer (`su -c "kill $(pidof gpuprod)"`) and restore the mode (`setenforce 1` if it was Enforcing).
## 2. List the counters this GPU offers
A capture without `counter_ids` records nothing, and the IDs are device-specific. Read them from the producer's descriptor:
```sh
adb exec-out perfetto --query-raw > qraw.bin
protoc --decode_raw < qraw.bin > qraw.txt
python3 - <<'P'
import re
s = open('qraw.txt').read()
for i, n in re.findall(r'\n\s*1: (\d+)\n\s*2: "([^"\n]{2,90})"\n\s*3: "', s):
print(i, n)
P
```
IDs below 65535 are raw hardware counters. IDs 65535 and up are derived expressions (percentages, rates). Prefer raw counters: they add up over time, while ratios don't.
On the Mali-G77 these raw IDs were the useful set:
| ID | Counter | Use |
|---|---|---|
| 6 | GPU active cycles | total GPU cost |
| 196 / 214 | Fragment / non-fragment active cycles | which side the gap is on |
| 68 | Tiler active cycles | vertex + binning cost |
| 160 / 175 | Output external read / write beats | DRAM bandwidth |
| 210 | Tiles | how many tile passes the frame runs (≈ passes × screen tiles) |
| 211 | Constant tiles killed | tile write-backs eliminated because the tile was unchanged |
| 254 | Tile buffer write beats to L2 | tile write-back traffic |
| 203 / 202 | Rasterized / partial rasterized quads | fragment workload size |
| 204 / 206 | Early ZS tested / killed quads | early depth test working |
| 207 / 208 | Late ZS tested / killed quads | shaders forced to late ZS (discard, depth writes, clears via shader) |
| 212 | FPK occluder quads | forward pixel kill |
| 234 | Texture quads | quads that sample textures |
| 219 / 220 / 221 / 222 | FMA / CVT / SFU / message instructions | shader instruction mix |
| 242 / 243 | 32-bit / 16-bit interpolation active | fp32 vs fp16 varyings |
| 209 | Warps using more than 32 registers | register pressure |
| 70 / 75 | Triangle / visible primitives | geometry identity check |
| 85 / 101 | IDVS position / varying shading requests | vertex shading |
`counters.cfg` captures this set: 5 s at a 20 ms sampling period.
## 3. Capture each renderer
Get each renderer into the same steady scene: same camera, frozen world time, same resolution. Then capture while it runs:
```sh
adb push counters.cfg /data/local/tmp/
adb shell 'cat /data/local/tmp/counters.cfg | perfetto --txt -c - -o /data/misc/perfetto-traces/cnt.pftrace'
adb pull /data/misc/perfetto-traces/cnt.pftrace A.pftrace
```
- Record each run's FPS during the capture (for example, the on-screen FPS counter in a screenshot taken right after).
- Change only the renderer between captures.
- Cool the phone between runs and keep the clocks locked.
## 4. Compare per frame
```sh
python compare.py A.pftrace <fpsA> B.pftrace <fpsB>
```
It sums each counter over the capture, converts to per second, divides by FPS, and prints A/frame, B/frame and B/A, sorted by A's per-frame cost. Many values come out fractional, because the producer averages per-core counters across shader cores. Only the ratios matter.
## 5. Read the result
Work top-down:
1. **GPU active cycles B/A**: the size of the gap. It should roughly match the FPS ratio when GPU-bound.
2. **Fragment vs non-fragment active cycles.** Whichever ratio is above 1 holds the gap. Equal vertex counters (non-fragment, tiler, primitives, IDVS) mean the geometry and vertex shading are identical, so look only at fragment work.
3. **Tiles.** B/A ≈ 1 + k means B renders about k extra full-screen passes per frame. Typical causes:
- a standalone clear pass;
- a pass split by a mid-frame upload or barrier;
- a blit, copy or clear outside a render pass (Mali runs those as fragment jobs too).
Find them with a per-frame Vulkan command census (see [Census of Magma's Vulkan commands](#census-of-magmas-vulkan-commands)).
4. **Rasterized quads, late ZS, texture quads.**
- With equal tiles: more rasterized quads mean extra full-screen draws inside a pass. For example, `vkCmdClearAttachments` mid-pass costs about one screen of quads each time.
- High late-ZS counts mean fragments lost the early depth test: shaders with `discard`, `gl_FragDepth` or `gl_SampleMask` writes, or clears implemented as shader draws.
- More early-ZS kills with equal visible primitives often mean the same thing seen from the other side.
5. **External write beats vs tile buffer write beats.**
- If B's extra external writes ≈ extra tile write beats × shader-core count, the difference is tile write-back.
- Check `Constant tiles killed`: the GLES driver eliminates write-back of unchanged tiles. A Vulkan path with a much lower count is losing that elimination.
- In a static benchmark scene this is a big effect. With a moving camera it is a small one.
6. **Instruction mix and interpolation.** More FMA or CVT instructions with equal quads point at shader code quality. Much more 32-bit than 16-bit interpolation points at precision: for example, ESSL `mediump` lost when a backend recompiles the shader as desktop GLSL.
## Census of Magma's Vulkan commands
When the tile count says Magma runs extra passes, find out which ones. Use a temporary, never-committed build:
- **Wrappers:** write a header that declares counting wrappers and maps each `vkCmd*` entry point to its wrapper with a function-like macro (`#define vkCmdBeginRenderPass(...) Census_BeginRenderPass(__VA_ARGS__)`). Function-like macros leave function-pointer assignments such as `fns.vkCmdBeginRenderPass = vkCmdBeginRenderPass;` alone.
- Include `VK_USE_PLATFORM_ANDROID_KHR` before `<vulkan/vulkan.h>` in that header.
- Map at least render pass begin, render pass creation (to record each pass's load/store ops), blit, copy, clear (including `vkCmdClearAttachments`), resolve, dispatch, barrier, submit and present.
- **Force-include:** apply it only to the DirectVulkan sources:
```cmake
file(GLOB _census ${CMAKE_CURRENT_SOURCE_DIR}/MobileGL/MG_Backend/DirectVulkan/*.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MobileGL/MG_Backend/DirectVulkan/Renderer/*.cpp)
list(FILTER _census EXCLUDE REGEX "Census") # the wrapper definitions must call the real entry points
set_source_files_properties(${_census} PROPERTIES COMPILE_OPTIONS "-include;<path>/Census.h")
```
- **Recording:** in each wrapper, key the count by kind, the caller's offset in the library (`__builtin_return_address(0)` minus `dladdr`'s `dli_fbase`) and details: render area plus load/store ops for passes, area for clears and blits. In the present wrapper, every few seconds, log the counts divided by the number of frames to logcat.
- **Symbolize** the offsets against the unstripped library: `llvm-addr2line -f -C -i -e build/intermediates/cxx/RelWithDebInfo/*/obj/arm64-v8a/libMobileGL.so 0x<offset>`. The library under `stripped_native_libs` has no symbols. If a wrapper's caller is a shared helper, log `__builtin_return_address(1..5)` inside that helper; arm64 Android keeps frame pointers.
## Example: Magma vs the system driver
Minecraft 1.12.2 with OptiFine at 2400×1080 on a Mali-G77, clocks locked, SFPEW over each backend:
| Finding | Counters | Cause | Fix |
|---|---|---|---|
| Fragment active cycles 1.33×, tiles 1.46× | rasterized quads, late ZS and tiles all up; vertex counters equal | one extra full-screen depth-only pass per frame (census: `vkCmdBeginRenderPass` with only `D24S8` LOAD/STORE, plus a full-screen `vkCmdClearAttachments`) | a window clear with a window pass open now ends the pass and folds into the next one (`VulkanRenderer::DeferDefaultWireClear`) |
| Extra external writes 1.7× | tile buffer write beats ≈ 23×, constant tiles killed 0.07× | the GLES driver eliminates the write-back of unchanged tiles in a static scene; Magma's attachments get none | still open |
| Fragment cycles still 1.18× with equal tiles | rasterized quads 1.26×, late ZS 1.44× | the game's two in-pass depth clears per frame, recorded as full-screen `vkCmdClearAttachments` | under investigation |
Separately, SFPEW hands MobileGL ESSL with `precision mediump float;` inside `#ifdef GL_ES`. The source processor rewrites ESSL to desktop GLSL with `GL_ES` undefined, so that guard silently turned the shaders fp32. 16-bit interpolation counters and the RelaxedPrecision decorations in the SPIR-V show this kind of loss.
## Pitfalls
- **Unlocked clocks.** Cycle counts are only comparable at the same GPU frequency, and FPS also depends on the CPU clock when the workload is not fully GPU-bound.
- **Not checking the build.** Before comparing builds, verify that each run actually ran the build you think it did. `adb install -r` of an APK with a lower versionCode fails with `INSTALL_FAILED_VERSION_DOWNGRADE`, and the run silently uses whatever was installed.
- **Different scenes.** Counters from different scenes or camera angles are not comparable at all. Freeze world time and camera position between runs.
- **Leaving the phone modified.** The producer and permissive SELinux stay in place until you undo them. Restore both, and the clock locks, when you are done.
@@ -0,0 +1,34 @@
"""Per-frame comparison of two Mali counter captures.
usage: python compare.py A.pftrace FPS_A B.pftrace FPS_B
Needs `pip install perfetto`.
"""
import sys
from perfetto.trace_processor import TraceProcessor
def per_second(path):
tp = TraceProcessor(trace=path)
rows = tp.query(
"select t.name, sum(c.value) s, (max(c.ts) - min(c.ts)) / 1e9 dur "
"from gpu_counter_track t join counter c on c.track_id = t.id group by t.id")
out = {r.name: r.s / r.dur for r in rows if r.dur > 0}
tp.close()
return out
def main():
if len(sys.argv) != 5:
sys.exit(__doc__)
a, fps_a, b, fps_b = sys.argv[1], float(sys.argv[2]), sys.argv[3], float(sys.argv[4])
A, B = per_second(a), per_second(b)
print(f"{'counter':52s} {'A/frame':>14s} {'B/frame':>14s} {'B/A':>6s}")
for name in sorted(A, key=lambda k: -A[k] / fps_a):
pa, pb = A[name] / fps_a, B.get(name, 0.0) / fps_b
ratio = f"{pb / pa:6.2f}" if pa else " n/a"
print(f"{name:52s} {pa:14.0f} {pb:14.0f} {ratio}")
if __name__ == "__main__":
main()
@@ -0,0 +1,50 @@
# Mali-G77 raw counters (IDs from step 2 of SKILL.md); 5 s at a 20 ms period.
buffers { size_kb: 131072 fill_policy: DISCARD }
data_sources { config { name: "gpu.counters" gpu_counter_config { counter_period_ns: 20000000
counter_ids: 6
counter_ids: 10
counter_ids: 18
counter_ids: 196
counter_ids: 214
counter_ids: 68
counter_ids: 160
counter_ids: 175
counter_ids: 218
counter_ids: 219
counter_ids: 220
counter_ids: 221
counter_ids: 222
counter_ids: 245
counter_ids: 201
counter_ids: 216
counter_ids: 203
counter_ids: 202
counter_ids: 204
counter_ids: 206
counter_ids: 207
counter_ids: 208
counter_ids: 212
counter_ids: 210
counter_ids: 211
counter_ids: 254
counter_ids: 250
counter_ids: 251
counter_ids: 231
counter_ids: 234
counter_ids: 241
counter_ids: 242
counter_ids: 243
counter_ids: 247
counter_ids: 248
counter_ids: 249
counter_ids: 70
counter_ids: 75
counter_ids: 85
counter_ids: 101
counter_ids: 209
counter_ids: 8
counter_ids: 16
counter_ids: 226
counter_ids: 223
} } }
duration_ms: 5000
@@ -0,0 +1,26 @@
/*
* Starts the vendor Mali counter producer so perfetto can record the
* "gpu.counters" data source. Run as root, with SELinux permissive.
*
* Build: aarch64-linux-android30-clang -O2 -o gpuprod gpuprod.c
*/
#include <dlfcn.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
void* lib = dlopen("/vendor/lib64/libgpudataproducer.so", RTLD_NOW);
if (!lib) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return 1;
}
void (*start)(void) = (void (*)(void))dlsym(lib, "start");
if (!start) {
fprintf(stderr, "libgpudataproducer.so exports no start()\n");
return 1;
}
fprintf(stderr, "starting producer\n");
start();
for (;;)
pause();
}