Skip to content

perf(replay): reduce PixelCopy screenshot overhead - #756

Open
dustinbyrne wants to merge 1 commit into
mainfrom
perf/replay-pixel-copy-buffer
Open

perf(replay): reduce PixelCopy screenshot overhead#756
dustinbyrne wants to merge 1 commit into
mainfrom
perf/replay-pixel-copy-buffer

Conversation

@dustinbyrne

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Session replay screenshot mode allocated a new full-resolution ARGB_8888 PixelCopy destination for every capture. On a 1080x2400 screen that is a 9.89 MiB destination per frame, and PixelCopy also spends RenderThread time reading back the full-resolution surface.

This change:

  • captures directly into a ceil-half-resolution RGB_565 destination (1.24 MiB at 1080x2400) and reuses it for the active recording run
  • safely quarantines an in-flight bitmap after a local timeout and prevents late callbacks from returning buffers across stop/restart generations
  • reconfigures the existing allocation for compatible size changes and recycles idle storage when recording stops
  • outward-rounds scaled mask rectangles so downscaling does not expose an unmasked pixel edge
  • falls back once to half-resolution ARGB_8888 if a device reports ERROR_DESTINATION_INVALID for RGB_565

There is no public API change. Screenshot payloads intentionally trade some image resolution and color precision for lower capture overhead; replay viewport geometry remains unchanged.

💚 How did you test it?

  • make checkFormat
  • ./gradlew :posthog-android:testDebugUnitTest
  • make compile
  • API 36 emulator smoke test with real PixelCopy + WebP encoding; the resulting screenshot decoded at 540x1200 versus 1080x2400 on main
  • Perfetto comparison using minified release builds, a fixed-geometry animated Compose screen, a deterministic local ingest server, 10-second warmups, and interleaved 20-second base/head trials

Production cadence results below are medians across four base and four head traces, with 19 PixelCopy operations per trace:

Metric main This PR Change
RenderThread copySurfaceInto mean 11.998 ms 6.750 ms -43.7%
RenderThread copySurfaceInto p95 14.171 ms 7.459 ms -47.4%
Frame duration p95 when overlapping PixelCopy 36.040 ms 27.618 ms -23.4%
Anonymous RSS average 39,660.9 KiB 35,810.0 KiB -9.7%
Anonymous RSS peak 51,596 KiB 36,638 KiB -29.0%

At a 100 ms stress cadence, the new path completed 7.4% more captures while reducing total PixelCopy RenderThread occupancy by 35.7%, all-frame p95 by 11.3%, average anonymous RSS by 22.8%, and peak anonymous RSS by 44.4%.

These are controlled relative measurements from one API 36 emulator using ANGLE/SwiftShader, not a physical-device or OEM compatibility matrix.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Added a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Implemented with Pi (openai-codex/gpt-5.6-sol; local session, no public session link), with read-only researcher, scout, design-oracle, and reviewer subagents. Perfetto was used to attribute the capture cost directly to HWUI's copySurfaceInto slice on the app RenderThread. The implementation uses an active-run bitmap lease because timeout callbacks can arrive late, and keeps a one-time format fallback for device compatibility.

@dustinbyrne dustinbyrne self-assigned this Sep 2, 2026
@dustinbyrne
dustinbyrne marked this pull request as ready for review September 2, 2026 19:21
@dustinbyrne
dustinbyrne requested a review from a team as a code owner September 2, 2026 19:21
}

private fun downscaledScreenshotDimension(size: Int): Int {
return maxOf(1, (size + SCREENSHOT_DOWNSCALE_FACTOR - 1) / SCREENSHOT_DOWNSCALE_FACTOR)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently we always downscale to half the resolution. in my testing, i didn't see a noticeable difference in quality. open to feedback on whether or not this should be configurable.

@greptile-apps

greptile-apps Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "perf(replay): reduce PixelCopy screensho..." | Re-trigger Greptile

private var nextLeaseId = 0L
private var activeLeaseId: Long? = null
private var idleBitmap: Bitmap? = null
private var bitmapConfig = Bitmap.Config.RGB_565

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RGB_565 has no alpha, and every dialog/popup is registered as its own decor view and captured as its own full-window screenshot stacked over the activity's — so their transparent regions ship opaque black instead of letting the app show through. An AlertDialog or bottom sheet turns the replay frame black for as long as it's up.

Confirmed on an API 37 emulator with a translucent Dialog (windowFormat = -2, decor 1080x2424), sampling the transparent centre pixel through the same WEBP_LOSSY q30 encode we ship:

ARGB_8888  → webp hasAlpha=true   centre=0x00000000   (main today)
RGB_565    → webp hasAlpha=false  centre=0xff000000   (this PR)

I think we need to stay on ARGB_8888 here — at half resolution it's still 2.47 MiB against 9.89 on main, and the 4x cut in destination pixels looks like the dominant term in the RenderThread win anyway. Picking the config per window from window.attributes.format would keep the full win, but then the config has to become part of the buffer's reuse key.

@dustinbyrne dustinbyrne Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i had to look deeper into this, and i found that we're capturing activity and dialog decor independently in such a way that only one decor is ever visible during replay at a time.

on main, we capture the dialog as ARGB_8888 and the transparency is retained. the issue is that the main activity would not be visible underneath because the snapshot only contains the dialog. the transparent pixels are rendered white during replay (maybe it's the background color of the player?). example

on this branch it's captured as RGB_565 so the transparent pixels are flattened to black. it still only contains the dialog. example

neither of these display the activity and the dialog as they're rendered to the user. to do this we'd likely need to build a composite snapshot on the client or come up with a solution for rrweb to render the scene as layered screenshots.

that said, this branch does change the current output from transparent to black. i agree that we should retain RGB_565 for explicitly opaque windows and only introduce an alpha channel when the window is transparent (or not known to be opaque). i'll need to check if that can be the case for a main activity.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that there's no benefit in capturing transparency if it won't contribute in showing what's behind. Since the first example rendered the same dialog white (players background prob I agree), I think it's okay trading that for black. Either way, a back background dialog still paints the picture of what the user is doing in the app (since it's what they are currently interacting with) so not sure worth investing in layer rendering or composition?

try {
if (copyResult != PixelCopy.SUCCESS) {
if (
copyResult == PixelCopy.ERROR_DESTINATION_INVALID &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] This is the only escape hatch, and RGB_565 readback is verified on one emulator — if a device rejects it with a different code, or accepts it and returns wrong colours, there's no recovery and no way for a user to opt out. Worth widening this to any repeated non-SUCCESS, or gating the format behind a config flag for a release?

downscaledScreenshotDimension(view.height),
) ?: run {
finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment)
config.logger.log("Session Replay screenshot skipped because the previous PixelCopy is still in progress.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] With a single lease, a timeout also drops every capture until the late callback lands — on main the next capture just allocated fresh and went ahead. Good trade for killing the 9.89 MiB-per-timeout leak, but on a loaded device it turns one dropped frame into several. Was a two-slot pool considered?

}

private fun downscaledScreenshotDimension(size: Int): Int {
return maxOf(1, (size + SCREENSHOT_DOWNSCALE_FACTOR - 1) / SCREENSHOT_DOWNSCALE_FACTOR)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] posthog-ios captures at native screen scale with no downscale (UIView+Util.swift), so after this Android replays render at roughly half the effective density of iOS ones on comparable devices. Reads like a deliberate trade to me — worth a line in the changeset or the replay docs so it doesn't come back later as an Android bug report?

return false
}

val scaleX = width.toFloat() / sourceWidth

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] isVisible() rules out a zero-width view at capture time, but masking runs later on the callback thread — if the view has been relaid out to 0 by then, scale is Infinity and the resulting NaN rects get silently skipped by drawRoundRect, which fails open on a masking path. A guard closes it:

Suggested change
val scaleX = width.toFloat() / sourceWidth
if (sourceWidth <= 0 || sourceHeight <= 0) return false
val scaleX = width.toFloat() / sourceWidth

}

private fun Bitmap.paintScreenshotMasks(rects: List<Rect>): Boolean {
internal fun RectF.setScaledScreenshotMask(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] internal here is only for the unit test — this could be private and covered through paintScreenshotMasks.

scaleY: Float,
) {
set(
floor(rect.left * scaleX).toFloat(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] floor/ceil on a Float already return Float, so these four .toFloat() calls are no-ops.

@turnipdabeets
turnipdabeets requested a review from a team September 2, 2026 19:56

@ioannisj ioannisj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar findings to @turnipdabeets here. Run a benchmark as well and it indeed looks like this PR will help out a lot

private var nextLeaseId = 0L
private var activeLeaseId: Long? = null
private var idleBitmap: Bitmap? = null
private var bitmapConfig = Bitmap.Config.RGB_565

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that there's no benefit in capturing transparency if it won't contribute in showing what's behind. Since the first example rendered the same dialog white (players background prob I agree), I think it's okay trading that for black. Either way, a back background dialog still paints the picture of what the user is doing in the app (since it's what they are currently interacting with) so not sure worth investing in layer rendering or composition?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants