Skip to content

Commit

Permalink
core: compute TBT impact for main thread tasks (#15175)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamraine authored Jul 5, 2023
1 parent 540f55e commit 9e2f70e
Show file tree
Hide file tree
Showing 7 changed files with 600 additions and 30 deletions.
76 changes: 48 additions & 28 deletions core/computed/metrics/tbt-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,52 @@

const BLOCKING_TIME_THRESHOLD = 50;

/**
* For TBT, We only want to consider tasks that fall in our time range
* - FCP and TTI for navigation mode
* - Trace start and trace end for timespan mode
*
* FCP is picked as `startTimeMs` because there is little risk of user input happening
* before FCP so Long Queuing Qelay regions do not harm user experience. Developers should be
* optimizing to reach FCP as fast as possible without having to worry about task lengths.
*
* TTI is picked as `endTimeMs` because we want a well defined end point for page load.
*
* @param {{start: number, end: number, duration: number}} event
* @param {number} startTimeMs Should be FCP in navigation mode and the trace start time in timespan mode
* @param {number} endTimeMs Should be TTI in navigation mode and the trace end time in timespan mode
* @param {{start: number, end: number, duration: number}} [topLevelEvent] Leave unset if `event` is top level. Has no effect if `event` has the same duration as `topLevelEvent`.
* @return {number}
*/
function calculateTbtImpactForEvent(event, startTimeMs, endTimeMs, topLevelEvent) {
let threshold = BLOCKING_TIME_THRESHOLD;

// If a task is not top level, it doesn't make sense to subtract the entire 50ms
// blocking threshold from the event.
//
// e.g. A 80ms top level task with two 40ms children should attribute some blocking
// time to the 40ms tasks even though they do not meet the 50ms threshold.
//
// The solution is to scale the threshold for child events to be considered blocking.
if (topLevelEvent) threshold *= (event.duration / topLevelEvent.duration);

if (event.duration < threshold) return 0;
if (event.end < startTimeMs) return 0;
if (event.start > endTimeMs) return 0;

// Perform the clipping and then calculate Blocking Region. So if we have a 150ms task
// [0, 150] and `startTimeMs` is at 50ms, we first clip the task to [50, 150], and then
// calculate the Blocking Region to be [100, 150]. The rational here is that tasks before
// the start time are unimportant, so we care whether the main thread is busy more than
// 50ms at a time only after the start time.
const clippedStart = Math.max(event.start, startTimeMs);
const clippedEnd = Math.min(event.end, endTimeMs);
const clippedDuration = clippedEnd - clippedStart;
if (clippedDuration < threshold) return 0;

return clippedDuration - threshold;
}

/**
* @param {Array<{start: number, end: number, duration: number}>} topLevelEvents
* @param {number} startTimeMs
Expand All @@ -15,36 +61,9 @@ const BLOCKING_TIME_THRESHOLD = 50;
function calculateSumOfBlockingTime(topLevelEvents, startTimeMs, endTimeMs) {
if (endTimeMs <= startTimeMs) return 0;

const threshold = BLOCKING_TIME_THRESHOLD;
let sumBlockingTime = 0;
for (const event of topLevelEvents) {
// Early exit for small tasks, which should far outnumber long tasks.
if (event.duration < threshold) continue;

// We only want to consider tasks that fall in our time range (FCP and TTI for navigations).
// FCP is picked as the lower bound because there is little risk of user input happening
// before FCP so Long Queuing Qelay regions do not harm user experience. Developers should be
// optimizing to reach FCP as fast as possible without having to worry about task lengths.
if (event.end < startTimeMs) continue;

// TTI is picked as the upper bound because we want a well defined end point for page load.
if (event.start > endTimeMs) continue;

// We first perform the clipping, and then calculate Blocking Region. So if we have a 150ms
// task [0, 150] and FCP happens midway at 50ms, we first clip the task to [50, 150], and then
// calculate the Blocking Region to be [100, 150]. The rational here is that tasks before FCP
// are unimportant, so we care whether the main thread is busy more than 50ms at a time only
// after FCP.
const clippedStart = Math.max(event.start, startTimeMs);
const clippedEnd = Math.min(event.end, endTimeMs);
const clippedDuration = clippedEnd - clippedStart;
if (clippedDuration < threshold) continue;

// The duration of the task beyond 50ms at the beginning is considered the Blocking Region.
// Example:
// [ 250ms Task ]
// | First 50ms | Blocking Region (200ms) |
sumBlockingTime += clippedDuration - threshold;
sumBlockingTime += calculateTbtImpactForEvent(event, startTimeMs, endTimeMs);
}

return sumBlockingTime;
Expand All @@ -53,4 +72,5 @@ function calculateSumOfBlockingTime(topLevelEvents, startTimeMs, endTimeMs) {
export {
BLOCKING_TIME_THRESHOLD,
calculateSumOfBlockingTime,
calculateTbtImpactForEvent,
};
2 changes: 1 addition & 1 deletion core/computed/metrics/total-blocking-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class TotalBlockingTime extends ComputedMetric {
timing: calculateSumOfBlockingTime(
events,
0,
data.processedTrace.timestamps.traceEnd
data.processedTrace.timings.traceEnd
),
};
}
Expand Down
221 changes: 221 additions & 0 deletions core/computed/tbt-impact-tasks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/**
* @license Copyright 2023 The Lighthouse Authors. All Rights Reserved.
* 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 {makeComputedArtifact} from './computed-artifact.js';
import {MainThreadTasks} from './main-thread-tasks.js';
import {FirstContentfulPaint} from './metrics/first-contentful-paint.js';
import {Interactive} from './metrics/interactive.js';
import {TotalBlockingTime} from './metrics/total-blocking-time.js';
import {ProcessedTrace} from './processed-trace.js';
import {calculateTbtImpactForEvent} from './metrics/tbt-utils.js';

/** @typedef {LH.Artifacts.TaskNode & {tbtImpact: number, selfTbtImpact: number}} TBTImpactTask */

class TBTImpactTasks {
/**
* @param {LH.Artifacts.TaskNode} task
* @return {LH.Artifacts.TaskNode}
*/
static getTopLevelTask(task) {
let topLevelTask = task;
while (topLevelTask.parent) {
topLevelTask = topLevelTask.parent;
}
return topLevelTask;
}

/**
* @param {LH.Artifacts.MetricComputationDataInput} metricComputationData
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<{startTimeMs: number, endTimeMs: number}>}
*/
static async getTbtBounds(metricComputationData, context) {
const processedTrace = await ProcessedTrace.request(metricComputationData.trace, context);
if (metricComputationData.gatherContext.gatherMode !== 'navigation') {
return {
startTimeMs: 0,
endTimeMs: processedTrace.timings.traceEnd,
};
}

const fcpResult = await FirstContentfulPaint.request(metricComputationData, context);
const ttiResult = await Interactive.request(metricComputationData, context);

let startTimeMs = fcpResult.timing;
let endTimeMs = ttiResult.timing;

// When using lantern, we want to get a pessimistic view of the long tasks.
// This means we assume the earliest possible start time and latest possible end time.

if ('optimisticEstimate' in fcpResult) {
startTimeMs = fcpResult.optimisticEstimate.timeInMs;
}

if ('pessimisticEstimate' in ttiResult) {
endTimeMs = ttiResult.pessimisticEstimate.timeInMs;
}

return {startTimeMs, endTimeMs};
}

/**
* @param {LH.Artifacts.TaskNode[]} tasks
* @param {Map<LH.Artifacts.TaskNode, number>} taskToImpact
*/
static createImpactTasks(tasks, taskToImpact) {
/** @type {TBTImpactTask[]} */
const tbtImpactTasks = [];

for (const task of tasks) {
const tbtImpact = taskToImpact.get(task) || 0;
let selfTbtImpact = tbtImpact;

for (const child of task.children) {
const childTbtImpact = taskToImpact.get(child) || 0;
selfTbtImpact -= childTbtImpact;
}

tbtImpactTasks.push({
...task,
tbtImpact,
selfTbtImpact,
});
}

return tbtImpactTasks;
}

/**
* @param {LH.Artifacts.TaskNode[]} tasks
* @param {number} startTimeMs
* @param {number} endTimeMs
* @return {TBTImpactTask[]}
*/
static computeImpactsFromObservedTasks(tasks, startTimeMs, endTimeMs) {
/** @type {Map<LH.Artifacts.TaskNode, number>} */
const taskToImpact = new Map();

for (const task of tasks) {
const event = {
start: task.startTime,
end: task.endTime,
duration: task.duration,
};

const topLevelTask = this.getTopLevelTask(task);
const topLevelEvent = {
start: topLevelTask.startTime,
end: topLevelTask.endTime,
duration: topLevelTask.duration,
};

const tbtImpact = calculateTbtImpactForEvent(event, startTimeMs, endTimeMs, topLevelEvent);

taskToImpact.set(task, tbtImpact);
}

return this.createImpactTasks(tasks, taskToImpact);
}

/**
* @param {LH.Artifacts.TaskNode[]} tasks
* @param {LH.Gatherer.Simulation.Result['nodeTimings']} tbtNodeTimings
* @param {number} startTimeMs
* @param {number} endTimeMs
* @return {TBTImpactTask[]}
*/
static computeImpactsFromLantern(tasks, tbtNodeTimings, startTimeMs, endTimeMs) {
/** @type {Map<LH.Artifacts.TaskNode, number>} */
const taskToImpact = new Map();

/** @type {Map<LH.Artifacts.TaskNode, {start: number, end: number, duration: number}>} */
const topLevelTaskToEvent = new Map();

/** @type {Map<LH.TraceEvent, LH.Artifacts.TaskNode>} */
const traceEventToTask = new Map();
for (const task of tasks) {
traceEventToTask.set(task.event, task);
}

// Use lantern TBT timings to calculate the TBT impact of top level tasks.
for (const [node, timing] of tbtNodeTimings) {
if (node.type !== 'cpu') continue;

const event = {
start: timing.startTime,
end: timing.endTime,
duration: timing.duration,
};

const tbtImpact = calculateTbtImpactForEvent(event, startTimeMs, endTimeMs);

const task = traceEventToTask.get(node.event);
if (!task) continue;

topLevelTaskToEvent.set(task, event);
taskToImpact.set(task, tbtImpact);
}

// Interpolate the TBT impact of remaining tasks using the top level ancestor tasks.
// We don't have any lantern estimates for tasks that are not top level, so we need to estimate
// the lantern timing based on the task's observed timing relative to it's top level task's observed timing.
for (const task of tasks) {
if (taskToImpact.has(task)) continue;

const topLevelTask = this.getTopLevelTask(task);

const topLevelEvent = topLevelTaskToEvent.get(topLevelTask);
if (!topLevelEvent) continue;

const startRatio = (task.startTime - topLevelTask.startTime) / topLevelTask.duration;
const start = startRatio * topLevelEvent.duration + topLevelEvent.start;

const endRatio = (topLevelTask.endTime - task.endTime) / topLevelTask.duration;
const end = topLevelEvent.end - endRatio * topLevelEvent.duration;

const event = {
start,
end,
duration: end - start,
};

const tbtImpact = calculateTbtImpactForEvent(event, startTimeMs, endTimeMs, topLevelEvent);

taskToImpact.set(task, tbtImpact);
}

return this.createImpactTasks(tasks, taskToImpact);
}

/**
* @param {LH.Artifacts.MetricComputationDataInput} metricComputationData
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<TBTImpactTask[]>}
*/
static async compute_(metricComputationData, context) {
const tbtResult = await TotalBlockingTime.request(metricComputationData, context);
const tasks = await MainThreadTasks.request(metricComputationData.trace, context);

const {startTimeMs, endTimeMs} = await this.getTbtBounds(metricComputationData, context);

if ('pessimisticEstimate' in tbtResult) {
return this.computeImpactsFromLantern(
tasks,
tbtResult.pessimisticEstimate.nodeTimings,
startTimeMs,
endTimeMs
);
}

return this.computeImpactsFromObservedTasks(tasks, startTimeMs, endTimeMs);
}
}

const TBTImpactTasksComputed = makeComputedArtifact(
TBTImpactTasks,
['trace', 'devtoolsLog', 'URL', 'gatherContext', 'settings', 'simulator']
);
export {TBTImpactTasksComputed as TBTImpactTasks};
Loading

0 comments on commit 9e2f70e

Please sign in to comment.