perf(replay): reduce PixelCopy screenshot overhead - #756
Conversation
| } | ||
|
|
||
| private fun downscaledScreenshotDimension(size: Int): Int { | ||
| return maxOf(1, (size + SCREENSHOT_DOWNSCALE_FACTOR - 1) / SCREENSHOT_DOWNSCALE_FACTOR) |
There was a problem hiding this comment.
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.
|
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 && |
There was a problem hiding this comment.
[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.") |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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:
| 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( |
There was a problem hiding this comment.
[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(), |
There was a problem hiding this comment.
[nit] floor/ceil on a Float already return Float, so these four .toFloat() calls are no-ops.
ioannisj
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
💡 Motivation and Context
Session replay screenshot mode allocated a new full-resolution
ARGB_8888PixelCopy 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:
RGB_565destination (1.24 MiB at 1080x2400) and reuses it for the active recording runARGB_8888if a device reportsERROR_DESTINATION_INVALIDforRGB_565There 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:testDebugUnitTestmake compilemainProduction cadence results below are medians across four base and four head traces, with 19 PixelCopy operations per trace:
maincopySurfaceIntomeancopySurfaceIntop95At 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
If releasing new changes
🤖 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'scopySurfaceIntoslice 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.