emteria Blog | Android OS experts

Reducing janky frames by 95% on NXP i.MX8M Plus with precompiled system apps

Written by Igor Kalkov-Streitz | Jul 14, 2026

In one of our previous posts we showed how shipping Android's bundled system apps optimized into native code for on a Raspberry Pi 5 can cut the first-boot phase by a third. We applied the same change to an NXP i.MX8M Plus board, but boot time barely changed. Surprisingly, it fixed scroll stutter instead: the interface had been dropping frames on scroll, and after this change it stopped.
 
Getting there took two changes: the first one makes everything faster, the second one is what let the first one actually work on the device.

The bottleneck was the CPU, not the GPU

We were investigating a report that the UI was slow and stuttered a bit when scrolling on Android 14. Same board with an older Android 12 was working just fine. We checked the graphics stack, the GPU, the compositor, display clocks, since that's the obvious suspect on embedded hardware, but it wasn't the cause.
 
Android's own frame statistics showed the real bottleneck directly: almost every dropped frame was charged to the UI thread, not to drawing or compositing, and the GPU sat comfortably around 2 milliseconds per frame at the median. The CPU was pinned at its ceiling the entire time we scrolled, with no thermal throttling. The code deciding what the pixels should be wasn't running fast enough. The pixels themselves were never the problem.

Parts of the system interface were left uncompiled

The apps that draw the interface, the system UI and the launcher, are exactly the ones a stock configuration tends to leave at a profile-guided compiler level. That level is meant to optimize only the hot paths an app actually uses, based on a usage profile. With no profile speed baked into the image, it quietly falls back to verify, meaning the interpreter handles it at runtime. We understood that the most performance-sensitive code on the device, the code that has to redraw the screen 60 times a second, was shipping with no ahead-of-time optimization at all.

We tried raising those apps to speed profile, rebuilt the OS, and flashed to the device. Scroll jank was unchanged.

Runtime was rejecting precompiled code

The compilation process was correct and the main UI apps were genuinely built with the speed level inside the image. At runtime, though, the device ignored that native code and tried optimizing both apps anyway, reporting the apps as unoptimized:
 
# what the device reported after flashing the "speed" build
com.android.systemui   status=verify   reason=vdex
# the AOT-compiled code shipped, but the runtime discarded it
 
The reason was that Android's runtime doesn't trust precompiled native code blindly. Before running an app's compiled artifact, it checks that the set of shared libraries the app loads at runtime, matches it with the set assumed when the code was built by checking the app's class-loader context. If those two differ, the runtime concludes the code was built against a different environment, discards existing preoptimized code, and falls back to the interpreter, leaving it to Just-In-Time optimization of most used blocks. The log actually stated the cause very directly: a class-loader-context shared-library mismatch, where the build had assumed three libraries and the running device offered only one of them.
 
The missing pieces came from the window-extension libraries. Android uses these  components for additional multi-window and large-screen behavior. The build system had injected a dependency on them into every app's compiled artifact, since they existed as available platform libraries in the code, but our image never actually installed them. It means the optimized interface expected an environment the device didn't provide, and the runtime rejected it every time.
 
Installing the window-extension libraries, so the running device matched what the build had assumed, resolved this issue. Once the two sets lined up, the runtime accepted the AOT-compiled code:
 
# after installing the missing window-extension libraries
- com.android.systemui   status=verify   reason=vdex
+ com.android.systemui   status=speed    reason=prebuilt

Optimization was required for speedup

The fix arrived in two parts, but only one of them makes anything faster. The speedup comes entirely from speed-level compilation, the step that turns interpreted bytecode into native machine code. The window-extension libraries optimize nothing themselves, their only job is making the already-optimized code acceptable to the device.
 
We proved the two apart directly: before touching window extensions at all, optimizing a single interface app to speed directly on the device already dropped its scroll jank from 44.8% to 2.1%. That works because when the runtime compiles an app itself, it uses the app's real class-loader context, so the result always matches and is always accepted. The performance gain was there the whole time. The build-time version of the optimized code just wasn't being allowed to run.
 
The two changes play different roles in this context. Compiling to speed is the engine and gives the actual speedup. Installing the window-extension libraries is the key to start that engine. Without it, the engine built at compile time won't be executed on the device, the AOT-compiled code gets thrown away, and every app falls back to the interpreter. Only with both in place does the build-time win survive a real flash.

Scroll performance comparison

We measured the same sustained scroll on the same hardware and the same build, changing only the compiler optimization level of the main UI apps:

Frame statistics Before (verify) After (speed) Change
Janky frames 44.8% 2.1% -95%
50th-percentile frame time 36 ms 11 ms 3.3x faster
90th-percentile frame time 121 ms 13 ms 9.3x faster
Missed VSync events 94 0 Eliminated

These values were measured with Android's frame-timing statistics during a sustained scroll on an NXP i.MX8M Plus board, Android 14, quad-core Cortex-A53. Only the compiler optimization level of the interface apps changed between runs.
 
Sidenote: A frame is janky when the work behind it misses the roughly 16 millisecond window a 60Hz display allows. Before the change, even the median frame ran to 36 milliseconds, already over budget, and the worst tenth arrived at 121 milliseconds, late enough that the motion visibly stuttered. After the change, the median dropped to 11 milliseconds and the worst tenth to 13 milliseconds, both inside budget, and the stutter was gone.

The same change produced two different payoffs

On the Raspberry Pi 5, this same change improved boot time. A fresh image no longer had to optimize every app before the launcher appeared. Here it improved frame time instead. The lever is identical, but it shows where each device runs out of headroom.
 
Android 14's interface is a bit more compute-heavy per frame than the Android 12 interface that ran on the same silicon. Interpreted code additionally carries a warm-up cost on every burst of work. On this board's CPU, that cost was enough to blow past the 16 millisecond budget on nearly every frame of a scroll. Running the same interface as native code from the first instruction removed that cost, and the frames started landing on time. A faster core might have absorbed the interpreted cost and hidden the problem entirely, which is why the same software felt fine on the Pi's boot path and janky when scrolling on NXP i.MX8M Plus with Android 14.