Skip to content

Commit 0e0de1d

Browse files
guangyuexuDevtools-frontend LUCI CQ
authored andcommitted
Show number of CSS invalidation in DevTools performance panel
In the Selector stats table, a new column is added to display the number of invalidated nodes for each selector. With the information, developers can identify the CSS rules that invalidate a large number of nodes to reduce over-invalidation, so that they can improve long Recalculate style events by rewriting expensive selectors. More details can be found in this design doc: https://docs.google.com/document/d/1K7ng5TdeyJemKqXP2I7r1IQPYz4Xb059E6d7M2B8SxY/edit?tab=t.0 Bug: 379886422 Change-Id: I57daf28191cd002624f1fbbfd843c6b009316eaf Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6333633 Reviewed-by: Paul Irish <[email protected]> Commit-Queue: Guangyue Xu <[email protected]>
1 parent a53fa82 commit 0e0de1d

File tree

14 files changed

+521
-132
lines changed

14 files changed

+521
-132
lines changed

front_end/models/trace/handlers/SelectorStatsHandler.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,90 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import type * as Protocol from '../../../generated/protocol.js';
56
import * as Types from '../types/types.js';
67

8+
interface SelectorWithStyleSheedId {
9+
selector: string;
10+
styleSheetId: string;
11+
}
12+
13+
interface InvalidatedNode {
14+
frame: string;
15+
backendNodeId: Protocol.DOM.BackendNodeId;
16+
type: Types.Events.InvalidationEventType;
17+
selectorList: SelectorWithStyleSheedId[];
18+
ts: Types.Timing.Micro;
19+
tts?: Types.Timing.Micro;
20+
subtree:
21+
boolean; // Indicates if the invalidation applies solely to the node (false) or extends to all its descendants (true)
22+
lastUpdateLayoutTreeEventTs: Types.Timing.Micro;
23+
}
24+
725
let lastUpdateLayoutTreeEvent: Types.Events.UpdateLayoutTree|null = null;
26+
let lastInvalidatedNode: InvalidatedNode|null = null;
827

928
const selectorDataForUpdateLayoutTree = new Map<Types.Events.UpdateLayoutTree, {
1029
timings: Types.Events.SelectorTiming[],
1130
}>();
1231

32+
const invalidatedNodeList = new Array<InvalidatedNode>();
33+
1334
export function reset(): void {
1435
lastUpdateLayoutTreeEvent = null;
36+
lastInvalidatedNode = null;
1537
selectorDataForUpdateLayoutTree.clear();
38+
invalidatedNodeList.length = 0;
1639
}
1740

1841
export function handleEvent(event: Types.Events.Event): void {
42+
if (Types.Events.isStyleRecalcInvalidationTracking(event)) {
43+
/**
44+
* CSS Style substree invalidation
45+
* A subtree invalidation comes with two records, 1) a StyleInvalidatorInvalidationTracking
46+
* event 2) following with a StyleRecalcInvalidationTracking event. List of selectors and style
47+
* sheet ID information is stored in the 1st event. Subtree flag is stored in the 2nd
48+
* event.
49+
*/
50+
if (event.args.data.subtree &&
51+
event.args.data.reason === Types.Events.StyleRecalcInvalidationReason.RELATED_STYLE_RULE &&
52+
lastInvalidatedNode && event.args.data.nodeId === lastInvalidatedNode.backendNodeId) {
53+
lastInvalidatedNode.subtree = true;
54+
return;
55+
}
56+
}
57+
1958
if (Types.Events.isSelectorStats(event) && lastUpdateLayoutTreeEvent && event.args.selector_stats) {
2059
selectorDataForUpdateLayoutTree.set(lastUpdateLayoutTreeEvent, {
2160
timings: event.args.selector_stats.selector_timings,
2261
});
2362
return;
2463
}
2564

65+
if (Types.Events.isStyleInvalidatorInvalidationTracking(event)) {
66+
const selectorList = new Array<SelectorWithStyleSheedId>();
67+
event.args.data.selectors?.forEach(selector => {
68+
selectorList.push({
69+
selector: selector.selector,
70+
styleSheetId: selector.style_sheet_id,
71+
});
72+
});
73+
74+
if (selectorList.length > 0) {
75+
lastInvalidatedNode = {
76+
frame: event.args.data.frame,
77+
backendNodeId: event.args.data.nodeId,
78+
type: Types.Events.InvalidationEventType.StyleInvalidatorInvalidationTracking,
79+
selectorList,
80+
ts: event.ts,
81+
tts: event.tts,
82+
subtree: false,
83+
lastUpdateLayoutTreeEventTs: lastUpdateLayoutTreeEvent ? lastUpdateLayoutTreeEvent.ts : Types.Timing.Micro(0),
84+
};
85+
invalidatedNodeList.push(lastInvalidatedNode);
86+
}
87+
}
88+
2689
if (Types.Events.isUpdateLayoutTree(event)) {
2790
lastUpdateLayoutTreeEvent = event;
2891
return;
@@ -36,10 +99,12 @@ export interface SelectorStatsData {
3699
dataForUpdateLayoutEvent: Map<Types.Events.UpdateLayoutTree, {
37100
timings: Types.Events.SelectorTiming[],
38101
}>;
102+
invalidatedNodeList: InvalidatedNode[];
39103
}
40104

41105
export function data(): SelectorStatsData {
42106
return {
43107
dataForUpdateLayoutEvent: selectorDataForUpdateLayoutTree,
108+
invalidatedNodeList,
44109
};
45110
}

front_end/models/trace/insights/SlowCSSSelector.test.ts

Lines changed: 12 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,14 @@ describeWithEnvironment('SelectorStatsInsights', function() {
1616
assert.strictEqual(insight.totalMatchAttempts, 2444);
1717
assert.strictEqual(insight.totalMatchCount, 465);
1818

19-
const topElapsedMs = insight.topElapsedMs;
20-
const topMatchAttempts = insight.topMatchAttempts;
19+
const topSelectorElapsedMs = insight.topSelectorElapsedMs;
20+
const topSelectorMatchAttempts = insight.topSelectorMatchAttempts;
2121

22-
assert.lengthOf(topElapsedMs, 3);
23-
assert.lengthOf(topMatchAttempts, 3);
22+
assert.isNull(topSelectorElapsedMs);
23+
assert.isNotNull(topSelectorMatchAttempts);
2424

25-
assert.strictEqual(topElapsedMs[0].selector, ':root');
26-
assert.strictEqual(topElapsedMs[0]['elapsed (us)'], 14);
27-
assert.strictEqual(topElapsedMs[1].selector, 'abbr[title]');
28-
assert.strictEqual(topElapsedMs[1]['elapsed (us)'], 8);
29-
assert.strictEqual(topElapsedMs[2].selector, 'div');
30-
assert.strictEqual(topElapsedMs[2]['elapsed (us)'], 7);
31-
32-
assert.strictEqual(topMatchAttempts[0].selector, '.HG1dvd > *');
33-
assert.strictEqual(topMatchAttempts[0].match_attempts, 169);
34-
assert.strictEqual(topMatchAttempts[1].selector, '.gb_Bd > :only-child');
35-
assert.strictEqual(topMatchAttempts[1].match_attempts, 169);
36-
assert.strictEqual(topMatchAttempts[2].selector, 'div');
37-
assert.strictEqual(topMatchAttempts[2].match_attempts, 140);
25+
assert.strictEqual(topSelectorMatchAttempts.selector, '.gb_Bd > :only-child');
26+
assert.strictEqual(topSelectorMatchAttempts.match_attempts, 169);
3827
});
3928

4029
it('generates slow selectors by frame ID', async function() {
@@ -47,24 +36,13 @@ describeWithEnvironment('SelectorStatsInsights', function() {
4736
assert.strictEqual(insight.totalMatchAttempts, 32);
4837
assert.strictEqual(insight.totalMatchCount, 16);
4938

50-
const topElapsedMs = insight.topElapsedMs;
51-
const topMatchAttempts = insight.topMatchAttempts;
52-
53-
assert.lengthOf(topElapsedMs, 3);
54-
assert.lengthOf(topMatchAttempts, 3);
39+
const topSelectorElapsedMs = insight.topSelectorElapsedMs;
40+
const topSelectorMatchAttempts = insight.topSelectorMatchAttempts;
5541

56-
assert.strictEqual(topElapsedMs[0].selector, 'h1');
57-
assert.strictEqual(topElapsedMs[0]['elapsed (us)'], 2);
58-
assert.strictEqual(topElapsedMs[1].selector, ':root');
59-
assert.strictEqual(topElapsedMs[1]['elapsed (us)'], 2);
60-
assert.strictEqual(topElapsedMs[2].selector, 'iframe');
61-
assert.strictEqual(topElapsedMs[2]['elapsed (us)'], 2);
42+
assert.isNull(topSelectorElapsedMs);
43+
assert.isNotNull(topSelectorMatchAttempts);
6244

63-
assert.strictEqual(topMatchAttempts[0].selector, 'iframe');
64-
assert.strictEqual(topMatchAttempts[0].match_attempts, 4);
65-
assert.strictEqual(topMatchAttempts[1].selector, 'html::spelling-error');
66-
assert.strictEqual(topMatchAttempts[1].match_attempts, 3);
67-
assert.strictEqual(topMatchAttempts[2].selector, ':root');
68-
assert.strictEqual(topMatchAttempts[2].match_attempts, 3);
45+
assert.strictEqual(topSelectorMatchAttempts.selector, 'iframe');
46+
assert.strictEqual(topSelectorMatchAttempts.match_attempts, 4);
6947
});
7048
});

front_end/models/trace/insights/SlowCSSSelector.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import * as i18n from '../../../core/i18n/i18n.js';
66
import type * as Handlers from '../handlers/handlers.js';
7+
import type {SelectorStatsData} from '../handlers/SelectorStatsHandler.js';
78
import * as Helpers from '../helpers/helpers.js';
89
import {type SelectorTiming, SelectorTimingsKey} from '../types/TraceEvents.js';
910
import * as Types from '../types/types.js';
@@ -52,27 +53,32 @@ export const UIStrings = {
5253
*/
5354
enableSelectorData:
5455
'No CSS selector data was found. CSS selector stats need to be enabled in the performance panel settings.',
56+
/**
57+
*@description top CSS selector when ranked by elapsed time in ms
58+
*/
59+
topSelectorElapsedTime: 'Top selector elaspsed time',
60+
/**
61+
*@description top CSS selector when ranked by match attempt
62+
*/
63+
topSelectorMatchAttempt: 'Top selector match attempt',
5564
} as const;
5665

5766
const str_ = i18n.i18n.registerUIStrings('models/trace/insights/SlowCSSSelector.ts', UIStrings);
5867
export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
68+
const slowCSSSelectorThreshold = 500; // 500us threshold for slow selectors
5969

6070
export type SlowCSSSelectorInsightModel = InsightModel<typeof UIStrings, {
6171
totalElapsedMs: Types.Timing.Milli,
6272
totalMatchAttempts: number,
6373
totalMatchCount: number,
64-
topElapsedMs: Types.Events.SelectorTiming[],
65-
topMatchAttempts: Types.Events.SelectorTiming[],
74+
topSelectorElapsedMs: Types.Events.SelectorTiming | null,
75+
topSelectorMatchAttempts: Types.Events.SelectorTiming | null,
6676
}>;
6777

68-
function aggregateSelectorStats(
69-
data: Map<Types.Events.UpdateLayoutTree, {
70-
timings: Types.Events.SelectorTiming[],
71-
}>,
72-
context: InsightSetContext): SelectorTiming[] {
78+
function aggregateSelectorStats(data: SelectorStatsData, context: InsightSetContext): SelectorTiming[] {
7379
const selectorMap = new Map<String, SelectorTiming>();
7480

75-
for (const [event, value] of data) {
81+
for (const [event, value] of data.dataForUpdateLayoutEvent) {
7682
if (event.args.beginData?.frame !== context.frameId) {
7783
continue;
7884
}
@@ -103,8 +109,7 @@ function finalize(partialModel: PartialInsightModel<SlowCSSSelectorInsightModel>
103109
title: i18nString(UIStrings.title),
104110
description: i18nString(UIStrings.description),
105111
category: InsightCategory.ALL,
106-
state: partialModel.topElapsedMs.length !== 0 && partialModel.topMatchAttempts.length !== 0 ? 'informative' :
107-
'pass',
112+
state: partialModel.topSelectorElapsedMs && partialModel.topSelectorMatchAttempts ? 'informative' : 'pass',
108113
...partialModel,
109114
};
110115
}
@@ -117,7 +122,7 @@ export function generateInsight(
117122
throw new Error('no selector stats data');
118123
}
119124

120-
const selectorTimings = aggregateSelectorStats(selectorStatsData.dataForUpdateLayoutEvent, context);
125+
const selectorTimings = aggregateSelectorStats(selectorStatsData, context);
121126

122127
let totalElapsedUs = 0;
123128
let totalMatchAttempts = 0;
@@ -129,24 +134,34 @@ export function generateInsight(
129134
totalMatchCount += timing[SelectorTimingsKey.MatchCount];
130135
});
131136

132-
// sort by elapsed time
133-
const sortByElapsedMs = selectorTimings.toSorted((a, b) => {
134-
return b[SelectorTimingsKey.Elapsed] - a[SelectorTimingsKey.Elapsed];
135-
});
137+
let topSelectorElapsedMs: SelectorTiming|null = null;
138+
let topSelectorMatchAttempts: SelectorTiming|null = null;
136139

137-
// sort by match attempts
138-
const sortByMatchAttempts = selectorTimings.toSorted((a, b) => {
139-
return b[SelectorTimingsKey.MatchAttempts] - a[SelectorTimingsKey.MatchAttempts];
140-
});
140+
if (selectorTimings.length > 0) {
141+
// find the selector with most elapsed time
142+
topSelectorElapsedMs = selectorTimings.reduce((a, b) => {
143+
return a[SelectorTimingsKey.Elapsed] > b[SelectorTimingsKey.Elapsed] ? a : b;
144+
});
145+
146+
// check if the slowest selector is slow enough to trigger insights info
147+
if (topSelectorElapsedMs && topSelectorElapsedMs[SelectorTimingsKey.Elapsed] < slowCSSSelectorThreshold) {
148+
topSelectorElapsedMs = null;
149+
}
150+
151+
// find the selector with most match attempts
152+
topSelectorMatchAttempts = selectorTimings.reduce((a, b) => {
153+
return a[SelectorTimingsKey.MatchAttempts] > b[SelectorTimingsKey.MatchAttempts] ? a : b;
154+
});
155+
}
141156

142157
return finalize({
143158
// TODO: should we identify UpdateLayout events as linked to this insight?
144159
relatedEvents: [],
145160
totalElapsedMs: Types.Timing.Milli(totalElapsedUs / 1000.0),
146161
totalMatchAttempts,
147162
totalMatchCount,
148-
topElapsedMs: sortByElapsedMs.slice(0, 3),
149-
topMatchAttempts: sortByMatchAttempts.slice(0, 3),
163+
topSelectorElapsedMs,
164+
topSelectorMatchAttempts,
150165
});
151166
}
152167

front_end/models/trace/types/TraceEvents.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,7 @@ export function isScheduleStyleInvalidationTracking(event: Event): event is Sche
12051205

12061206
export const enum StyleRecalcInvalidationReason {
12071207
ANIMATION = 'Animation',
1208+
RELATED_STYLE_RULE = 'Related style rule',
12081209
}
12091210

12101211
export interface StyleRecalcInvalidationTracking extends Instant {
@@ -1235,6 +1236,8 @@ export interface StyleInvalidatorInvalidationTracking extends Instant {
12351236
subtree: boolean,
12361237
nodeName?: string,
12371238
extraData?: string,
1239+
// eslint-disable-next-line @typescript-eslint/naming-convention
1240+
selectors?: Array<{selector: string, style_sheet_id: string}>,
12381241
},
12391242
};
12401243
}
@@ -1289,6 +1292,9 @@ export interface ScheduleStyleRecalculation extends Instant {
12891292
args: Args&{
12901293
data: {
12911294
frame: string,
1295+
reason?: StyleRecalcInvalidationReason,
1296+
subtree?: boolean,
1297+
nodeId?: Protocol.DOM.BackendNodeId,
12921298
},
12931299
};
12941300
}
@@ -1871,17 +1877,19 @@ export function isDecodeImage(event: Event): event is DecodeImage {
18711877
return event.name === Name.DECODE_IMAGE;
18721878
}
18731879

1880+
export const enum InvalidationEventType {
1881+
StyleInvalidatorInvalidationTracking = 'StyleInvalidatorInvalidationTracking',
1882+
StyleRecalcInvalidationTracking = 'StyleRecalcInvalidationTracking',
1883+
}
1884+
18741885
export interface SelectorTiming {
18751886
'elapsed (us)': number;
1876-
18771887
fast_reject_count: number;
1878-
18791888
match_attempts: number;
18801889
selector: string;
1881-
18821890
style_sheet_id: string;
1883-
18841891
match_count: number;
1892+
invalidation_count: number;
18851893
}
18861894

18871895
export enum SelectorTimingsKey {
@@ -1892,6 +1900,7 @@ export enum SelectorTimingsKey {
18921900
MatchCount = 'match_count',
18931901
Selector = 'selector',
18941902
StyleSheetId = 'style_sheet_id',
1903+
InvalidationCount = 'invalidation_count',
18951904
}
18961905

18971906
export interface SelectorStats {

front_end/panels/timeline/TimelineController.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export class TimelineController implements Tracing.TracingManager.TracingManager
131131
}
132132
if (options.captureSelectorStats) {
133133
categoriesArray.push(disabledByDefault('blink.debug'));
134+
// enable invalidation nodes
135+
categoriesArray.push(disabledByDefault('devtools.timeline.invalidationTracking'));
134136
}
135137

136138
await LiveMetrics.LiveMetrics.instance().disable();

0 commit comments

Comments
 (0)