blob: 63e913f675a07acdc3017425caa4979d7b051bda [file] [log] [blame]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {duration, Time, time} from '../../base/time';
import {exists} from '../../base/utils';
import {raf} from '../../core/raf_scheduler';
import {
BottomTab,
bottomTabRegistry,
NewBottomTabArgs,
} from '../../frontend/bottom_tab';
import {
GenericSliceDetailsTabConfig,
} from '../../frontend/generic_slice_details_tab';
import {sqlValueToString} from '../../frontend/sql_utils';
import {
ColumnDescriptor,
numberColumn,
Table,
TableData,
} from '../../frontend/tables/table';
import {DurationWidget} from '../../frontend/widgets/duration';
import {Timestamp} from '../../frontend/widgets/timestamp';
import {LONG, NUM, STR} from '../../trace_processor/query_result';
import {DetailsShell} from '../../widgets/details_shell';
import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout';
import {Section} from '../../widgets/section';
import {SqlRef} from '../../widgets/sql_ref';
import {MultiParagraphText, TextParagraph} from '../../widgets/text_paragraph';
import {dictToTreeNodes, Tree} from '../../widgets/tree';
import {
buildScrollOffsetsGraph,
getAppliedScrollDeltas,
getJankIntervals,
getUserScrollDeltas,
} from './scroll_delta_graph';
import {
getScrollJankSlices,
getSliceForTrack,
ScrollJankSlice,
} from './scroll_jank_slice';
import {ScrollJankV3Track} from './scroll_jank_v3_track';
function widgetColumn<T>(
name: string, getter: (t: T) => m.Child): ColumnDescriptor<T> {
return new ColumnDescriptor<T>(name, getter);
}
interface Data {
// Scroll ID.
id: number;
// Timestamp of the beginning of this slice in nanoseconds.
ts: time;
// DurationWidget of this slice in nanoseconds.
dur: duration;
}
interface Metrics {
inputEventCount?: number;
frameCount?: number;
presentedFrameCount?: number;
jankyFrameCount?: number;
jankyFramePercent?: number;
missedVsyncs?: number;
startOffset?: number;
endOffset?: number;
totalPixelsScrolled?: number;
}
interface JankSliceDetails {
cause: string;
jankSlice: ScrollJankSlice;
delayDur: duration;
delayVsync: number;
}
export class ScrollDetailsPanel extends
BottomTab<GenericSliceDetailsTabConfig> {
static readonly kind = 'org.perfetto.ScrollDetailsPanel';
loaded = false;
data: Data|undefined;
metrics: Metrics = {};
orderedJankSlices: JankSliceDetails[] = [];
scrollDeltas: m.Child;
static create(args: NewBottomTabArgs): ScrollDetailsPanel {
return new ScrollDetailsPanel(args);
}
constructor(args: NewBottomTabArgs) {
super(args);
this.loadData();
}
private async loadData() {
const queryResult = await this.engine.query(`
WITH scrolls AS (
SELECT
id,
IFNULL(gesture_scroll_begin_ts, ts) AS start_ts,
CASE
WHEN gesture_scroll_end_ts IS NOT NULL THEN gesture_scroll_end_ts
WHEN gesture_scroll_begin_ts IS NOT NULL
THEN gesture_scroll_begin_ts + dur
ELSE ts + dur
END AS end_ts
FROM chrome_scrolls WHERE id = ${this.config.id})
SELECT
id,
start_ts AS ts,
end_ts - start_ts AS dur
FROM scrolls`);
const iter = queryResult.firstRow({
id: NUM,
ts: LONG,
dur: LONG,
});
this.data = {
id: iter.id,
ts: Time.fromRaw(iter.ts),
dur: iter.dur,
};
await this.loadMetrics();
this.loaded = true;
raf.scheduleFullRedraw();
}
private async loadMetrics() {
await this.loadInputEventCount();
await this.loadFrameStats();
await this.loadDelayData();
await this.loadScrollOffsets();
}
private async loadInputEventCount() {
if (exists(this.data)) {
const queryResult = await this.engine.query(`
SELECT
COUNT(*) AS inputEventCount
FROM slice s
WHERE s.name = "EventLatency"
AND EXTRACT_ARG(arg_set_id, 'event_latency.event_type') = 'TOUCH_MOVED'
AND s.ts >= ${this.data.ts}
AND s.ts + s.dur <= ${this.data.ts + this.data.dur}
`);
const iter = queryResult.firstRow({
inputEventCount: NUM,
});
this.metrics.inputEventCount = iter.inputEventCount;
}
}
private async loadFrameStats() {
if (exists(this.data)) {
const queryResult = await this.engine.query(`
SELECT
IFNULL(frame_count, 0) AS frameCount,
IFNULL(missed_vsyncs, 0) AS missedVsyncs,
IFNULL(presented_frame_count, 0) AS presentedFrameCount,
IFNULL(janky_frame_count, 0) AS jankyFrameCount,
ROUND(IFNULL(janky_frame_percent, 0), 2) AS jankyFramePercent
FROM chrome_scroll_stats
WHERE scroll_id = ${this.data.id}
`);
const iter = queryResult.iter({
frameCount: NUM,
missedVsyncs: NUM,
presentedFrameCount: NUM,
jankyFrameCount: NUM,
jankyFramePercent: NUM,
});
for (; iter.valid(); iter.next()) {
this.metrics.frameCount = iter.frameCount;
this.metrics.missedVsyncs = iter.missedVsyncs;
this.metrics.presentedFrameCount = iter.presentedFrameCount;
this.metrics.jankyFrameCount = iter.jankyFrameCount;
this.metrics.jankyFramePercent = iter.jankyFramePercent;
return;
}
}
}
private async loadDelayData() {
if (exists(this.data)) {
const queryResult = await this.engine.query(`
SELECT
IFNULL(sub_cause_of_jank, IFNULL(cause_of_jank, 'Unknown')) AS cause,
IFNULL(event_latency_id, 0) AS eventLatencyId,
IFNULL(dur, 0) AS delayDur,
IFNULL(delayed_frame_count, 0) AS delayVsync
FROM chrome_janky_frame_presentation_intervals s
WHERE s.ts >= ${this.data.ts}
AND s.ts + s.dur <= ${this.data.ts + this.data.dur}
ORDER by delayDur DESC;
`);
const iter = queryResult.iter({
cause: STR,
eventLatencyId: NUM,
delayDur: LONG,
delayVsync: NUM,
});
for (; iter.valid(); iter.next()) {
if (iter.delayDur <= 0) {
break;
}
const jankSlices =
await getScrollJankSlices(this.engine, iter.eventLatencyId);
this.orderedJankSlices.push({
cause: iter.cause,
jankSlice: jankSlices[0],
delayDur: iter.delayDur,
delayVsync: iter.delayVsync,
});
}
}
}
private async loadScrollOffsets() {
if (exists(this.data)) {
const userDeltas =
await getUserScrollDeltas(this.engine, this.data.ts, this.data.dur);
const appliedDeltas = await getAppliedScrollDeltas(
this.engine, this.data.ts, this.data.dur);
const jankIntervals =
await getJankIntervals(this.engine, this.data.ts, this.data.dur);
this.scrollDeltas =
buildScrollOffsetsGraph(userDeltas, appliedDeltas, jankIntervals);
if (appliedDeltas.length > 0) {
this.metrics.startOffset = appliedDeltas[0].scrollOffset;
this.metrics.endOffset =
appliedDeltas[appliedDeltas.length - 1].scrollOffset;
let pixelsScrolled = 0;
for (let i = 0; i < appliedDeltas.length; i++) {
pixelsScrolled += Math.abs(appliedDeltas[i].scrollDelta);
}
if (pixelsScrolled != 0) {
this.metrics.totalPixelsScrolled = pixelsScrolled;
}
}
}
}
private renderMetricsDictionary(): m.Child[] {
const metrics: {[key: string]: m.Child} = {};
metrics['Total Finger Input Event Count'] = this.metrics.inputEventCount;
metrics['Total Vsyncs within Scrolling period'] = this.metrics.frameCount;
metrics['Total Chrome Presented Frames'] = this.metrics.presentedFrameCount;
metrics['Total Janky Frames'] = this.metrics.jankyFrameCount;
metrics['Number of Vsyncs Janky Frames were Delayed by'] =
this.metrics.missedVsyncs;
if (this.metrics.jankyFramePercent !== undefined) {
metrics['Janky Frame Percentage (Total Janky Frames / Total Chrome Presented Frames)'] =
`${this.metrics.jankyFramePercent}%`;
}
if (this.metrics.startOffset != undefined) {
metrics['Starting Offset'] = this.metrics.startOffset;
}
if (this.metrics.endOffset != undefined) {
metrics['Ending Offset'] = this.metrics.endOffset;
}
if (this.metrics.startOffset != undefined &&
this.metrics.endOffset != undefined) {
metrics['Net Pixels Scrolled'] =
Math.abs(this.metrics.endOffset - this.metrics.startOffset);
}
if (this.metrics.totalPixelsScrolled != undefined) {
metrics['Total Pixels Scrolled (all directions)'] =
this.metrics.totalPixelsScrolled;
}
return dictToTreeNodes(metrics);
}
private getDelayTable(): m.Child {
if (this.orderedJankSlices.length > 0) {
interface DelayData {
jankLink: m.Child;
dur: m.Child;
delayedVSyncs: number;
}
const columns: ColumnDescriptor<DelayData>[] = [
widgetColumn<DelayData>('Cause', (x) => x.jankLink),
widgetColumn<DelayData>('Duration', (x) => x.dur),
numberColumn<DelayData>('Delayed Vsyncs', (x) => x.delayedVSyncs),
];
const data: DelayData[] = [];
for (const jankSlice of this.orderedJankSlices) {
data.push({
jankLink: getSliceForTrack(
jankSlice.jankSlice, ScrollJankV3Track.kind, jankSlice.cause),
dur: m(DurationWidget, {dur: jankSlice.delayDur}),
delayedVSyncs: jankSlice.delayVsync,
});
}
const tableData = new TableData(data);
return m(Table, {
data: tableData,
columns: columns,
});
} else {
return sqlValueToString('None');
}
}
private getDescriptionText(): m.Child {
return m(
MultiParagraphText,
m(TextParagraph, {
text: `The interval during which the user has started a scroll ending
after their finger leaves the screen and any resulting fling
animations have finished.`,
}),
m(TextParagraph, {
text: `Note: This can contain periods of time where the finger is down
and not moving and no active scrolling is occurring.`,
}),
m(TextParagraph, {
text: `Note: Sometimes if a user touches the screen quickly after
letting go or Chrome was hung and got into a bad state. A new
scroll will start which will result in a slightly overlapping
scroll. This can occur due to the last scroll still outputting
frames (to get caught up) and the "new" scroll having started
producing frames after the user has started scrolling again.`,
}),
);
}
private getGraphText(): m.Child {
return m(
MultiParagraphText,
m(TextParagraph, {
text: `The scroll offset is the discrepancy in physical screen pixels
between two consecutive frames.`,
}),
m(TextParagraph, {
text: `The overall curve of the graph indicates the direction (up or
down) by which the user scrolled over time.`,
}),
m(TextParagraph, {
text: `Grey blocks in the graph represent intervals of jank
corresponding with the Chrome Scroll Janks track.`,
}),
);
}
viewTab() {
if (this.isLoading() || this.data == undefined) {
return m('h2', 'Loading');
}
const details = dictToTreeNodes({
'Scroll ID': sqlValueToString(this.data.id),
'Start time': m(Timestamp, {ts: this.data.ts}),
'Duration': m(DurationWidget, {dur: this.data.dur}),
'SQL ID': m(SqlRef, {table: 'chrome_scrolls', id: this.config.id}),
});
return m(
DetailsShell,
{
title: this.getTitle(),
},
m(GridLayout,
m(GridLayoutColumn,
m(
Section,
{title: 'Details'},
m(Tree, details),
),
m(Section,
{title: 'Slice Metrics'},
m(Tree, this.renderMetricsDictionary())),
m(
Section,
{title: 'Frame Presentation Delays'},
this.getDelayTable(),
)),
m(
GridLayoutColumn,
m(
Section,
{title: 'Description'},
this.getDescriptionText(),
),
m(
Section,
{title: 'Scroll Offsets Plot'},
m('.div[style=\'padding-bottom:5px\']', this.getGraphText()),
this.scrollDeltas,
),
)),
);
}
getTitle(): string {
return this.config.title;
}
isLoading() {
return !this.loaded;
}
}
bottomTabRegistry.register(ScrollDetailsPanel);