// 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, duration, Time, time} from '../../base/time'; import {hasArgs, renderArguments} from '../../components/details/slice_args'; import {renderDetails} from '../../components/details/slice_details'; import { getDescendantSliceTree, getSlice, SliceDetails, SliceTreeNode, } from '../../components/sql_utils/slice'; import {asSliceSqlId, SliceSqlId} from '../../components/sql_utils/core_types'; import { ColumnDescriptor, Table, TableData, widgetColumn, } from '../../widgets/table'; import {TreeTable, TreeTableAttrs} from '../../components/widgets/treetable'; 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 {MultiParagraphText, TextParagraph} from '../../widgets/text_paragraph'; import {Tree, TreeNode} from '../../widgets/tree'; import { EventLatencyCauseThreadTracks, EventLatencyStage, getCauseLink, getEventLatencyCauseTracks, getScrollJankCauseStage, } from './scroll_jank_cause_link_utils'; import {ScrollJankCauseMap} from './scroll_jank_cause_map'; import {sliceRef} from '../../components/widgets/slice'; import {JANKS_TRACK_URI, renderSliceRef} from './utils'; import {TrackEventDetailsPanel} from '../../public/details_panel'; import {Trace} from '../../public/trace'; // Given a node in the slice tree, return a path from root to it. function getPath(slice: SliceTreeNode): string[] { const result: string[] = []; let node: SliceTreeNode | undefined = slice; while (node.parent !== undefined) { result.push(node.name); node = node.parent; } return result.reverse(); } // Given a slice tree node and a path, find the node following the path from // the given slice, or `undefined` if not found. function findSliceInTreeByPath( slice: SliceTreeNode | undefined, path: string[], ): SliceTreeNode | undefined { if (slice === undefined) { return undefined; } let result = slice; for (const segment of path) { let found = false; for (const child of result.children) { if (child.name === segment) { found = true; result = child; break; } } if (!found) { return undefined; } } return result; } function durationDelta(value: duration, base?: duration): string { if (base === undefined) { return 'NULL'; } const delta = value - base; return `${delta > 0 ? '+' : ''}${Duration.humanise(delta)}`; } export class EventLatencySliceDetailsPanel implements TrackEventDetailsPanel { private name = ''; private topEventLatencyId: SliceSqlId | undefined = undefined; private sliceDetails?: SliceDetails; private jankySlice?: { ts: time; dur: duration; id: number; causeOfJank: string; }; // Whether this stage has caused jank. This is also true for top level // EventLatency slices where a descendant is a cause of jank. private isJankStage = false; // For top level EventLatency slices - if any descendant is a cause of jank, // this field stores information about that descendant slice. Otherwise, this // is stores information about the current stage; private relevantThreadStage: EventLatencyStage | undefined; private relevantThreadTracks: EventLatencyCauseThreadTracks[] = []; // Stages tree for the current EventLatency. private eventLatencyBreakdown?: SliceTreeNode; // Stages tree for the next EventLatency. private nextEventLatencyBreakdown?: SliceTreeNode; // Stages tree for the prev EventLatency. private prevEventLatencyBreakdown?: SliceTreeNode; private tracksByTrackId: Map; constructor( private readonly trace: Trace, private readonly id: number, ) { this.tracksByTrackId = new Map(); this.trace.tracks.getAllTracks().forEach((td) => { td.tags?.trackIds?.forEach((trackId) => { this.tracksByTrackId.set(trackId, td.uri); }); }); } async load() { const queryResult = await this.trace.engine.query(` SELECT name FROM slice WHERE id = ${this.id} `); const iter = queryResult.firstRow({ name: STR, }); this.name = iter.name; await this.loadSlice(); await this.loadJankSlice(); await this.loadRelevantThreads(); await this.loadEventLatencyBreakdown(); } async loadSlice() { this.sliceDetails = await getSlice( this.trace.engine, asSliceSqlId(this.id), ); } async loadJankSlice() { if (!this.sliceDetails) return; // Get the id for the top-level EventLatency slice (this or parent), as // this id is used in the ScrollJankV3 track to identify the corresponding // janky interval. if (this.sliceDetails.name === 'EventLatency') { this.topEventLatencyId = this.sliceDetails.id; } else { this.topEventLatencyId = asSliceSqlId( await this.getOldestAncestorSliceId(), ); } const it = ( await this.trace.engine.query(` SELECT ts, dur, id, cause_of_jank as causeOfJank FROM chrome_janky_frame_presentation_intervals WHERE event_latency_id = ${this.topEventLatencyId}`) ).iter({ id: NUM, ts: LONG, dur: LONG, causeOfJank: STR, }); if (it.valid()) { this.jankySlice = { id: it.id, ts: Time.fromRaw(it.ts), dur: Duration.fromRaw(it.dur), causeOfJank: it.causeOfJank, }; } } async loadRelevantThreads() { if (!this.sliceDetails) return; if (!this.topEventLatencyId) return; // Relevant threads should only be available on a "Janky" EventLatency // slice to allow the user to jump to the possible cause of jank. if (this.sliceDetails.name === 'EventLatency' && !this.jankySlice) return; const possibleScrollJankStage = await getScrollJankCauseStage( this.trace.engine, this.topEventLatencyId, ); if (this.sliceDetails.name === 'EventLatency') { this.isJankStage = true; this.relevantThreadStage = possibleScrollJankStage; } else { if ( possibleScrollJankStage && this.sliceDetails.name === possibleScrollJankStage.name ) { this.isJankStage = true; } this.relevantThreadStage = { name: this.sliceDetails.name, eventLatencyId: this.topEventLatencyId, ts: this.sliceDetails.ts, dur: this.sliceDetails.dur, }; } if (this.relevantThreadStage) { this.relevantThreadTracks = await getEventLatencyCauseTracks( this.trace.engine, this.relevantThreadStage, ); } } async loadEventLatencyBreakdown() { if (this.topEventLatencyId === undefined) { return; } this.eventLatencyBreakdown = await getDescendantSliceTree( this.trace.engine, this.topEventLatencyId, ); // TODO(altimin): this should only consider EventLatencies within the same scroll. const prevEventLatency = ( await this.trace.engine.query(` INCLUDE PERFETTO MODULE chrome.event_latency; SELECT id FROM chrome_event_latencies WHERE event_type IN ( 'FIRST_GESTURE_SCROLL_UPDATE', 'GESTURE_SCROLL_UPDATE', 'INERTIAL_GESTURE_SCROLL_UPDATE') AND is_presented AND id < ${this.topEventLatencyId} ORDER BY id DESC LIMIT 1 ; `) ).maybeFirstRow({id: NUM}); if (prevEventLatency !== undefined) { this.prevEventLatencyBreakdown = await getDescendantSliceTree( this.trace.engine, asSliceSqlId(prevEventLatency.id), ); } const nextEventLatency = ( await this.trace.engine.query(` INCLUDE PERFETTO MODULE chrome.event_latency; SELECT id FROM chrome_event_latencies WHERE event_type IN ( 'FIRST_GESTURE_SCROLL_UPDATE', 'GESTURE_SCROLL_UPDATE', 'INERTIAL_GESTURE_SCROLL_UPDATE') AND is_presented AND id > ${this.topEventLatencyId} ORDER BY id DESC LIMIT 1; `) ).maybeFirstRow({id: NUM}); if (nextEventLatency !== undefined) { this.nextEventLatencyBreakdown = await getDescendantSliceTree( this.trace.engine, asSliceSqlId(nextEventLatency.id), ); } } private getRelevantLinks(): m.Child { if (!this.sliceDetails) return undefined; // Relevant threads should only be available on a "Janky" EventLatency // slice to allow the user to jump to the possible cause of jank. if ( this.sliceDetails.name === 'EventLatency' && !this.relevantThreadStage ) { return undefined; } const name = this.relevantThreadStage ? this.relevantThreadStage.name : this.sliceDetails.name; const ts = this.relevantThreadStage ? this.relevantThreadStage.ts : this.sliceDetails.ts; const dur = this.relevantThreadStage ? this.relevantThreadStage.dur : this.sliceDetails.dur; const stageDetails = ScrollJankCauseMap.getEventLatencyDetails(name); if (stageDetails === undefined) return undefined; const childWidgets: m.Child[] = []; childWidgets.push(m(TextParagraph, {text: stageDetails.description})); interface RelevantThreadRow { description: string; tracks: EventLatencyCauseThreadTracks; ts: time; dur: duration; } const columns: ColumnDescriptor[] = [ widgetColumn('Relevant Thread', (x) => getCauseLink(this.trace, x.tracks, this.tracksByTrackId, x.ts, x.dur), ), widgetColumn('Description', (x) => { if (x.description === '') { return x.description; } else { return m(TextParagraph, {text: x.description}); } }), ]; const trackLinks: RelevantThreadRow[] = []; for (let i = 0; i < this.relevantThreadTracks.length; i++) { const track = this.relevantThreadTracks[i]; let description = ''; if (i == 0 || track.thread != this.relevantThreadTracks[i - 1].thread) { description = track.causeDescription; } trackLinks.push({ description: description, tracks: this.relevantThreadTracks[i], ts: ts, dur: dur, }); } const tableData = new TableData(trackLinks); if (trackLinks.length > 0) { childWidgets.push( m(Table, { data: tableData, columns: columns, }), ); } return m( Section, {title: this.isJankStage ? `Jank Cause: ${name}` : name}, childWidgets, ); } private async getOldestAncestorSliceId(): Promise { let eventLatencyId = -1; if (!this.sliceDetails) return eventLatencyId; const queryResult = await this.trace.engine.query(` SELECT id FROM ancestor_slice(${this.sliceDetails.id}) WHERE name = 'EventLatency' `); const it = queryResult.iter({ id: NUM, }); for (; it.valid(); it.next()) { eventLatencyId = it.id; break; } return eventLatencyId; } private getLinksSection(): m.Child { return m( Section, {title: 'Quick links'}, m( Tree, m(TreeNode, { left: this.sliceDetails ? sliceRef( this.sliceDetails, 'EventLatency in context of other Input events', ) : 'EventLatency in context of other Input events', right: this.sliceDetails ? '' : 'N/A', }), this.jankySlice && m(TreeNode, { left: renderSliceRef({ trace: this.trace, id: this.jankySlice.id, trackUri: JANKS_TRACK_URI, title: this.jankySlice.causeOfJank, }), }), ), ); } private getBreakdownSection(): m.Child { if (this.eventLatencyBreakdown === undefined) { return undefined; } const attrs: TreeTableAttrs = { rows: [this.eventLatencyBreakdown], getChildren: (slice) => slice.children, columns: [ {name: 'Name', getData: (slice) => slice.name}, {name: 'Duration', getData: (slice) => Duration.humanise(slice.dur)}, { name: 'vs prev', getData: (slice) => durationDelta( slice.dur, findSliceInTreeByPath( this.prevEventLatencyBreakdown, getPath(slice), )?.dur, ), }, { name: 'vs next', getData: (slice) => durationDelta( slice.dur, findSliceInTreeByPath( this.nextEventLatencyBreakdown, getPath(slice), )?.dur, ), }, ], }; return m( Section, { title: 'EventLatency Stage Breakdown', }, m(TreeTable, attrs), ); } private getDescriptionText(): m.Child { return m( MultiParagraphText, m(TextParagraph, { text: `EventLatency tracks the latency of handling a given input event (Scrolls, Touches, Taps, etc). Ideally from when the input was read by the hardware to when it was reflected on the screen.`, }), m(TextParagraph, { text: `Note however the concept of coalescing or terminating early. This occurs when we receive multiple events or handle them quickly by converting them into a different event. Such as a TOUCH_MOVE being converted into a GESTURE_SCROLL_UPDATE type, or a multiple GESTURE_SCROLL_UPDATE events being formed into a single frame at the end of the RendererCompositorQueuingDelay.`, }), m(TextParagraph, { text: `*Important:* On some platforms (MacOS) we do not get feedback on when something is presented on the screen so the timings are only accurate for what we know on a given platform.`, }), ); } render() { if (this.sliceDetails) { const slice = this.sliceDetails; const rightSideWidgets: m.Child[] = []; rightSideWidgets.push( m( Section, {title: 'Description'}, m('.div', this.getDescriptionText()), ), ); const stageWidget = this.getRelevantLinks(); // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (stageWidget) { rightSideWidgets.push(stageWidget); } rightSideWidgets.push(this.getLinksSection()); rightSideWidgets.push(this.getBreakdownSection()); return m( DetailsShell, { title: 'Slice', description: this.name, }, m( GridLayout, m( GridLayoutColumn, renderDetails(this.trace, slice), hasArgs(slice.args) && m( Section, {title: 'Arguments'}, m(Tree, renderArguments(this.trace, slice.args)), ), ), m(GridLayoutColumn, rightSideWidgets), ), ); } else { return m(DetailsShell, {title: 'Slice', description: 'Loading...'}); } } }