Skip to content

optimize GPU performance #510

Description

@scottdraves

GPU Performance Analysis

Investigation of the rendering pipeline reveals several issues that together explain the ~40% GPU utilization for what should be a simple video playback workload.

Update (2026-03-12): Added Metal GPU frame timing instrumentation (MTLCommandBuffer.GPUStartTime/GPUEndTime) to the F2 overlay. This shows actual rendering work is only ~1.6ms per frame (10% of budget), not 40%. The system-level IOKit "Device Utilization %" metric includes WindowServer compositor overhead and is misleading. powermetrics confirms the GPU is running at its lowest frequency (444 MHz / 1398 MHz max) at only 128 mW — the GPU is coasting. Issues A–E below would reduce the 1.6ms further, but the largest opportunity is eliminating compositor overhead via direct scanout (Issue F).

Issue A: Multiple render passes per frame

RendererMetal.mmClear() (L269–298) and DrawQuad() (L344–457)

Every DrawQuad() call creates a separate Metal render pass (new MTLRenderCommandEncoder). The Clear() method in BeginFrame() creates yet another. So:

  • Normal playback = 2 render passes per frame (Clear + DrawQuad)
  • Crossfade transition = 3 render passes (Clear + DrawQuad × 2)

Each render pass forces the GPU to load and store the entire framebuffer to/from tile memory, which is extremely expensive on Apple GPUs.

Additionally, BeginFrame() sets currentLoadAction = MTLLoadActionClear then calls Clear() which creates a pass that clears the surface. The first DrawQuad() reads currentLoadAction which is still MTLLoadActionClear, clearing the surface a second time before drawing.

Apple's Metal Best Practices Guide: "Minimize the number of render passes per frame. Combine rendering work into fewer, larger render passes whenever possible."

Fix: Create a single render encoder in BeginFrame() with MTLLoadActionClear, pass it through to all DrawQuad() calls within the frame, and end it in EndFrame(). This collapses 2–3 passes into 1.


Issue B: Debug conditionals left in production shader

MetalShaders.metal:74–77SampleYUVTexturesRGBA()

if (yuv.x == 0)
    return float4(1, 0, 0, 0.5);  // red for black Y
if (all(yuv.yz == float2(0, 0)))
    return float4(0, 0, 1, 0.5);  // blue for zero UV

These per-pixel branches return diagnostic colors and cause GPU thread divergence on every pixel of every frame. This is clearly debug code that was left in.

Fix: Remove these conditionals.


Issue C: Unnecessary depth buffer

RendererMetal.mm:472–488 and all render pass descriptors

A MTLPixelFormatDepth32Float texture is allocated at full display resolution and attached to every render pass. But this is a 2D fullscreen video player — there is no 3D geometry or depth testing. This wastes GPU memory and adds load/store bandwidth cost on every render pass (compounding issue A).

Fix: Remove the depth texture allocation and depth attachment from all render pass descriptors.


Issue D: Cubic interpolation: 8 texture samples per pixel

MetalShaders.metal:207–212drawDecodedFrameCubicFrameBlendFragment()

The cubic display mode calls SampleYUVTexturesRGBA() 4 times per pixel, each sampling both Y and UV textures = 8 texture reads per pixel. Combined with the YUV→RGB conversion math running 4× and the debug branches from issue B, this is quite heavy.

This is partially by design for quality, but it compounds significantly with issue A (8 texture samples × 3 render passes × 60 FPS).


Issue E: CVMetalTexture objects created and released every draw call

RendererMetal.mm:311–342GetYUVMetalTextures() / CreateMetalTextureFromDecoderFrame()

CVMetalTextureCacheCreateTextureFromImage is called for every YUV texture on every DrawQuad(). For cubic mode with 4 frame textures, that's 8 CVMetalTexture create/release cycles per draw, × 60 FPS = ~480/sec. These are lightweight (zero-copy IOSurface wrapping), but the overhead still adds up.


Issue F: WindowServer compositor overhead ⚠️ Highest measured impact

powermetrics shows 37% GPU HW active residency but Metal frame timing shows only 10% (1.6ms/frame). The ~27% gap is WindowServer compositing the app's framebuffer with the desktop every frame.

On macOS, direct scanout allows a fullscreen app's framebuffer to be sent directly to the display hardware, bypassing WindowServer compositing entirely. This requires:

  1. True fullscreen rendering — the CAMetalLayer must cover the entire screen with no overlapping UI (menu bar, dock, notifications)
  2. Matching display resolution — the layer's drawableSize must match the native display resolution so no scaling is needed
  3. Compatible pixel format — use MTLPixelFormatBGRA8Unorm (the display's native format)
  4. EDR and presentsWithTransaction considerations — avoid features that force compositor involvement

Since the app runs as a screensaver (fullscreen, no other UI), it is a strong candidate for direct scanout. The current ESMetalView / ESScreensaverView setup should be audited to ensure these conditions are met. If the layer size doesn't match the display's native backing resolution, or if the pixel format differs, WindowServer will be forced to composite.

Investigation steps:

  • Log CAMetalLayer.drawableSize vs NSScreen.frame × NSScreen.backingScaleFactor to check for a resolution mismatch
  • Check the layer's pixelFormat — must be BGRAPixelFormat (not RGBA)
  • Verify no overlay windows or views (dock, menu bar) are present during screensaver mode
  • Check CAMetalLayer.wantsExtendedDynamicRangeContent and presentsWithTransaction settings
  • Confirm with sudo powermetrics --samplers gpu_power before/after — direct scanout should cut the 37% HW active residency roughly in half

Fix: Ensure the CAMetalLayer drawable size, pixel format, and presentation path meet Apple's direct scanout requirements. This could eliminate ~27% of the measured GPU active residency (~4.5ms/frame of compositor overhead) which dwarfs the 1.6ms of actual rendering work.


Estimated impact (revised)

Issue F (direct scanout) is the single largest contributor to measured GPU active time, accounting for ~27% out of 37% total. Issues A–E affect the remaining ~10% (1.6ms of actual rendering). Fixing F alone could cut GPU power from ~128 mW to roughly half. Issues A–C are still worth fixing as good practice.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions