Skip to content

Commit 53a3dbe

Browse files
yanlingwang23Devtools-frontend LUCI CQ
authored andcommitted
[RPP] Add forced reflow insight
This insight displays the most time-consuming function calls resulting in forced reflows along with related bottom-up stack trace data. Screenshot: https://imgur.com/a/qwBByxH Bug:369766156 Change-Id: I7b0256a97519c242c45008ccfafcde2839cadc98 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/5894042 Commit-Queue: Yanling Wang <[email protected]> Reviewed-by: Paul Irish <[email protected]> Reviewed-by: Andres Olivares <[email protected]>
1 parent 1827f11 commit 53a3dbe

File tree

16 files changed

+399
-0
lines changed

16 files changed

+399
-0
lines changed

config/gni/devtools_grd_files.gni

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,7 @@ grd_files_debug_sources = [
11111111
"front_end/models/trace/insights/DOMSize.js",
11121112
"front_end/models/trace/insights/DocumentLatency.js",
11131113
"front_end/models/trace/insights/FontDisplay.js",
1114+
"front_end/models/trace/insights/ForcedReflow.js",
11141115
"front_end/models/trace/insights/ImageDelivery.js",
11151116
"front_end/models/trace/insights/InteractionToNextPaint.js",
11161117
"front_end/models/trace/insights/LCPDiscovery.js",
@@ -1865,6 +1866,7 @@ grd_files_debug_sources = [
18651866
"front_end/panels/timeline/components/insights/DocumentLatency.js",
18661867
"front_end/panels/timeline/components/insights/EventRef.js",
18671868
"front_end/panels/timeline/components/insights/FontDisplay.js",
1869+
"front_end/panels/timeline/components/insights/ForcedReflow.js",
18681870
"front_end/panels/timeline/components/insights/Helpers.js",
18691871
"front_end/panels/timeline/components/insights/ImageDelivery.js",
18701872
"front_end/panels/timeline/components/insights/InteractionToNextPaint.js",

front_end/models/trace/Processor.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ describeWithEnvironment('TraceProcessor', function() {
409409
'ThirdParties',
410410
'SlowCSSSelector',
411411
'LongCriticalNetworkTree',
412+
'ForcedReflow',
412413
]);
413414

414415
const orderWithMetadata = await getInsightOrder(true);
@@ -427,6 +428,7 @@ describeWithEnvironment('TraceProcessor', function() {
427428
'ThirdParties',
428429
'SlowCSSSelector',
429430
'LongCriticalNetworkTree',
431+
'ForcedReflow',
430432
]);
431433
});
432434
});

front_end/models/trace/Processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ export class TraceProcessor extends EventTarget {
366366
ThirdParties: null,
367367
SlowCSSSelector: null,
368368
LongCriticalNetworkTree: null,
369+
ForcedReflow: null,
369370
};
370371

371372
// Determine the weights for each metric based on field data, utilizing the same scoring curve that Lighthouse uses.

front_end/models/trace/insights/BUILD.gn

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ devtools_module("insights") {
1313
"DOMSize.ts",
1414
"DocumentLatency.ts",
1515
"FontDisplay.ts",
16+
"ForcedReflow.ts",
1617
"ImageDelivery.ts",
1718
"InteractionToNextPaint.ts",
1819
"LCPDiscovery.ts",
@@ -55,6 +56,7 @@ ts_library("unittests") {
5556
"DOMSize.test.ts",
5657
"DocumentLatency.test.ts",
5758
"FontDisplay.test.ts",
59+
"ForcedReflow.test.ts",
5860
"ImageDelivery.test.ts",
5961
"InteractionToNextPaint.test.ts",
6062
"LCPDiscovery.test.ts",
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2024 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6+
import {getInsightOrError} from '../../../testing/InsightHelpers.js';
7+
import {TraceLoader} from '../../../testing/TraceLoader.js';
8+
9+
export async function processTrace(testContext: Mocha.Suite|Mocha.Context|null, traceFile: string) {
10+
const {parsedTrace, insights} = await TraceLoader.traceEngine(testContext, traceFile);
11+
if (!insights) {
12+
throw new Error('No insights');
13+
}
14+
15+
return {data: parsedTrace, insights};
16+
}
17+
18+
describeWithEnvironment('ForcedReflow', function() {
19+
it('generates call stacks', async function() {
20+
const {data, insights} = await processTrace(this, 'forced-reflow.json.gz');
21+
assert.strictEqual(insights.size, 1);
22+
const insight =
23+
getInsightOrError('ForcedReflow', insights, data.Meta.navigationsByNavigationId.values().next().value);
24+
25+
assert.strictEqual(insight.topLevelFunctionCallData?.topLevelFunctionCall.columnNumber, 25217);
26+
assert.strictEqual(insight.topLevelFunctionCallData?.topLevelFunctionCall.lineNumber, 6);
27+
assert.strictEqual(insight.topLevelFunctionCallData?.totalReflowTime, 26052);
28+
29+
const callStack = insight.aggregatedBottomUpData[1];
30+
assert.strictEqual(callStack.bottomUpData.columnNumber, 137906);
31+
assert.strictEqual(callStack.bottomUpData.lineNumber, 6);
32+
assert.lengthOf(callStack.relatedEvents, 2);
33+
});
34+
});
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright 2024 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import * as i18n from '../../../core/i18n/i18n.js';
6+
import type * as Protocol from '../../../generated/protocol.js';
7+
import type {Warning} from '../handlers/WarningsHandler.js';
8+
import * as Helpers from '../helpers/helpers.js';
9+
import * as Types from '../types/types.js';
10+
11+
import {
12+
type BottomUpCallStack,
13+
type ForcedReflowAggregatedData,
14+
InsightCategory,
15+
type InsightModel,
16+
type RequiredData,
17+
} from './types.js';
18+
19+
export function deps(): ['Warnings', 'Renderer'] {
20+
return ['Warnings', 'Renderer'];
21+
}
22+
23+
const UIStrings = {
24+
/**
25+
*@description Title of an insight that provides details about Forced reflow.
26+
*/
27+
title: 'Forced reflow',
28+
/**
29+
* @description Text to describe the forced reflow.
30+
*/
31+
description:
32+
'Many APIs, typically reading layout geometry, force the rendering engine to pause script execution in order to calculate the style and layout. Learn more about [forced reflow](https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing#avoid-forced-synchronous-layouts) and its mitigations.',
33+
};
34+
35+
const str_ = i18n.i18n.registerUIStrings('models/trace/insights/ForcedReflow.ts', UIStrings);
36+
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
37+
38+
export type ForcedReflowInsightModel = InsightModel<{
39+
topLevelFunctionCallData: ForcedReflowAggregatedData | undefined,
40+
aggregatedBottomUpData: BottomUpCallStack[],
41+
}>;
42+
43+
function aggregateForcedReflow(
44+
data: Map<Warning, Types.Events.Event[]>,
45+
entryToNodeMap: Map<Types.Events.Event, Helpers.TreeHelpers.TraceEntryNode>):
46+
[ForcedReflowAggregatedData|undefined, BottomUpCallStack[]] {
47+
const dataByTopLevelFunction = new Map<string, ForcedReflowAggregatedData>();
48+
const bottomUpDataMap = new Map<string, BottomUpCallStack>();
49+
const forcedReflowEvents = data.get('FORCED_REFLOW');
50+
if (!forcedReflowEvents || forcedReflowEvents.length === 0) {
51+
return [undefined, []];
52+
}
53+
54+
forcedReflowEvents.forEach(e => {
55+
// Gather the stack traces by searching in the tree
56+
const traceNode = entryToNodeMap.get(e);
57+
58+
if (!traceNode) {
59+
return;
60+
}
61+
// Compute call stack fully
62+
const bottomUpData: (Types.Events.CallFrame|Protocol.Runtime.CallFrame)[] = [];
63+
let currentNode = traceNode;
64+
let previousNode;
65+
const childStack: Protocol.Runtime.CallFrame[] = [];
66+
67+
// Some profileCalls maybe constructed as its children in hierarchy tree
68+
while (currentNode.children.length > 0) {
69+
const childNode = currentNode.children[0];
70+
if (!previousNode) {
71+
previousNode = childNode;
72+
}
73+
const eventData = childNode.entry;
74+
if (Types.Events.isProfileCall(eventData)) {
75+
childStack.push(eventData.callFrame);
76+
}
77+
currentNode = childNode;
78+
}
79+
80+
// In order to avoid too much information, we only contain 2 levels bottomUp data,
81+
while (childStack.length > 0 && bottomUpData.length < 2) {
82+
const traceData = childStack.pop();
83+
if (traceData) {
84+
bottomUpData.push(traceData);
85+
}
86+
}
87+
88+
let node = traceNode.parent;
89+
let topLevelFunctionCall;
90+
let topLevelFunctionCallEvent: Types.Events.Event|undefined;
91+
while (node) {
92+
const eventData = node.entry;
93+
if (Types.Events.isProfileCall(eventData)) {
94+
if (bottomUpData.length < 2) {
95+
bottomUpData.push(eventData.callFrame);
96+
}
97+
} else {
98+
// We have finished searching bottom up data
99+
if (Types.Events.isFunctionCall(eventData) && eventData.args.data &&
100+
Types.Events.objectIsCallFrame(eventData.args.data)) {
101+
topLevelFunctionCall = eventData.args.data;
102+
topLevelFunctionCallEvent = eventData;
103+
if (bottomUpData.length === 0) {
104+
bottomUpData.push(topLevelFunctionCall);
105+
}
106+
} else {
107+
// Sometimes the top level task can be other JSInvocation event
108+
// then we use the top level profile call as topLevelFunctionCall's data
109+
const previousData = previousNode?.entry;
110+
if (previousData && Types.Events.isProfileCall(previousData)) {
111+
topLevelFunctionCall = previousData.callFrame;
112+
topLevelFunctionCallEvent = previousNode?.entry;
113+
}
114+
}
115+
break;
116+
}
117+
previousNode = node;
118+
node = node.parent;
119+
}
120+
121+
if (!topLevelFunctionCall || !topLevelFunctionCallEvent || bottomUpData.length === 0) {
122+
return;
123+
}
124+
const bottomUpDataId =
125+
bottomUpData[0].scriptId + ':' + bottomUpData[0].lineNumber + ':' + bottomUpData[0].columnNumber + ':';
126+
127+
const data = bottomUpDataMap.get(bottomUpDataId) ?? {
128+
bottomUpData: bottomUpData[0],
129+
totalTime: 0,
130+
relatedEvents: [],
131+
};
132+
data.totalTime += (e.dur ?? 0);
133+
data.relatedEvents.push(e);
134+
bottomUpDataMap.set(bottomUpDataId, data);
135+
136+
const aggregatedDataId =
137+
topLevelFunctionCall.scriptId + ':' + topLevelFunctionCall.lineNumber + ':' + topLevelFunctionCall.columnNumber;
138+
if (!dataByTopLevelFunction.has(aggregatedDataId)) {
139+
dataByTopLevelFunction.set(aggregatedDataId, {
140+
topLevelFunctionCall,
141+
totalReflowTime: 0,
142+
bottomUpData: new Set<string>(),
143+
topLevelFunctionCallEvents: [],
144+
});
145+
}
146+
const aggregatedData = dataByTopLevelFunction.get(aggregatedDataId);
147+
if (aggregatedData) {
148+
aggregatedData.totalReflowTime += (e.dur ?? 0);
149+
aggregatedData.bottomUpData.add(bottomUpDataId);
150+
aggregatedData.topLevelFunctionCallEvents.push(topLevelFunctionCallEvent);
151+
}
152+
});
153+
154+
let topTimeConsumingDataId = '';
155+
let maxTime = 0;
156+
dataByTopLevelFunction.forEach((value, key) => {
157+
if (value.totalReflowTime > maxTime) {
158+
maxTime = value.totalReflowTime;
159+
topTimeConsumingDataId = key;
160+
}
161+
});
162+
163+
const aggregatedBottomUpData: BottomUpCallStack[] = [];
164+
const topLevelFunctionCallData = dataByTopLevelFunction.get(topTimeConsumingDataId);
165+
const dataSet = dataByTopLevelFunction.get(topTimeConsumingDataId)?.bottomUpData;
166+
if (dataSet) {
167+
dataSet.forEach(value => {
168+
const callStackData = bottomUpDataMap.get(value);
169+
if (callStackData && callStackData.totalTime > Helpers.Timing.milliToMicro(Types.Timing.Milli(1))) {
170+
aggregatedBottomUpData.push(callStackData);
171+
}
172+
});
173+
}
174+
175+
return [topLevelFunctionCallData, aggregatedBottomUpData];
176+
}
177+
178+
function finalize(partialModel: Omit<ForcedReflowInsightModel, 'title'|'description'|'category'|'shouldShow'>):
179+
ForcedReflowInsightModel {
180+
return {
181+
title: i18nString(UIStrings.title),
182+
description: i18nString(UIStrings.description),
183+
category: InsightCategory.ALL,
184+
shouldShow: partialModel.topLevelFunctionCallData !== undefined && partialModel.aggregatedBottomUpData.length !== 0,
185+
...partialModel,
186+
};
187+
}
188+
189+
export function generateInsight(traceParsedData: RequiredData<typeof deps>): ForcedReflowInsightModel {
190+
const warningsData = traceParsedData.Warnings;
191+
const entryToNodeMap = traceParsedData.Renderer.entryToNode;
192+
193+
if (!warningsData) {
194+
throw new Error('no warnings data');
195+
}
196+
197+
if (!entryToNodeMap) {
198+
throw new Error('no renderer data');
199+
}
200+
201+
const [topLevelFunctionCallData, aggregatedBottomUpData] =
202+
aggregateForcedReflow(warningsData.perWarning, entryToNodeMap);
203+
204+
return finalize({
205+
relatedEvents: topLevelFunctionCallData?.topLevelFunctionCallEvents,
206+
topLevelFunctionCallData,
207+
aggregatedBottomUpData,
208+
});
209+
}

front_end/models/trace/insights/Models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * as CLSCulprits from './CLSCulprits.js';
66
export * as DocumentLatency from './DocumentLatency.js';
77
export * as DOMSize from './DOMSize.js';
88
export * as FontDisplay from './FontDisplay.js';
9+
export * as ForcedReflow from './ForcedReflow.js';
910
export * as ImageDelivery from './ImageDelivery.js';
1011
export * as InteractionToNextPaint from './InteractionToNextPaint.js';
1112
export * as LCPDiscovery from './LCPDiscovery.js';

front_end/models/trace/insights/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import type * as Common from '../../../core/common/common.js';
6+
import type * as Protocol from '../../../generated/protocol.js';
67
import type * as Handlers from '../handlers/handlers.js';
78
import type * as Lantern from '../lantern/lantern.js';
89
import type * as Types from '../types/types.js';
@@ -34,6 +35,19 @@ export interface LanternContext {
3435
metrics: Record<string, Lantern.Metrics.MetricResult>;
3536
}
3637

38+
export interface ForcedReflowAggregatedData {
39+
topLevelFunctionCall: Types.Events.CallFrame|Protocol.Runtime.CallFrame;
40+
totalReflowTime: number;
41+
bottomUpData: Set<string>;
42+
topLevelFunctionCallEvents: Types.Events.Event[];
43+
}
44+
45+
export interface BottomUpCallStack {
46+
bottomUpData: Types.Events.CallFrame|Protocol.Runtime.CallFrame;
47+
totalTime: number;
48+
relatedEvents: Types.Events.Event[];
49+
}
50+
3751
export type InsightModelsType = typeof Models;
3852

3953
export enum InsightWarning {

front_end/panels/timeline/components/SidebarSingleInsightSet.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describeWithEnvironment('SidebarSingleInsightSet', () => {
6666
'Optimize viewport for mobile',
6767
'Optimize DOM size',
6868
'CSS Selector costs',
69+
'Forced reflow',
6970
]);
7071

7172
const passedInsightTitles = getPassedInsights(component).flatMap(component => {
@@ -78,6 +79,7 @@ describeWithEnvironment('SidebarSingleInsightSet', () => {
7879
'Optimize viewport for mobile',
7980
'Optimize DOM size',
8081
'CSS Selector costs',
82+
'Forced reflow',
8183
]);
8284
});
8385

@@ -112,6 +114,7 @@ describeWithEnvironment('SidebarSingleInsightSet', () => {
112114
'Optimize viewport for mobile',
113115
'Optimize DOM size',
114116
'CSS Selector costs',
117+
'Forced reflow',
115118
]);
116119

117120
const passedInsightTitles = getPassedInsights(component).flatMap(component => {
@@ -126,6 +129,7 @@ describeWithEnvironment('SidebarSingleInsightSet', () => {
126129
'Optimize viewport for mobile',
127130
'Optimize DOM size',
128131
'CSS Selector costs',
132+
'Forced reflow',
129133
]);
130134
});
131135

@@ -165,6 +169,7 @@ describeWithEnvironment('SidebarSingleInsightSet', () => {
165169
'Optimize DOM size',
166170
'CSS Selector costs',
167171
'Long critical network tree',
172+
'Forced reflow',
168173
]);
169174

170175
const passedInsightTitles = getPassedInsights(component).flatMap(component => {
@@ -179,6 +184,7 @@ describeWithEnvironment('SidebarSingleInsightSet', () => {
179184
'Optimize DOM size',
180185
'CSS Selector costs',
181186
'Long critical network tree',
187+
'Forced reflow',
182188
]);
183189
});
184190

front_end/panels/timeline/components/SidebarSingleInsightSet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const INSIGHT_NAME_TO_COMPONENT: InsightNameToComponentMapping = {
7979
RenderBlocking: Insights.RenderBlocking.RenderBlocking,
8080
SlowCSSSelector: Insights.SlowCSSSelector.SlowCSSSelector,
8181
ThirdParties: Insights.ThirdParties.ThirdParties,
82+
ForcedReflow: Insights.ForcedReflow.ForcedReflow,
8283
Viewport: Insights.Viewport.Viewport,
8384
};
8485

0 commit comments

Comments
 (0)