iOS performance profiling on Windows without Xcode Instruments
When your app launches slowly, scrolling stutters, memory keeps growing or the phone heats up after a while, on a Mac you would open Instruments. This tutorial shows how to sample your iPhone from Windows with kxdevice, find the code behind the problem and fix it. You need an iPhone, a USB cable and a project that already builds with KXBuild. To install KXBuild, see the install page.
Install a Release build first
Debug builds aren’t optimized and carry assertions and debug info, so what you measure won’t match what your users see. Something slow in Debug may not be slow in Release. Before measuring performance, build with the Release configuration and install it on the phone.
1kxbuild build .\MyApp.xcodeproj -c Release --install
Release builds strip symbols by default, so call stacks show only addresses. When you need function names, build again with -D STRIP_INSTALLED_PRODUCT=NO.
1kxbuild build .\MyApp.xcodeproj -c Release --install -D STRIP_INSTALLED_PRODUCT=NO
Keep test conditions the same so you can compare runs. Turn off Low Power Mode, let the phone cool down if it’s hot, and repeat the same action several times to see the trend rather than trusting a single run. If you have an older model, test on it first. The slowest phones your users have expose problems first.
Launch the app and get its PID
Most sampling commands filter by process ID. Launching the app with kxdevice prints the PID. If the app is already running, find it with apps ps.
1kxdevice apps launch com.example.myapp
2kxdevice apps ps --apps
The sampling commands below keep printing until you press Ctrl-C. Add -n to limit the number of samples, and --udid to pick a device when several phones are connected.
If you’d rather not remember commands, run kxdevice web and open http://localhost:8780 in your browser. On the Instruments page on the left, you can select your app’s process and watch CPU, memory and frame rate graphs together. The process list below is sorted by CPU usage.
Slow launch
First find out which part is slow. Record a timestamp when the app starts initializing and log the elapsed time when the first screen appears.
1import os
2
3enum LaunchTimer {
4 static let start = CFAbsoluteTimeGetCurrent()
5 static let log = Logger(subsystem: "com.example.myapp", category: "launch")
6}
7
8// First line of application(_:didFinishLaunchingWithOptions:) or the App's init
9_ = LaunchTimer.start
10
11// viewDidAppear or onAppear of the first screen
12LaunchTimer.log.notice("First screen took \(CFAbsoluteTimeGetCurrent() - LaunchTimer.start, format: .fixed(precision: 3)) s")
With the Release build installed, kill the app first, then watch the log in one terminal and launch the app from another. This measures a cold launch.
1kxdevice apps kill com.example.myapp
2kxdevice ostrace --subsystem com.example.myapp
1kxdevice apps launch com.example.myapp
If the time to first screen is clearly too long, add a few more log lines in didFinishLaunching and the first screen’s setup code, and bisect until you find the slow part. Running kxdevice instruments stackshot during launch shows which functions the main thread is stuck in.
Common causes and fixes.
- Too many SDKs initialized synchronously at launch. Initialize analytics, push, ads and anything else that doesn’t affect the first screen after that screen appears, or on a background queue.
- Reading large files, migrating databases or parsing large JSON on the main thread. Move the work to
DispatchQueue.global()or Swift’sTask.detached, and return to the main thread to update the UI when it’s done. - The first screen waits for a network response before showing. Show cached content or a placeholder first, and refresh when the response arrives.
Scroll hitches
Watch for dropped frames with fluidity, then scroll the problem list back and forth on the phone.
1kxdevice instruments fluidity --pid 1234
Each dropped frame prints an event. If nothing new appears when you stop scrolling and events come in bursts as soon as you scroll, the problem is in creating and displaying the list’s cells. Then use stackshot to see which function the main thread is stuck in during those frames. This is where you need the Release build with symbols from the previous section.
1kxdevice instruments stackshot -n 20
Common causes and fixes.
- Decoding large images on the main thread. Images from
UIImage(named:)andUIImage(contentsOfFile:)are decoded the first time they’re displayed, so a multi-MB photo in a small icon slot causes a hitch every time. Generate thumbnails at display size in the background. On iOS 15 and later, usepreparingThumbnail(of:)orbyPreparingForDisplay(). On earlier versions, use ImageIO’sCGImageSourceCreateThumbnailAtIndex. - Synchronous work in cellForRowAt. Don’t read files, query the database or create a
DateFormatterthere. Prepare data in advance and reuse a singleDateFormatter. - Shadows and rounded corners causing offscreen rendering. Set
layer.shadowPathwhen adding shadows. For many images with rounded corners, crop them before displaying instead of settingmasksToBoundson every cell. - Long SwiftUI lists. Use
LazyVStackorList. Don’t do calculations or create formatters inbody, which gets called often.
Memory keeps growing
Most memory problems are objects that aren’t freed after a screen closes. First find an action that reproduces it, such as entering and leaving the same screen repeatedly, then record memory allocations and backtraces while you do it.
1kxdevice instruments allocations --pid 1234 --backtrace -n 20
Enter and leave the screen five or six times while recording. Allocations that only ever grow are the suspects, and their backtraces point to the code and class that created them. Check the memory graph in kxdevice web too. If memory steps up each time you enter and leave and doesn’t drop after you leave, that screen is almost certainly leaking.
Common causes and fixes.
- Retain cycles through closures. An object holds a closure that uses
self. Start the closure with[weak self]. In Objective-C, use__weak typeof(self) weakSelf = self. - Timer and CADisplayLink holding their target strongly.
Timer.scheduledTimer(timeInterval:target:selector:...)andCADisplayLinkkeep their target alive, so callinvalidate()when the screen disappears. In Swift, use the block-based Timer and captureselfweakly in the block. - Delegates declared as strong references. Declare your own delegate properties as
weak var delegate, and asweakin Objective-C. - Notification observers not removed. Keep the object returned by
addObserver(forName:object:queue:using:)and remove it when the screen is deallocated. Captureselfweakly in the block here too. - Caches without a limit. Use
NSCachefor image and data caches. The system clears it automatically under memory pressure, while a dictionary-based cache only keeps growing.
After the fix, confirm the object is actually freed. In the Debug configuration, add a deinit to the screen. It should print when you leave the screen.
1deinit {
2 print("DetailViewController deinit")
3}
In Objective-C, print from dealloc. The output appears in the KXBuild Console channel of the VS Code Output panel. If nothing prints after you leave the screen, something else is still holding on to it.
High CPU and a hot phone
Start with overall CPU, printed once a second.
1kxdevice instruments sysmon -i 1
The CPU: 35.2% at the start of each line is the device’s total load. Leave the app on a static screen and don’t touch anything. If the number stays high, some code keeps running in the background. The process list in kxdevice web shows CPU usage per process, so you can confirm whether it’s your app.
Next, take an energy sample of the app process. It prints a set of readings starting with energy.. Look for the highest one.
1kxdevice instruments energy 1234
To find which function is using the CPU, sample call stacks with stackshot.
Common causes and fixes.
- Timers that fire too often or never stop. Stop polling timers when the screen isn’t visible, and replace polling with push, notifications or KVO wherever you can.
- Animations running while not visible. Stop looping animations such as
CADisplayLinkand Lottie when the screen disappears, and also when the app goes to the background. - Location accuracy higher than needed. If you don’t need navigation-level accuracy, lower
desiredAccuracy, and callstopUpdatingLocation()when you’re done. - Repeatedly triggered layout. Changing layout-affecting properties in
layoutSubviewsorviewDidLayoutSubviewscauses round after round of layout.
Narrow it down with breakpoints in VS Code
Sampling tells you roughly what kind of problem it is and which screen it’s on. To find the exact line, the debugger is faster. Switch the Build configuration node back to Debug and press F5. Timings in Debug differ from Release, so use this step only to locate the code, and measure again with a Release build after the fix.
- Pause when it hangs. The moment the UI freezes, click “Pause” on the debug toolbar and look at the main thread in the Call Stack panel. The function it stopped in is where it’s stuck. Type
bt allin the Debug Console to print the call stacks of all threads. - A function you suspect is called too often. Add a logpoint on its first line that prints a message, and count how many lines one scroll produces.
- Stop only in a specific situation. Use a conditional breakpoint such as
images.count > 500to stop once memory has grown enough and see who is adding to it.
For the full guide to breakpoints, see Debug iOS apps on Windows with LLDB, breakpoints and crash logs.
After changing the code, switch back to Release and measure again with the same actions and the same commands, then compare with the output from before. It’s fixed when there are fewer dropped frame events, memory drops back after leaving the screen and CPU goes down when the app is idle.
Xcode Instruments equivalents
If you’ve used Instruments, find the command for each template below. Prefix every command with kxdevice.
| Instruments template | Command |
|---|---|
| Activity Monitor | instruments sysmon -i 1 |
| Time Profiler | instruments stackshot |
| Allocations / Leaks | instruments allocations --pid <pid> --backtrace |
| Core Animation / Animation Hitches | instruments fluidity --pid <pid> |
| Energy Log | instruments energy <pid> |
| os_signpost / Logging | instruments activitytrace --pid <pid> |
| System Trace | instruments coreprofile |
For the options of each command, see Performance profiling and the kxdevice command reference.