跪拜 Guibai
← Back to the summary

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:

  1. Run the project in Profile mode
  2. First, select Animation Hitches, which can identify which frame was not submitted on time
  3. Hitch type will tell you which phase caused the time consumption
  4. Frame LifeTimes will tell you the specific time interval of the delay
  5. Filter the time interval and use Time Profiler to locate the specific time-consuming method
  6. Analyze what this method is doing and how to optimize it
  7. Repeat this process until you feel the performance is acceptable

Running the App in Profile mode will bring up the Instruments panel:

Instrument_1.png

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:

Hitches_1.png

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:

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:

Hitches_FrameLifetimes.png

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:

Hitches_FrameLifetimes_2.png

Frame 2:

Hitches_FrameLifetimes_3.png

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:

Time Profiler

Continuing the analysis of this frame, long-press to select the area from Frame Lifetimes to the Commit Phase:

Hitches_TimeProfile_1.png

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:

Hitches_TimeProfile_2.png

Clicking to jump can directly locate this code's position in the project:

Hitches_TimeProfile_4.png

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:

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

Hitches_2.png

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:

  1. The App side calculates the content
  2. Commit to the Render Server
  3. Submit to the GPU
  4. Draw into the frame buffer
  5. 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:

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:

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:

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:

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:

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:

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:

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:

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:

Look at a demo, adding an 80ms time-consuming task in a drag callback:

UserEvents_1.png

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:

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 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:

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:

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:

Render Count

If you see Render Count in the details, it indicates there are extra Renders / off-screen rendering here.

You can further check:

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:

Divided into three segments, if it's:

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:

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:

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:

ThreadStateTrace_1.png

Meaning of the fields in the chart:

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:

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:

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.