-
Notifications
You must be signed in to change notification settings - Fork 9.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
core(tracehouse): structure main thread task creation to be more forgiving #9491
Changes from 8 commits
900093c
3d24a75
a06ff76
08f7d0c
b8e0e81
6daf898
349a13d
863c478
4bad8cc
fba1df7
2c6abbf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,104 +44,285 @@ const {taskGroups, taskNameToGroup} = require('./task-groups.js'); | |
class MainThreadTasks { | ||
/** | ||
* @param {LH.TraceEvent} event | ||
* @param {TaskNode} [parent] | ||
* @param {Pick<LH.TraceEvent, 'ph'|'ts'>} [endEvent] | ||
* @return {TaskNode} | ||
*/ | ||
static _createNewTaskNode(event, parent) { | ||
static _createNewTaskNode(event, endEvent) { | ||
const isCompleteEvent = event.ph === 'X' && !endEvent; | ||
const isStartEndEventPair = event.ph === 'B' && endEvent && endEvent.ph === 'E'; | ||
if (!isCompleteEvent && !isStartEndEventPair) { | ||
throw new Error('Invalid parameters for _createNewTaskNode'); | ||
} | ||
|
||
const startTime = event.ts; | ||
const endTime = endEvent ? endEvent.ts : event.ts + Number(event.dur || 0); | ||
|
||
const newTask = { | ||
event, | ||
startTime: event.ts, | ||
endTime: event.ph === 'X' ? event.ts + Number(event.dur || 0) : NaN, | ||
parent: parent, | ||
children: [], | ||
startTime, | ||
endTime, | ||
duration: endTime - startTime, | ||
|
||
// These properties will be filled in later | ||
parent: undefined, | ||
children: [], | ||
attributableURLs: [], | ||
group: taskGroups.other, | ||
duration: NaN, | ||
selfTime: NaN, | ||
}; | ||
|
||
if (parent) { | ||
parent.children.push(newTask); | ||
} | ||
|
||
return newTask; | ||
} | ||
|
||
/** | ||
* @param {LH.TraceEvent[]} mainThreadEvents | ||
* | ||
* @param {TaskNode} currentTask | ||
* @param {{startTime: number}} nextTask | ||
* @param {PriorTaskData} priorTaskData | ||
* @param {Array<LH.TraceEvent>} reverseEventsQueue | ||
*/ | ||
static _assignAllTimerInstallsBetweenTasks( | ||
currentTask, | ||
brendankenny marked this conversation as resolved.
Show resolved
Hide resolved
|
||
nextTask, | ||
priorTaskData, | ||
reverseEventsQueue | ||
) { | ||
while (reverseEventsQueue.length) { | ||
const nextTimerInstallEvent = reverseEventsQueue.pop(); | ||
// We're out of events to look at; we're done. | ||
if (!nextTimerInstallEvent) break; | ||
|
||
// Timer event is after our current task; push it back on for next time, and we're done. | ||
if (nextTimerInstallEvent.ts > Math.min(nextTask.startTime, currentTask.endTime)) { | ||
reverseEventsQueue.push(nextTimerInstallEvent); | ||
break; | ||
} | ||
|
||
// Timer event is before the current task, just skip it. | ||
if (nextTimerInstallEvent.ts < currentTask.startTime) { | ||
continue; | ||
} | ||
|
||
// We're right where we need to be, point the timerId to our `currentTask` | ||
/** @type {string} */ | ||
// @ts-ignore - timerId exists on `TimerInstall` events. | ||
const timerId = nextTimerInstallEvent.args.data.timerId; | ||
priorTaskData.timers.set(timerId, currentTask); | ||
} | ||
} | ||
|
||
/** | ||
* This function takes the start and end events from a thread and creates tasks from them. | ||
* We do this by iterating through the start and end event arrays simultaneously. For each start | ||
* event we attempt to find its end event. | ||
* | ||
* Because of this matching of start/end events and the need to be mutating our end events queue, | ||
* we reverse the array to more efficiently `.pop()` them off rather than `.shift()` them off. | ||
* While it's true the worst case runtime here is O(n^2), ~99.999% of the time the reverse loop is O(1) | ||
* because the overwhelmingly common case is that end event for a given start event is simply the very next event in our queue. | ||
* | ||
* @param {LH.TraceEvent[]} taskStartEvents | ||
* @param {LH.TraceEvent[]} taskEndEvents | ||
* @param {number} traceEndTs | ||
* @return {TaskNode[]} | ||
*/ | ||
static _createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) { | ||
static _createTasksFromStartAndEndEvents(taskStartEvents, taskEndEvents, traceEndTs) { | ||
/** @type {TaskNode[]} */ | ||
const tasks = []; | ||
/** @type {TaskNode|undefined} */ | ||
let currentTask; | ||
// Create a reversed copy of the array to avoid copying the rest of the queue on every mutation. | ||
// i.e. pop() is O(1) while shift() is O(n), we take the earliest ts element off the queue *a lot* | ||
// so we'll optimize for having the earliest timestamp events at the end of the array. | ||
const taskEndEventsReverseQueue = taskEndEvents.slice().reverse(); | ||
|
||
for (let i = 0; i < taskStartEvents.length; i++) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are a lot of the nested loops where one side searches left to right, and then looks up right to left. Is there any way to iterate and build a map and lookup the seen that way? go from O(x^2) to O(x)? This might be overcomplicated for some code that is already complex. ^ That might not make 100% given this code is searching 2 separate lists and it is trying to handle nesting, but this definitely has a suspicious smell to the code. Might just need better comments. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i was curious about the perf here too. on a theverge trace (42MB), meanwhile, recordsFromLogs takes 1380ms. so i think right now this is still pretty cheap and not worth optimizing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Like network recordsFromLogs?? Even theverge will have max 100s of requests. What's taking 1.3s? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. while it's true the worst case runtime here is O(n^2), ~99.999% of the time the reverse loop is O(1) because the common case is that B/E events are in the correct order (think of every time we've run LH today without it throwing a fatal error, that is all of the cases that this reverse loop was O(1) on every event). The only reason we're doing this at all is to be able to handle the rare and unexpected case where Chrome put the events in an order that doesn't make sense. When that happens we always have to look at every event worst case so there's no way to escape O(n^2). Also, what paul said, it's fine right now so seems premature to worry about taking up extra memory with a map. Seems like a good idea to put all this into a comment somewhere :) EDIT: I forgot about nested events when originally writing this but because we don't have stack sampling enabled the max depth should still be ~5, so still O(1) :D example: the average iterations of the inner loop on theverge was 1.1387605983318398 |
||
const taskStartEvent = taskStartEvents[i]; | ||
if (taskStartEvent.ph === 'X') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it might be clearer to handle this separately altogether instead of combining with the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the solution to this isn't clear to me, so I might need some help :) what's clear to me now is that every start task case is handled here which is comforting compared to making sure I have my bases covered by combining the results of several methods if that's what the suggestion is? IMO, it's maybe a wash considering the mental overhead of this is only 3 lines? might be missing what the separately altogether looks like though 🤷♂ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, it wouldn't be much different, just something like The main thing for me wasn't having this code here (though it would keep it slightly more focused), it was the Not a huge deal, it also works as it is. |
||
// Task is a complete X event, we have all the information we need already. | ||
tasks.push(MainThreadTasks._createNewTaskNode(taskStartEvent)); | ||
continue; | ||
} | ||
|
||
for (const event of mainThreadEvents) { | ||
// Save the timer data, TimerInstall events are instant events `ph === 'I'` so process them first. | ||
if (event.name === 'TimerInstall' && currentTask) { | ||
/** @type {string} */ | ||
// @ts-ignore - timerId exists when name is TimerInstall | ||
const timerId = event.args.data.timerId; | ||
priorTaskData.timers.set(timerId, currentTask); | ||
// Task is a B/E event pair, we need to find the matching E event. | ||
let matchedEventIndex = -1; | ||
let matchingNestedEventCount = 0; | ||
let matchingNestedEventIndex = i + 1; | ||
|
||
// We loop through the reversed end events queue from back to front because we still want to | ||
// see end events in increasing timestamp order. | ||
// While worst case we will loop through all events, the overwhelmingly common case is that | ||
// the immediate next event is our event of interest which makes this loop typically O(1). | ||
for (let j = taskEndEventsReverseQueue.length - 1; j >= 0; j--) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we reversed this array it's unclear why we're iterating from the back to front. this improves the perf of the pop() and splice()s? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep |
||
const endEvent = taskEndEventsReverseQueue[j]; | ||
// We are considering an end event, so we'll count how many nested events we saw along the way. | ||
while (matchingNestedEventIndex < taskStartEvents.length && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looking at https://cs.chromium.org/chromium/src/third_party/catapult/tracing/tracing/extras/importer/trace_event_importer.html (and slice_group) .. they use something very simple also interestingly they don't sort events or preprocess before doing this begin/end matching. perhaps making this assumption from the trace saves some headaches? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i tried out a basic implementation of this approach but it fails on
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It absolutely does save headaches and that's how the old version was written but that assumption is also what fails on several real-world traces. Recovering from that error situation is exactly what this rewrite is about, so if there's a way to get that old way to work and nicely recover from errors when that assumption breaks down I'm definitely open to someone else running with their vision for it :)
patrickhulce marked this conversation as resolved.
Show resolved
Hide resolved
|
||
taskStartEvents[matchingNestedEventIndex].ts < endEvent.ts) { | ||
if (taskStartEvents[matchingNestedEventIndex].name === taskStartEvent.name) { | ||
matchingNestedEventCount++; | ||
} | ||
|
||
matchingNestedEventIndex++; | ||
} | ||
|
||
// The event doesn't have a matching name, skip it. | ||
if (endEvent.name !== taskStartEvent.name) continue; | ||
// The event has a timestamp that is too early, skip it. | ||
if (endEvent.ts < taskStartEvent.ts) continue; | ||
|
||
// The event matches our name and happened after start, the last thing to check is if it was for a nested event. | ||
if (matchingNestedEventCount > 0) { | ||
// If it was for a nested event, decrement our counter and move on. | ||
matchingNestedEventCount--; | ||
continue; | ||
} | ||
|
||
// If it wasn't, we found our matching E event! Mark the index and stop the loop. | ||
matchedEventIndex = j; | ||
break; | ||
} | ||
|
||
/** @type {Pick<LH.TraceEvent, 'ph'|'ts'>} */ | ||
let taskEndEvent; | ||
if (matchedEventIndex === -1) { | ||
// If we couldn't find an end event, we'll assume it's the end of the trace. | ||
// If this creates invalid parent/child relationships it will be caught in the next step. | ||
taskEndEvent = {ph: 'E', ts: traceEndTs}; | ||
} else if (matchedEventIndex === taskEndEventsReverseQueue.length - 1) { | ||
// Use .pop() in the common case where the immediately next event is needed. | ||
// It's ~25x faster, https://jsperf.com/pop-vs-splice. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FWIW for a trace that ends up ~70% popped vs 30% spliced, I see no performance difference eliminating the popped path altogether. |
||
taskEndEvent = /** @type {LH.TraceEvent} */ (taskEndEventsReverseQueue.pop()); | ||
} else { | ||
taskEndEvent = taskEndEventsReverseQueue.splice(matchedEventIndex, 1)[0]; | ||
} | ||
|
||
// Only look at X (Complete), B (Begin), and E (End) events as they have most data | ||
if (event.ph !== 'X' && event.ph !== 'B' && event.ph !== 'E') continue; | ||
tasks.push(MainThreadTasks._createNewTaskNode(taskStartEvent, taskEndEvent)); | ||
} | ||
|
||
if (taskEndEventsReverseQueue.length) { | ||
throw new Error( | ||
`Fatal trace logic error - ${taskEndEventsReverseQueue.length} unmatched end events` | ||
); | ||
} | ||
|
||
return tasks; | ||
} | ||
|
||
/** | ||
* | ||
patrickhulce marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* @param {TaskNode[]} sortedTasks | ||
* @param {LH.TraceEvent[]} timerInstallEvents | ||
* @param {PriorTaskData} priorTaskData | ||
*/ | ||
static _createTaskRelationships(sortedTasks, timerInstallEvents, priorTaskData) { | ||
/** @type {TaskNode|undefined} */ | ||
let currentTask; | ||
// Create a reversed copy of the array to avoid copying the rest of the queue on every mutation. | ||
const timerInstallEventsReverseQueue = timerInstallEvents.slice().reverse(); | ||
|
||
// Update currentTask based on the elapsed time. | ||
// The next event may be after currentTask has ended. | ||
for (let i = 0; i < sortedTasks.length; i++) { | ||
const nextTask = sortedTasks[i]; | ||
|
||
// Update `currentTask` based on the elapsed time. | ||
patrickhulce marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// The `nextTask` may be after currentTask has ended. | ||
patrickhulce marked this conversation as resolved.
Show resolved
Hide resolved
|
||
while ( | ||
currentTask && | ||
Number.isFinite(currentTask.endTime) && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when is this an issue? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given the new construction method it should never be an issue when given a valid trace. Unfortunately I have no more confidence that this code will be given a valid trace these days 😢 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (to be fair though, it should be much much rarer than before where |
||
currentTask.endTime <= event.ts | ||
currentTask.endTime <= nextTask.startTime | ||
) { | ||
MainThreadTasks._assignAllTimerInstallsBetweenTasks( | ||
patrickhulce marked this conversation as resolved.
Show resolved
Hide resolved
|
||
currentTask, | ||
nextTask, | ||
priorTaskData, | ||
timerInstallEventsReverseQueue | ||
); | ||
currentTask = currentTask.parent; | ||
} | ||
|
||
// If we don't have a current task, start a new one. | ||
if (!currentTask) { | ||
// We can't start a task with an end event | ||
if (event.ph === 'E') { | ||
throw new Error('Fatal trace logic error - unexpected end event'); | ||
if (currentTask) { | ||
if (nextTask.endTime > currentTask.endTime) { | ||
const timeDelta = nextTask.endTime - currentTask.endTime; | ||
// The child task is taking longer than the parent task, which should be impossible. | ||
// If it's less than 1ms, we'll let it slide. | ||
// If it's more, throw an error. | ||
if (timeDelta < 1000) { | ||
brendankenny marked this conversation as resolved.
Show resolved
Hide resolved
|
||
currentTask.endTime = nextTask.endTime; | ||
currentTask.duration += timeDelta; | ||
brendankenny marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} else { | ||
throw new Error('Fatal trace logic error - child cannot end after parent'); | ||
} | ||
} | ||
|
||
currentTask = MainThreadTasks._createNewTaskNode(event); | ||
tasks.push(currentTask); | ||
|
||
continue; | ||
// We're currently in the middle of a task, so `nextTask` is a child. | ||
nextTask.parent = currentTask; | ||
currentTask.children.push(nextTask); | ||
MainThreadTasks._assignAllTimerInstallsBetweenTasks( | ||
currentTask, | ||
nextTask, | ||
priorTaskData, | ||
timerInstallEventsReverseQueue | ||
); | ||
} else { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure how to reconcile this one with the request for more comments and documentation, this is explicitly here so that the reader knows what the else branch would have been and why it's not necessary There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FWIW, I think something like, |
||
// We're not currently in the middle of a task, so `nextTask` is a toplevel task. | ||
// Nothing really to do here. | ||
} | ||
|
||
if (event.ph === 'X' || event.ph === 'B') { | ||
// We're starting a nested event, create it as a child and make it the currentTask | ||
const newTask = MainThreadTasks._createNewTaskNode(event, currentTask); | ||
tasks.push(newTask); | ||
currentTask = newTask; | ||
} else { | ||
if (currentTask.event.ph !== 'B') { | ||
throw new Error( | ||
`Fatal trace logic error - expected start event, got ${currentTask.event.ph}`); | ||
} | ||
currentTask = nextTask; | ||
} | ||
|
||
// We're ending an event, update the end time and the currentTask to its parent | ||
currentTask.endTime = event.ts; | ||
currentTask = currentTask.parent; | ||
} | ||
if (currentTask) { | ||
MainThreadTasks._assignAllTimerInstallsBetweenTasks( | ||
currentTask, | ||
{startTime: Infinity}, | ||
priorTaskData, | ||
timerInstallEventsReverseQueue | ||
); | ||
} | ||
} | ||
|
||
// Starting from the last and bottom-most task, we finish any tasks that didn't end yet. | ||
while (currentTask && !Number.isFinite(currentTask.endTime)) { | ||
// The last event didn't finish before tracing stopped, use traceEnd timestamp instead. | ||
currentTask.endTime = traceEndTs; | ||
currentTask = currentTask.parent; | ||
/** | ||
* This function takes the raw trace events sorted in increasing timestamp order and outputs connected task nodes. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This description is very helpful for understanding this file, I wish it was higher up or there was some top level description of this file. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a top level description of this file that addresses all that this file does while this comment discusses how this piece of the puzzle is computed (the only parts of this file that were touched were the trace parsing pieces). It sounds like you think it might be worth splitting this file into different components? I'm not quite sure what that would look like though. Maybe just...
? |
||
* To create the task heirarchy we make several passes over the events. | ||
* | ||
* 1. Create three arrays of X/B events, E events, and TimerInstall events. | ||
* 2. Create tasks for each X/B event, throwing if a matching E event cannot be found for a given B. | ||
* 3. Sort the tasks by ↑ startTime, ↓ duration. | ||
* 4. Match each task to its parent, throwing if there is any invalid overlap between tasks. | ||
* | ||
* @param {LH.TraceEvent[]} mainThreadEvents | ||
* @param {PriorTaskData} priorTaskData | ||
* @param {number} traceEndTs | ||
* @return {TaskNode[]} | ||
*/ | ||
static _createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) { | ||
patrickhulce marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/** @type {Array<LH.TraceEvent>} */ | ||
const taskStartEvents = []; | ||
/** @type {Array<LH.TraceEvent>} */ | ||
const taskEndEvents = []; | ||
/** @type {Array<LH.TraceEvent>} */ | ||
const timerInstallEvents = []; | ||
|
||
// Phase 1 - Create three arrays of X/B events, E events, and TimerInstall events. | ||
for (const event of mainThreadEvents) { | ||
if (event.ph === 'X' || event.ph === 'B') taskStartEvents.push(event); | ||
if (event.ph === 'E') taskEndEvents.push(event); | ||
if (event.name === 'TimerInstall') timerInstallEvents.push(event); | ||
} | ||
|
||
// At this point we expect all tasks to have a finite startTime and endTime. | ||
return tasks; | ||
// Phase 2 - Create tasks for each taskStartEvent. | ||
const tasks = MainThreadTasks._createTasksFromStartAndEndEvents( | ||
taskStartEvents, | ||
taskEndEvents, | ||
traceEndTs | ||
); | ||
|
||
// Phase 3 - Sort the tasks by increasing startTime, decreasing duration. | ||
const sortedTasks = tasks.sort( | ||
(taskA, taskB) => taskA.startTime - taskB.startTime || taskB.duration - taskA.duration | ||
); | ||
|
||
// Phase 4 - Match each task to its parent. | ||
MainThreadTasks._createTaskRelationships(sortedTasks, timerInstallEvents, priorTaskData); | ||
|
||
return sortedTasks; | ||
} | ||
|
||
/** | ||
|
@@ -157,7 +338,6 @@ class MainThreadTasks { | |
const childTime = task.children | ||
.map(child => MainThreadTasks._computeRecursiveSelfTime(child, task)) | ||
.reduce((sum, child) => sum + child, 0); | ||
task.duration = task.endTime - task.startTime; | ||
task.selfTime = task.duration - childTime; | ||
return task.duration; | ||
} | ||
|
@@ -213,7 +393,8 @@ class MainThreadTasks { | |
|
||
task.attributableURLs = attributableURLs; | ||
task.children.forEach(child => | ||
MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData)); | ||
MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData) | ||
); | ||
} | ||
|
||
/** | ||
|
@@ -227,14 +408,18 @@ class MainThreadTasks { | |
} | ||
|
||
/** | ||
* @param {LH.TraceEvent[]} traceEvents | ||
* @param {LH.TraceEvent[]} mainThreadEvents | ||
* @param {number} traceEndTs | ||
* @return {TaskNode[]} | ||
*/ | ||
static getMainThreadTasks(traceEvents, traceEndTs) { | ||
static getMainThreadTasks(mainThreadEvents, traceEndTs) { | ||
const timers = new Map(); | ||
const priorTaskData = {timers}; | ||
const tasks = MainThreadTasks._createTasksFromEvents(traceEvents, priorTaskData, traceEndTs); | ||
const tasks = MainThreadTasks._createTasksFromEvents( | ||
mainThreadEvents, | ||
priorTaskData, | ||
traceEndTs | ||
); | ||
|
||
// Compute the recursive properties we couldn't compute earlier, starting at the toplevel tasks | ||
for (const task of tasks) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are there other test cases we should add for this new method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I already added
lighthouse/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
Lines 156 to 197 in 6daf898