Profiling
Change detection cycle sometimes can cause your application jankiness.
Since change detection is synchronous, when it takes too much time,
the browser doesn't have enough time to re-render frames, so it starts dropping them.
Hence, it’s important that the process of computing changes is as fast as possible.
To that end, Angular ships with a built-in profiler function and the ability to define a custom implementation.
Sometimes it's hard to tell if the slowness is due to the act of computing the changes being slow,
or due to the act of applying those changes to the UI (change detection).
To figure that out, the profiler shipped by Angular repeatedly performs change detection without invoking any user actions,
such as clicking buttons or entering text in input fields. In a properly-designed application repeated
attempts to run change detection without any user actions should be consistent and super quick.
Ideally the number printed by the profiler should be well below the length of a single animation frame (16ms).
For best results it should be under 3 milliseconds in order to leave room for the application logic,
the UI updates and browser's rendering pipeline to fit within the 16 millisecond frame (assuming the 60 FPS target frame rate).
To enable the built-in Angular profiler, we need to execute the
enableDebugTools
function. It’s not exposed by default in the global namespace, but we don't really need that.
Because we only need to execute it once, we could do it as part of the application code, perhaps in the main.ts
similarly to enableProdMode
.
The function takes a reference to an object that can provide in injector, e.g. ModuleRef
:
import { bootstrapApplication, enableDebugTools } from '@angular/platform-browser';
platformBrowserDynamic().bootstrapModule(AppModule)
.then(moduleRef=> {
enableDebugTools({injector: moduleRef.injector} as any);
})
.catch(err => console.error(err));
When executed, the enableDebugTools
simply adds the profiler
to the global ng
namespace:
export function enableDebugTools<T>(ref: ComponentRef<T>): ComponentRef<T> {
exportNgVar(PROFILER_GLOBAL_NAME, new AngularProfiler(ref));
return ref;
}
Once it’s available in the console, we can run profiler.timeChangeDetection
function to measure change detection timings:
This is
the gist
of the timeChangeDetection
function:
timeChangeDetection(config: any): ChangeDetectionPerfRecord {
const start = performanceNow();
let numTicks = 0;
while (numTicks < 5 || (performanceNow() - start) < 500) {
this.appRef.tick();
numTicks++;
}
const end = performanceNow();
const msPerTick = (end - start) / numTicks;
window.console.log(`ran ${numTicks} change detection cycles`);
window.console.log(`${msPerTick.toFixed(2)} ms per check`);
}
It runs the tick
method (global change detection) for as many cycles as it can fit into 500ms,
but if combined they take more than 500ms the number of cycles is limited to 5.
It then computes the average amount of time it took to perform a single cycle of change detection in milliseconds
and prints it to the console.
The calculate numbers depend on the current state of the UI. As you run the profiler on the different pages,
you will likely see that the numbers differ as you go from one screen to another.
For techniques to reduce change detection cost, check out
Optimization section.
The profiler can also create a CPU profile if you pass the {record: true}
param object:
ng.profiler.timeChangeDetection({ record: true });
However, the API
that’s used to record the profile inside the timeChangeDetection
is not standard.
Besides, the Profiler
page where you can find those recordings is also deprecated in Chrome:
We could write our own function to profile change detection using modern
User Timing API
to track timestamps (marks) and durations (measures). Let’s see how to do that.
Local change detection with a custom function
First, we need to write our custom implementation of the timeChangeDetection
function. Here’s how it could look like:
import { ChangeDetectorRef } from '@angular/core';
function customTimeChangeDetection(hostElement) {
performance.mark('Start');
const start = performance.now();
let numTicks = 0;
const injector = (window as any).ng.getInjector(hostElement);
const viewRef = injector.get(ChangeDetectorRef)
while (numTicks < 5 || (performance.now() - start) < 500) {
viewRef.detectChanges();
numTicks++;
}
performance.mark('End');
const entry = performance.measure('Change Detection', 'Start', 'End');
const msPerTick = entry.duration / numTicks;
window.console.log(`ran ${numTicks} change detection cycles`);
window.console.log(`${msPerTick.toFixed(2)} ms per check`);
}
The function will accept a host element for a particular component and then will
use its injector to get a reference to the underlying ChangeDetectionRef
:
const injector = (window as any).ng.getInjector(hostElement);
const viewRef = injector.get(ChangeDetectorRef)
This will enable local change detection profiling for any child component.
Notice that we’re also using performance.mark
and performance.measure
API to compute the average time.
Since we're accessing global ng
namespace that's only available in the development mode,
make sure you don't have enableProdMode()
in your code when running the function.
Then we need to expose our custom function globally:
platformBrowserDynamic()
.bootstrapModule(AppModule, {ngZoneEventCoalescing: true})
.then((ref) => {
(window as any).customTimeChangeDetection = customTimeChangeDetection;
})
.catch((err) => {...});
and then run it like this:
customTimeChangeDetection(document.querySelector('kw-header'));
Before profiling anything, it’s also a good idea to adjust the CPU throttling to emulate a real life machine,
not a powerful development workstation:
Once we run the profiler, here’s what we have:
If during the time the profiler runs you have recording enabled, you’ll see the following entries under the Timings
section:
But as you can see it spans the entire period of multiple change detection cycles, not just one cycle.
So there’s not much use for this profiling record when it comes to showing it in the performance tab.
Let’s now see how we can improve our profiling even more by implementing granular profiler
to measure time for subparts of the change detection.
Custom granular profiler
Angular exposes
setProfiler
function in the global ng
namespace that takes a callback function which will be invoked before
and after performing certain actions at runtime (for example, before and after it executes a template update function).
To enable that, Angular instruments internal framework code to call the function at certain points
in the lifecycle of the change detection process.
The common set of events is defined on the
ProfilerEvent
enum. Here’s the list of events in the form of an array:
const profilerEvent = [
'TemplateCreateStart',
'TemplateCreateEnd',
'TemplateUpdateStart',
'TemplateUpdateEnd',
'LifecycleHookStart',
'LifecycleHookEnd',
'OutputStart',
'OutputEnd',
];
If we register a custom callback function and run the change detection:
const profilerEvent = [...];
(window as any).ng.ɵsetProfiler((event, instance, hookOrListener) => {
console.log(profilerEvent[event]);
});
ng.applyChanges(ng.getComponent(document.querySelector('child-cmp')));
Here is, for example, how the framework embeds the profiler calling logic into
executeTemplate
function to send two events TemplateUpdateStart
and TemplateUpdateEnd
:
function executeTemplate<T>(tView, lView, templateFn, rf, context) {
const consumer = getReactiveLViewConsumer(lView, REACTIVE_TEMPLATE_CONSUMER);
const prevSelectedIndex = getSelectedIndex();
const isUpdatePhase = rf & RenderFlags.Update;
try {
...
const preHookType = isUpdatePhase
? ProfilerEvent.TemplateUpdateStart
: ProfilerEvent.TemplateCreateStart;
profiler(preHookType, context as unknown as {});
consumer.runInContext(templateFn, rf, context);
} finally {
...
const postHookType =
isUpdatePhase ? ProfilerEvent.TemplateUpdateEnd : ProfilerEvent.TemplateCreateEnd;
profiler(postHookType, context as unknown as {});
}
}
we could see all those events listed in the console:
So let’s suppose we want to measure how much time it takes for Angular to render the template.
This means we need to measure time between TemplateUpdateStart
and TemplateUpdateEnd
.
To do that, we could put together a function like this:
const profilerEvent = [...];
function timeTemplateUpdate(event, instance, hookOrListener) {
if (!instance?.constructor) return;
const instanceName = instance.constructor.name;
const evtName = profilerEvent[event];
switch (evtName) {
case 'TemplateUpdateStart': {
performance.mark(`${instanceName}:TemplateUpdateStart`);
break;
}
case 'TemplateUpdateEnd': {
performance.mark(`${instanceName}:TemplateUpdateEnd`);
const entry = performance.measure(`Template Update for ${instanceName}`,
`${instanceName}:TemplateUpdateStart`,
`${instanceName}:TemplateUpdateEnd`
);
console.log(entry.name, entry.duration)
break;
}
}
});
Then, when we register it
ng.ɵsetProfiler(timeTemplateUpdate);
and run it in the console, we’ll see the following:
And since we used User Timing API, if the recording is enabled, we’ll see the following segments under the user timings section:
That’s it folks.