Pinpointing iOS Scroll Stutter with Xcode's Animation Hitches
Foreword
In this line of work, everything requires evidence. You say it's laggy, officer, but where's the proof?
You claim it's lagging here, but what's the evidence? Some stutters are invisible to the naked eye and are not easy to reproduce consistently. The tester says it's laggy, the developer says it's not—how do you settle this? And what is causing the lag? The codebase is vast and long, the logic convoluted and messy; you can't possibly investigate line by line. If you share these frustrations, then it's time to use Xcode Instruments.
This article will start with an example of fixing list stuttering and explain clearly how to use the Animation Hitches tool in Xcode Instruments to pinpoint the cause of the lag. Then, it will detail the meaning of the various metrics within Hitches.
The general process for locating the issue is as follows:
- Run the project in Profile mode
- First, select Animation Hitches, which can identify which frame was not submitted on time
- Hitch type will tell you which phase caused the time consumption
- Frame LifeTimes will tell you the specific time interval of the delay
- Filter the time interval and use Time Profiler to locate the specific time-consuming method
- Analyze what this method is doing and how to optimize it
- Repeat this process until you feel the performance is acceptable
Running the App in Profile mode will bring up the Instruments panel:
Before starting, you might need a basic understanding of the iOS rendering pipeline. You can first read Deep Understanding of iOS Rendering Principles.
Animation Hitches
After entering Animation Hitches, click the top-left button to launch your project. Then, navigate to the page you want to investigate. For example, to troubleshoot list stuttering, I go to the list page and scroll quickly. After finishing the operation, click stop, and you will get an analysis chart:
The vertical lines at the top, in orange, green, and blue (the colors do not represent severity but only distinguish different frames), each represent a Hitch. The condition for a Hitch to be generated in Instruments is:
The actual display time of a frame is later than its expected display time.
In other words, this frame arrived late.
Let's first look at the meaning of the metrics in the red box:
- Hitch Begin: Expected on-screen time
- Presentation: Actual on-screen time
- Hitch Duration: How late it was
- Acceptable latency: The total time allowed from the start of processing this frame to the expected on-screen time
- Buffer Count: The number of frame buffers used
- Severity: The severity of this Hitch
- Low: Late by no more than 1 refresh interval
- Moderate: Late by more than 1 but no more than 2 refresh intervals
- High: Late by more than 2 refresh intervals
- At 60Hz, the refresh interval is approximately 16.7 ms
- Hitch Type
- Pre-Commit(s) latency: The budget was exhausted before Commit even started
- Expensive Commit(s): The Commit phase exceeded the budget
- Commit to Render latency: The App committed, but the Render Server did not start in time
- Expensive Rendering: The Render Server was too slow processing the layer tree on the CPU
- CPU to GPU Rendering Latency: Rendering commands were ready but not handed over to the GPU for execution in time
- Expensive GPU: The GPU drawing/rendering phase exceeded the budget
- Delay Frame Swap: CPU and GPU finished, but the frame was not swapped to the screen in time
Buffer Count = 2 indicates the current display pipeline uses 2 frame buffers to hold the frame being displayed, waiting to be displayed, or being rendered.
Buffer A: The frame currently displayed on screen
Buffer B: The next frame being drawn by the GPU
When the next frame is drawn, at the arrival of the VSync signal, the two swap.
Then Buffer A can continue to be used for drawing subsequent frames.
Some devices use triple buffering:
Buffer A: Currently displayed on screen
Buffer B: Drawn and waiting to be displayed
Buffer C: The GPU is drawing a subsequent frame
Apple's explanation here is: Two buffers are used by default. When rendering is delayed and the Render Server tries to catch up, the system can maintain the rendering pipeline through triple buffering.
After understanding these concepts, let's revisit what "this frame arrived late" means.
Take frame 3 from the screenshot as an example:
Expected on-screen time (Hitch Begin): 00:11.794.540
Actual on-screen time (Presentation): 00:11.827.876
Delay time (Hitch Duration) = Presentation - Hitch Begin = 33.34 ms
Allowed processing time (Acceptable latency): 33.33 ms
Therefore, the total lifecycle of this frame is 33.33 + 33.34 = 66.67ms.
Frame Lifetimes
Select the third frame we just discussed, zoom in, and then expand Hitches:
Frame Lifetimes is the lifecycle of this frame. We calculated earlier:
The time from start to on-screen for frame 3 is 33.33 ms + Hitch Duration 33.34 ms = 67ms.
The first row is Hitch Duration. You can see that the time point of the blue vertical line is Hitch Begin, which is the time it should have been on screen. But looking at the Commits row, you can see that Commit hasn't started yet, indicating the time was mainly consumed before Commit.
So for this frame, the interval we need to analyze is:
Left edge of Frame Lifetimes -> Left edge of the Commit block.
Overlapping Situations
Sometimes when looking at Frame Lifetime, you might find overlaps:
Frame 1:
Frame 2:
This is normal because rendering is a pipelined process; different frames can be in different states simultaneously.
For example, at 60Hz with double buffering:
Time: 0ms 16.7ms 33.3ms 50ms
Frame A: [App Processing] [Render/GPU] [On-screen]
Frame B: [App Processing] [Render/GPU] [On-screen]
Corresponding Frame Lifetime:
Frame A: 0ms ───────── 33.3ms
Frame B: 16.7ms ───────── 50ms
The lifecycles of the two frames overlap by about 16.7 ms.
This indicates that at the same time, it could be:
- The App is preparing Frame B
- The Render Server is processing Frame A
- The GPU is drawing an earlier frame
Time Profiler
Continuing the analysis of this frame, long-press to select the area from Frame Lifetimes to the Commit Phase:
The red blocks represent the code we are executing during this area. Those with a person icon represent user code. Also, the code here has a containment relationship; the upper code contains the lower code.
You can see that in the 47 ms period, 38 ms were spent processing this code. Hovering the mouse over that line will show:
Clicking to jump can directly locate this code's position in the project:
At this point, the cause is revealed: it's the UIGraphicsImageRenderer method causing the delay. Inside this method, it's essentially the sourceImage.draw() method. Here, on the main thread, image data is being drawn into a bitmap:
- JPEG compressed data
UIImage(data:)createssourceImagedraw(in:)triggers JPEG pixel decoding- Scales and draws into a new bitmap buffer
- Gets the
UIImagein bitmap form
Because our JPEG is just a set of compressed data, to actually display it on the screen, it needs to be decoded and drawn into a bitmap first.
As Time Profiler shows, decoding is indeed a time-consuming operation.
So the problem is:
Drawing a bitmap on the main thread triggers the image decoding operation, thus affecting drawing speed.
Image decoding is time-consuming. Either reduce the image size or do it in the background. We choose to decode in the background. Decoding depends on the image's dimensions, but our display container doesn't need such a large image. Decode directly to the required display size, draw it into a bitmap, then assign it back on the main thread. During background decoding, use a placeholder image.
This is just to demonstrate how to use Instruments to locate the cause; the optimization part won't be expanded upon here.
What's Inside Animation Hitches
What I've pixelated is the project name.
In Hitches, things are arranged chronologically. From here, you can also see how Apple handles the rendering of a single frame.
Starting from a user operation (User Events), through various calculations on the App side, committing to the Render Server, then to the GPU, Frame Lifetimes is the lifecycle of a frame.
Let's go through them one by one, but I think it's necessary to supplement the Hitch Type content mentioned earlier first.
Hitch Type
Hitch Type is a classification given by Instruments based on the relationship between the phases in a frame's lifecycle and the time budget, indicating "in which phase the budget was exhausted." The types were listed earlier; here, I'll note what might cause timeouts for these types.
It should be noted that Hitch Type is used to hint at which phase the frame experienced a delay and where to start investigating. The final cause still needs to be confirmed using other means, such as Lifetimes, Commits, Renders, GPU, and Time Profiler.
Also, these types are not judgments of "whether a specific phase itself exceeded the complete budget," because Instruments uses cumulative judgment. That is, the budget was exhausted by this phase, but it's not necessarily caused by this phase itself.
Let's first outline the general iOS rendering process:
- The App side calculates the content
- Commit to the Render Server
- Submit to the GPU
- Draw into the frame buffer
- Display on screen
1. Pre-Commit(s) latency
Frame start -> Timeout -> Commit start
Timeout occurred before even entering the Commit interval.
The Commit here refers to Core Animation Commit:
The App submits the layer tree changes for the current frame to the Render Server.
Common causes:
- Business data processing
- Image decoding, scaling
- Text calculation
- CollectionView / TableView data source processing
- Lock waiting
- Synchronous I/O
- Main thread scheduling delays
This step all happens on the App side, mainly processed by the CPU, usually involving the main thread.
2. Expensive Commit(s)
Commit start -> Timeout -> Commit end
Timeout during the execution of Commit. Common causes:
layoutSubviews- Auto Layout
drawRect- Image preparation and decoding
- Layer tree traversal and packaging
- Submitting the layer tree to the Render Server
Pre-Commit and Commit are both on the CPU side, and this is the bulk of what we need to handle.
3. Commit to Render latency
Commit complete -> Timeout -> Render Server start
The App committed, but the Render Server did not start processing in time.
This period is mainly waiting between phases and cannot be directly attributed to a specific App function. It might involve:
- Waiting for the next VSync/VBL
- Render Server scheduling
- System rendering load
- Rendering competition from other processes or windows
Our focus generally doesn't need to be here.
4. Expensive Rendering
Render Server CPU start -> Timeout -> End
The Render Server needs to first parse the layer tree submitted by the App on the CPU and generate rendering commands that the GPU can execute.
Common causes:
- Too many layers
- Deep hierarchy
- Complex masks
- Dynamic shadows
- blur / vibrancy
- Complex layer compositions
- Extensive screen recording rendering preparation
These are things we need to avoid when writing code, otherwise they increase the workload here, leading to timeouts.
Also, this part occurs in the Render Server process, not the App's main thread, so you cannot locate it solely using the App's Time Profiler call stack.
5. CPU to GPU Rendering Latency
Render CPU complete -> Waiting time too long -> GPU start
The Render Server has prepared the rendering commands, but the GPU didn't start executing them in time.
It represents the delay between the CPU and GPU phases. Possible causes:
- GPU queue is busy
- System GPU competition
- Command submission or scheduling delays
It does not mean "Render CPU itself is slow," nor does it mean "GPU execution itself is slow."
This is also not a key area for us to manage.
6. Expensive GPU
GPU start -> Timeout -> GPU complete
GPU execution timeout. Common causes:
- Large area transparent blending
- blur
- Dynamic shadows
- mask
- Large image textures
- High-resolution images
- Multiple off-screen render passes
- Many simultaneous animations on screen
The focus here is to look at the Render Count and Offscreen Count in the GPU and Renders tracks, not just checking the main thread.
7. Delay Frame Swap
GPU complete -> Waiting time too long -> Screen display
The final step. This indicates the frame generated by the GPU was not swapped to the screen in time when VSync arrived.
Possible causes:
- Frame Swap missed VSync
- Display system scheduling
- Buffer state
- System-level rendering pressure
There's basically nothing we can do about this.
User Events
It contains three parts:
Input -> Handover -> Processing
Input
The time point when the system recorded this HID input, such as touch, drag, etc.
Handover
The time point when UIKit prepared to hand this input event over to the App's main thread for processing.
Note that this only indicates the event is ready to be handed to the main queue, not that the main thread has started executing the event callback.
However, during my testing, I often couldn't see it. It's not necessarily displayed for every input.
Processing
The time interval when the App actually dispatches and processes this UIEvent, from the start of event dispatch to the end of event dispatch.
During this period, the following might execute:
- Touch event dispatch
- Gesture recognition
- Responder Chain
UIScrollViewscroll handling- Control
actions - App code directly triggered by the event
The same input might also be associated with multiple Processing intervals.
So these three nodes can be understood as:
User input recorded by the system
↓ Input
Event prepared to be handed to the App's main thread
↓ Handover
Main thread starts dispatching and processing the event
↓ Processing
Event processing produces UI changes
↓
Enters subsequent Commit, Render, GPU
During analysis, you can focus on:
- Input → Handover is long: The delay mainly occurred before the input reached the main thread.
- Handover → Processing is long: The event was waiting to be processed, but the main thread was not free for a long time.
- Processing is long: The main thread took a long time to process this input event itself, requiring Time Profiler to check the call stack.
Look at a demo, adding an 80ms time-consuming task in a drag callback:
You can clearly see the Processing phase occupies a large portion of the Frame lifecycle.
Commits
Definition of Commits:
The time intervals when various processes associated with the current Hitch frame execute Core Animation Commit.
When the App modifies view or layer properties, these changes ultimately need to be organized into Core Animation's layer context and submitted to the Render Server. Instruments displays this recorded Core Animation Commit in the Commits track.
Here, a distinction needs to be made from the App's broadly defined commit transaction, which broadly includes four phases:
- layout
- display
- prepare
- commit
Instruments' Commits track: The interval recorded based on Core Animation's internal commit tracking events cannot be simply equated to the entire broad transaction.
The reason it's "Commits" and not "Commit" is that it might contain Commits from multiple processes, such as here:
- SpringBoard
- Your App
SpringBoard is iOS's system interface process. This interval indicates that some system layer context managed by SpringBoard also underwent a Commit in this frame.
If SpringBoard and your App appear together, it doesn't mean SpringBoard called your project code, nor are their time consumptions additive. It just means this frame is associated with Core Animation Commits from two processes.
Commits can tell us:
- How many Commits are associated with the current Hitch frame
- Which process produced each Commit
- When each Commit started and how long it lasted
- Whether the client's time budget was exhausted within the Commit interval
Renders
Definition of Renders:
The time interval when the Render Server performs rendering preparation work for the current Hitch frame.
It sits between Commit and GPU. This time interval is mainly the Render Server working:
- Aggregating the layer contexts involved in the current frame
- Traversing the layer tree
- Processing compositing information like layer order, transforms, opacity, clipping, etc.
- Processing the Core Animation animation system
- Determining if additional off-screen rendering is needed
- Compiling the layer tree into a sequence of rendering operations executable by the GPU
Apple calls this part render prepare. Only afterward does the GPU actually draw pixels in render execute.
The Render Server is another system process, not within our own App process. Its workload is affected by the layer tree submitted by the App, for example:
- High number of layers, deep hierarchy
- Dynamic shadows
- Mask
- Blur and
UIVisualEffectView - Complex corner radius clipping
- Many transparent layers
- Need for multiple off-screen render passes
Render Count
If you see Render Count in the details, it indicates there are extra Renders / off-screen rendering here.
You can further check:
- Whether dynamic shadows have
shadowPathset - Whether there are unnecessary
Masks - Whether corner radius clipping is causing off-screen rendering
- Whether the layer structure is too deep
- Whether there are complex visual effects
Renders and Hitch Type
If the client Commit has completed, but the Render Server's preparation phase exhausted the budget, the Hitch Type might be Expensive Rendering.
If Renders itself is short, but it took a long time after finishing to hand over to the GPU, it's more likely CPU to GPU Rendering Latency.
GPU
Definition of this interval:
The time interval when the GPU executes rendering commands for the current Hitch frame.
At this step, the Render Server has organized the layer tree into rendering operations executable by the GPU. The GPU then executes these operations, compositing layers, images, text, and visual effects into the final frame.
Apple calls this phase render execute.
The track graph for GPU here will tell us:
- When the GPU started processing this frame
- When the GPU finished this frame
- Whether the budget was exhausted here
Divided into three segments, if it's:
- Render Server end -> GPU start
- Then the Hitch Type might be
CPU to GPU Render Latency,
- Then the Hitch Type might be
- GPU start -> GPU end
- Might be
Expensive GPU
- Might be
- GPU end -> Presentation
- Might be
Delay Frame Swap
- Might be
GPU is similar to Renders; it mainly depends on the layer tree submitted by the App. After we submit from the App to the Render Server, how the next steps proceed is the system's own scheduling. So if there are problems during GPU rendering, it's highly likely our submitted layer tree is too complex:
- Number of off-screen rendering passes
- Processing cost per pass for shadows, blur, masks, etc.
- Pixel range needing repeated drawing, copying, compositing
- Blending and overdraw caused by overlapping transparent layers
- Image texture sampling and its memory bandwidth consumption
Display
The above were all contents within Hitches. Display is not a computation phase; it's the track for the final display result.
Unlike the previous tracks, the meaning of the previous tracks was: Where is this frame being processed?
Display is:
Which frames ultimately actually displayed, and how long each frame stayed on the screen.
For example, during a stable 60Hz animation:
- One frame displayed for about 16.67 ms: Normal update.
- One frame displayed for about 33.33 ms: The next frame missed one VSync, the old frame stayed for an extra refresh interval.
- One frame displayed for about 50ms: Missed about two VSyncs.
Thread State Trace
When a thread occupies the CPU, what function is it specifically executing?
Was the thread actually running at that time? If not, was it blocked, or was it ready but didn't get the CPU?
It's not a phase in the rendering pipeline but a tool to assist in locating why the App thread didn't complete its work in time.
Common thread states include:
Running: The thread is executing code on some CPU coreRunnable: The thread is ready to run but hasn't gotten the CPU yetBlocked: The thread is waiting for some condition, like a lock, semaphore, I/O, Mach message, or RunLoop eventPreempted: The thread was running but was moved off the CPU by the scheduler to let other work execute firstInterrupted: The CPU executed a hardware interrupt, temporarily interrupting the current threadIdle: A CPU idle interval recorded by the scheduler, no suitable thread needed to execute on that coreTerminated: The thread has endedUnknown: Instruments doesn't have enough information to determine the thread state for this interval
Meaning of the fields in the chart:
- Count: Number of state intervals recorded, not the number of threads
- Duration: Cumulative duration of all these state intervals
- Min Duration: Shortest single state interval
- Avg Duration: Average duration of a single interval
- Std Dev Duration: Dispersion degree of each duration
- Max Duration: Longest single state interval
When the main thread is executing time-consuming code, use Time Profiler to see the call stack.
When the main thread's CPU usage is very low, Time Profiler even shows No Data, but the UI is still unresponsive, then you should look at Thread State Trace. Because a blocked thread doesn't occupy CPU, Time Profiler can't sample it. But Thread State Trace can record how long it was blocked.
Thermal State
Also not a phase in the rendering pipeline, but environmental information for performance analysis:
When the device temperature rises, the system might take cooling measures, reducing available CPU, GPU, and other system performance, thus causing code that could originally complete on time to start experiencing Hitches.
It records the thermal pressure level comprehensively judged by the system, not a specific temperature, nor can it tell you which function in the App caused the heating.
It has several states:
- Nominal: Temperature is in the normal range
- Fair: Temperature is slightly elevated, measures taken by the system will reduce performance
- Serious: Thermal state is high, measures taken by the system will reduce performance
- Critical: Thermal state has significantly impacted system performance, the device needs to cool down
- Unknown: Instruments did not obtain usable thermal state data, cannot be interpreted as normal temperature
Here, pay attention to the causal relationship:
The App's prolonged high load causes the device temperature to rise, then the system takes cooling measures, available performance drops, and the Commit / Render / GPU phases are more likely to exceed the time budget.
So Thermal State might be an amplifying factor for stuttering, but it cannot pinpoint the specific cause.
If the test period has already reached Serious, it's best to let the device cool down and re-collect data, otherwise the comparison environment might be different due to heating.
The application itself can read this state via ProcessInfo.processInfo.thermalState.
Hangs
It's related to RunLoop, focusing on:
Has the App's main thread been unable to process new events for a long time?
What does this mean? For example, a button is tapped, and the main thread starts processing images, reading files, or waiting for locks. Until these processes finish, new taps cannot be processed, and the user feels the App is stuck. This is a Hang.
The main thread has a RunLoop, which continuously repeats:
- Wait for events
- Receive and process events
- Update UI
- Wait for events again
If the main thread takes too long to process a single task and cannot return to the "wait for events" state, Instruments will mark this period as a potential bug.
Summary
First, understand the iOS rendering pipeline, starting from the App side, to the Render Server, to the GPU, to the screen. This is a complete chain. We need to know where in this chain performance is being consumed, and Instruments can collect metrics for various operations along this chain to help us locate problems.
For example: You feel a scrolling list is stuttering. First, use Animation Hitches to find the stuttering frames. Combine metrics like Frame Lifetimes, User Events, Commits, Renders, GPU, and Display to confirm what this frame experienced from input, App processing, Render Server, GPU, to final on-screen display.
If the problem is on the App's CPU side and is a main thread issue, use Time Profiler to locate the time-consuming method. If the problem occurs in the Render Server or GPU, you need to check the layer tree, off-screen rendering, and actual GPU execution. Thermal State can confirm whether the test results were affected by heating.
Then repeat the cycle until the Hitches are eliminated one by one.