xref: /aosp_15_r20/external/perfetto/ui/src/plugins/org.chromium.ChromeScrollJank/scroll_details_panel.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2023 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {duration, Time, time} from '../../base/time';
17import {exists} from '../../base/utils';
18import {
19  ColumnDescriptor,
20  Table,
21  TableData,
22  widgetColumn,
23} from '../../widgets/table';
24import {DurationWidget} from '../../components/widgets/duration';
25import {Timestamp} from '../../components/widgets/timestamp';
26import {
27  LONG,
28  LONG_NULL,
29  NUM,
30  NUM_NULL,
31  STR,
32} from '../../trace_processor/query_result';
33import {DetailsShell} from '../../widgets/details_shell';
34import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout';
35import {Section} from '../../widgets/section';
36import {SqlRef} from '../../widgets/sql_ref';
37import {MultiParagraphText, TextParagraph} from '../../widgets/text_paragraph';
38import {dictToTreeNodes, Tree} from '../../widgets/tree';
39import {
40  buildScrollOffsetsGraph,
41  getInputScrollDeltas,
42  getJankIntervals,
43  getPredictorJankDeltas,
44  getPresentedScrollDeltas,
45} from './scroll_delta_graph';
46import {JANKS_TRACK_URI, renderSliceRef} from './selection_utils';
47import {TrackEventDetailsPanel} from '../../public/details_panel';
48import {Trace} from '../../public/trace';
49
50interface Data {
51  // Scroll ID.
52  id: number;
53  // Timestamp of the beginning of this slice in nanoseconds.
54  ts: time;
55  // DurationWidget of this slice in nanoseconds.
56  dur: duration;
57}
58
59interface Metrics {
60  inputEventCount?: number;
61  frameCount?: number;
62  presentedFrameCount?: number;
63  jankyFrameCount?: number;
64  jankyFramePercent?: number;
65  missedVsyncs?: number;
66  startOffset?: number;
67  endOffset?: number;
68  totalPixelsScrolled?: number;
69}
70
71interface JankSliceDetails {
72  cause: string;
73  id: number;
74  ts: time;
75  dur?: duration;
76  delayVsync?: number;
77}
78
79export class ScrollDetailsPanel implements TrackEventDetailsPanel {
80  private data?: Data;
81  private metrics: Metrics = {};
82  private orderedJankSlices: JankSliceDetails[] = [];
83
84  // TODO(altimin): Don't store Mithril vnodes between render cycles.
85  private scrollDeltas: m.Child;
86
87  constructor(
88    private readonly trace: Trace,
89    private readonly id: number,
90  ) {}
91
92  async load() {
93    const queryResult = await this.trace.engine.query(`
94      WITH scrolls AS (
95        SELECT
96          id,
97          IFNULL(gesture_scroll_begin_ts, ts) AS start_ts,
98          CASE
99            WHEN gesture_scroll_end_ts IS NOT NULL THEN gesture_scroll_end_ts
100            WHEN gesture_scroll_begin_ts IS NOT NULL
101              THEN gesture_scroll_begin_ts + dur
102            ELSE ts + dur
103          END AS end_ts
104        FROM chrome_scrolls WHERE id = ${this.id})
105      SELECT
106        id,
107        start_ts AS ts,
108        end_ts - start_ts AS dur
109      FROM scrolls`);
110
111    const iter = queryResult.firstRow({
112      id: NUM,
113      ts: LONG,
114      dur: LONG,
115    });
116    this.data = {
117      id: iter.id,
118      ts: Time.fromRaw(iter.ts),
119      dur: iter.dur,
120    };
121
122    await this.loadMetrics();
123  }
124
125  private async loadMetrics() {
126    await this.loadInputEventCount();
127    await this.loadFrameStats();
128    await this.loadDelayData();
129    await this.loadScrollOffsets();
130  }
131
132  private async loadInputEventCount() {
133    if (exists(this.data)) {
134      const queryResult = await this.trace.engine.query(`
135        SELECT
136          COUNT(*) AS inputEventCount
137        FROM slice s
138        WHERE s.name = "EventLatency"
139          AND EXTRACT_ARG(arg_set_id, 'event_latency.event_type') = 'TOUCH_MOVED'
140          AND s.ts >= ${this.data.ts}
141          AND s.ts + s.dur <= ${this.data.ts + this.data.dur}
142      `);
143
144      const iter = queryResult.firstRow({
145        inputEventCount: NUM,
146      });
147
148      this.metrics.inputEventCount = iter.inputEventCount;
149    }
150  }
151
152  private async loadFrameStats() {
153    if (exists(this.data)) {
154      const queryResult = await this.trace.engine.query(`
155        SELECT
156          IFNULL(frame_count, 0) AS frameCount,
157          IFNULL(missed_vsyncs, 0) AS missedVsyncs,
158          IFNULL(presented_frame_count, 0) AS presentedFrameCount,
159          IFNULL(janky_frame_count, 0) AS jankyFrameCount,
160          ROUND(IFNULL(janky_frame_percent, 0), 2) AS jankyFramePercent
161        FROM chrome_scroll_stats
162        WHERE scroll_id = ${this.data.id}
163      `);
164      const iter = queryResult.iter({
165        frameCount: NUM,
166        missedVsyncs: NUM,
167        presentedFrameCount: NUM,
168        jankyFrameCount: NUM,
169        jankyFramePercent: NUM,
170      });
171
172      for (; iter.valid(); iter.next()) {
173        this.metrics.frameCount = iter.frameCount;
174        this.metrics.missedVsyncs = iter.missedVsyncs;
175        this.metrics.presentedFrameCount = iter.presentedFrameCount;
176        this.metrics.jankyFrameCount = iter.jankyFrameCount;
177        this.metrics.jankyFramePercent = iter.jankyFramePercent;
178        return;
179      }
180    }
181  }
182
183  private async loadDelayData() {
184    if (exists(this.data)) {
185      const queryResult = await this.trace.engine.query(`
186        SELECT
187          id,
188          ts,
189          dur,
190          IFNULL(sub_cause_of_jank, IFNULL(cause_of_jank, 'Unknown')) AS cause,
191          event_latency_id AS eventLatencyId,
192          delayed_frame_count AS delayVsync
193        FROM chrome_janky_frame_presentation_intervals s
194        WHERE s.ts >= ${this.data.ts}
195          AND s.ts + s.dur <= ${this.data.ts + this.data.dur}
196        ORDER by dur DESC;
197      `);
198
199      const it = queryResult.iter({
200        id: NUM,
201        ts: LONG,
202        dur: LONG_NULL,
203        cause: STR,
204        eventLatencyId: NUM_NULL,
205        delayVsync: NUM_NULL,
206      });
207
208      for (; it.valid(); it.next()) {
209        this.orderedJankSlices.push({
210          id: it.id,
211          ts: Time.fromRaw(it.ts),
212          dur: it.dur ?? undefined,
213          cause: it.cause,
214          delayVsync: it.delayVsync ?? undefined,
215        });
216      }
217    }
218  }
219
220  private async loadScrollOffsets() {
221    if (exists(this.data)) {
222      const inputDeltas = await getInputScrollDeltas(
223        this.trace.engine,
224        this.data.id,
225      );
226      const presentedDeltas = await getPresentedScrollDeltas(
227        this.trace.engine,
228        this.data.id,
229      );
230      const predictorDeltas = await getPredictorJankDeltas(
231        this.trace.engine,
232        this.data.id,
233      );
234      const jankIntervals = await getJankIntervals(
235        this.trace.engine,
236        this.data.ts,
237        this.data.dur,
238      );
239      this.scrollDeltas = buildScrollOffsetsGraph(
240        inputDeltas,
241        presentedDeltas,
242        predictorDeltas,
243        jankIntervals,
244      );
245
246      if (presentedDeltas.length > 0) {
247        this.metrics.startOffset = presentedDeltas[0].scrollOffset;
248        this.metrics.endOffset =
249          presentedDeltas[presentedDeltas.length - 1].scrollOffset;
250
251        let pixelsScrolled = 0;
252        for (let i = 0; i < presentedDeltas.length; i++) {
253          pixelsScrolled += Math.abs(presentedDeltas[i].scrollDelta);
254        }
255
256        if (pixelsScrolled != 0) {
257          this.metrics.totalPixelsScrolled = pixelsScrolled;
258        }
259      }
260    }
261  }
262
263  private renderMetricsDictionary(): m.Child[] {
264    const metrics: {[key: string]: m.Child} = {};
265    metrics['Total Finger Input Event Count'] = this.metrics.inputEventCount;
266    metrics['Total Vsyncs within Scrolling period'] = this.metrics.frameCount;
267    metrics['Total Chrome Presented Frames'] = this.metrics.presentedFrameCount;
268    metrics['Total Janky Frames'] = this.metrics.jankyFrameCount;
269    metrics['Number of Vsyncs Janky Frames were Delayed by'] =
270      this.metrics.missedVsyncs;
271
272    if (this.metrics.jankyFramePercent !== undefined) {
273      metrics[
274        'Janky Frame Percentage (Total Janky Frames / Total Chrome Presented Frames)'
275      ] = `${this.metrics.jankyFramePercent}%`;
276    }
277
278    if (this.metrics.startOffset != undefined) {
279      metrics['Starting Offset'] = this.metrics.startOffset;
280    }
281
282    if (this.metrics.endOffset != undefined) {
283      metrics['Ending Offset'] = this.metrics.endOffset;
284    }
285
286    if (
287      this.metrics.startOffset != undefined &&
288      this.metrics.endOffset != undefined
289    ) {
290      metrics['Net Pixels Scrolled'] = Math.abs(
291        this.metrics.endOffset - this.metrics.startOffset,
292      );
293    }
294
295    if (this.metrics.totalPixelsScrolled != undefined) {
296      metrics['Total Pixels Scrolled (all directions)'] =
297        this.metrics.totalPixelsScrolled;
298    }
299
300    return dictToTreeNodes(metrics);
301  }
302
303  private getDelayTable(): m.Child {
304    if (this.orderedJankSlices.length > 0) {
305      const columns: ColumnDescriptor<JankSliceDetails>[] = [
306        widgetColumn<JankSliceDetails>('Cause', (jankSlice) =>
307          renderSliceRef({
308            trace: this.trace,
309            id: jankSlice.id,
310            trackUri: JANKS_TRACK_URI,
311            title: jankSlice.cause,
312          }),
313        ),
314        widgetColumn<JankSliceDetails>('Duration', (jankSlice) =>
315          jankSlice.dur !== undefined
316            ? m(DurationWidget, {dur: jankSlice.dur})
317            : 'NULL',
318        ),
319        widgetColumn<JankSliceDetails>(
320          'Delayed Vsyncs',
321          (jankSlice) => jankSlice.delayVsync,
322        ),
323      ];
324
325      const tableData = new TableData(this.orderedJankSlices);
326
327      return m(Table, {
328        data: tableData,
329        columns: columns,
330      });
331    } else {
332      return 'None';
333    }
334  }
335
336  private getDescriptionText(): m.Child {
337    return m(
338      MultiParagraphText,
339      m(TextParagraph, {
340        text: `The interval during which the user has started a scroll ending
341                 after their finger leaves the screen and any resulting fling
342                 animations have finished.`,
343      }),
344      m(TextParagraph, {
345        text: `Note: This can contain periods of time where the finger is down
346                 and not moving and no active scrolling is occurring.`,
347      }),
348      m(TextParagraph, {
349        text: `Note: Sometimes if a user touches the screen quickly after
350                 letting go or Chrome was hung and got into a bad state. A new
351                 scroll will start which will result in a slightly overlapping
352                 scroll. This can occur due to the last scroll still outputting
353                 frames (to get caught up) and the "new" scroll having started
354                 producing frames after the user has started scrolling again.`,
355      }),
356    );
357  }
358
359  private getGraphText(): m.Child {
360    return m(
361      MultiParagraphText,
362      m(TextParagraph, {
363        text: `The scroll offset is the discrepancy in physical screen pixels
364                 between two consecutive frames.`,
365      }),
366      m(TextParagraph, {
367        text: `The overall curve of the graph indicates the direction (up or
368                 down) by which the user scrolled over time.`,
369      }),
370      m(TextParagraph, {
371        text: `Grey blocks in the graph represent intervals of jank
372                 corresponding with the Chrome Scroll Janks track.`,
373      }),
374      m(TextParagraph, {
375        text: `Yellow dots represent frames that were presented (sae as the red
376                 dots), but that we suspect are visible to users as unsmooth
377                 velocity/stutter (predictor jank).`,
378      }),
379    );
380  }
381
382  render() {
383    if (this.data == undefined) {
384      return m('h2', 'Loading');
385    }
386
387    const details = dictToTreeNodes({
388      'Scroll ID': this.data.id,
389      'Start time': m(Timestamp, {ts: this.data.ts}),
390      'Duration': m(DurationWidget, {dur: this.data.dur}),
391      'SQL ID': m(SqlRef, {table: 'chrome_scrolls', id: this.id}),
392    });
393
394    return m(
395      DetailsShell,
396      {
397        title: 'Scroll',
398      },
399      m(
400        GridLayout,
401        m(
402          GridLayoutColumn,
403          m(Section, {title: 'Details'}, m(Tree, details)),
404          m(
405            Section,
406            {title: 'Slice Metrics'},
407            m(Tree, this.renderMetricsDictionary()),
408          ),
409          m(
410            Section,
411            {title: 'Frame Presentation Delays'},
412            this.getDelayTable(),
413          ),
414        ),
415        m(
416          GridLayoutColumn,
417          m(Section, {title: 'Description'}, this.getDescriptionText()),
418          m(
419            Section,
420            {title: 'Scroll Offsets Plot'},
421            m(".div[style='padding-bottom:5px']", this.getGraphText()),
422            this.scrollDeltas,
423          ),
424        ),
425      ),
426    );
427  }
428}
429