跪拜 Guibai
← All articles
Android · Performance Optimization

Cutting Android Camera-to-Preview Lag from 400ms to 44ms with Perfetto

By Android打工仔 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Android camera-to-preview transitions are a common source of jank, and the visible symptoms — `sys_futex`, `sys_ioctl` — look like system problems. This walkthrough shows how to trace the real wait chain through Perfetto and proves that application-side decisions about image sizing, Surface lifecycle, and frame scheduling are the actual levers for fixing it.

Summary

After taking a photo, an Android app froze for hundreds of milliseconds. A Perfetto trace showed the main thread stuck in `sys_futex` waiting on the RenderThread, which itself was stuck in `sys_ioctl` waiting on the GPU and graphics driver. The root cause was not a system bug but application-level decisions: loading the full-resolution raw image directly into the UI, tearing down the camera Surface in the same frame the preview appeared, and packing multiple state updates into one frame.

Four rounds of targeted fixes brought the key frame duration from over 400ms down to roughly 44ms. The optimizations included downsampling the preview image to actual display size, keeping the camera preview alive briefly while the image overlays on top, spreading state updates across frames with `withFrameNanos`, and delaying CameraX unbind until the transition settles.

Perfetto SQL queries also revealed that after the first fix, the RenderThread was no longer sleeping but was `Runnable (Preempted)` — competing for CPU against CameraX threads, SurfaceFlinger, kernel memory compaction, and even the trace tooling itself. The investigation makes clear that Android UI jank often manifests as system calls, but the triggers are application-layer resource and timing choices.

Takeaways
Loading a full-resolution capture directly into an Image composable caused the RenderThread to stall for hundreds of milliseconds in QueueSubmit → sys_ioctl.
Downsampling the preview to the actual UI display size with .size() and Precision.INEXACT cut the long frame from 400ms+ to ~245ms.
After downsampling, the RenderThread's sys_ioctl state shifted from Sleeping to Runnable (Preempted), meaning it was now fighting for CPU time rather than waiting on the GPU.
Perfetto SQL queries over the sched table identified the CPU competitors: CameraX threads, SurfaceFlinger, RenderEngine, kernel compaction, and trace/log threads.
Removing CameraPreview and adding CapturedImagePreview in the same frame stacked Surface teardown, image decode, GPU texture creation, and SurfaceFlinger recomposition into one janky frame.
Keeping the camera preview alive underneath the image preview for a short period broke up the single 200ms+ QueueSubmit slice.
Delaying the previewImageUri assignment by two frames via withFrameNanos prevented multiple Compose state changes from landing in the same frame.
A 300ms delay before setting keepCameraPreviewVisible = false and calling unbindAll() moved the CameraX teardown cost away from the critical transition.
The final combined strategy brought the key frame duration down to ~44ms.
Trace collection itself adds CPU and I/O pressure; reducing data sources after the problem direction is clear gives more representative measurements.
Conclusions

The investigation reframes a common misinterpretation: sys_futex and sys_ioctl are not root causes but end-of-chain symptoms. The real question is what the thread is waiting on, and that answer lives in the parent call chain and the concurrent thread states.

Thread state (Sleeping vs. Runnable Preempted) is a more useful signal than the syscall name alone. The same sys_ioctl slice means entirely different things depending on whether the thread is blocked on a fence or starved for CPU.

Perfetto's UI timestamps cannot be used directly in SQL; the reliable path is to pull the real ts and dur from the slice table first, then query sched within that window. This is a practical gotcha for anyone writing Perfetto SQL.

The optimization is not about finding one bottleneck but about sequencing: the order and frame alignment of Surface removal, image decode, and state updates matter as much as the cost of each operation individually.

CameraX unbindAll in DisposableEffect.onDispose is a defensive pattern that prevents the camera stack from running after PreviewView leaves the tree — a subtle leak that can add sustained background load across multiple frames.

Concepts & terms
sys_futex in Perfetto
A system call that typically indicates a thread is waiting on a synchronization primitive (lock, condition variable, or another thread) rather than performing computation. When paired with postAndWait on the main thread, it usually means the main thread is waiting for the RenderThread to finish drawing.
sys_ioctl in RenderThread
A system call appearing under QueueSubmit in the graphics pipeline. Long durations here usually mean the RenderThread is blocked waiting on the GPU, graphics driver, fence, or Surface synchronization — not actively computing.
Runnable (Preempted) thread state
A scheduler state indicating a thread is ready to run but has been denied CPU time because other threads are occupying the cores. Distinct from Sleeping, which means the thread is blocked waiting for an event or I/O.
withFrameNanos
A Compose coroutine suspension point that resumes at the beginning of the next frame, used to spread state updates across frame boundaries instead of packing them into a single frame.
Precision.INEXACT in Coil
A Coil image loading option that allows the decoder to use a downsampled version of the image close to the requested size, trading off exact pixel dimensions for lower decode cost and memory pressure.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗