Skip to content

Commit 4debf10

Browse files
authored
core(network-analyzer): coarse rtt estimate on per-origin basis (#15103)
1 parent ec59d80 commit 4debf10

File tree

18 files changed

+380
-244
lines changed

18 files changed

+380
-244
lines changed

core/lib/dependency-graph/simulator/network-analyzer.js

Lines changed: 128 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -132,60 +132,60 @@ class NetworkAnalyzer {
132132
* single handshake.
133133
* This is the most accurate and preferred method of measurement when the data is available.
134134
*
135-
* @param {LH.Artifacts.NetworkRequest[]} records
136-
* @return {Map<string, number[]>}
135+
* @param {RequestInfo} info
136+
* @return {number[]|number|undefined}
137137
*/
138-
static _estimateRTTByOriginViaConnectionTiming(records) {
139-
return NetworkAnalyzer._estimateValueByOrigin(records, ({timing, connectionReused, record}) => {
140-
if (connectionReused) return;
141-
142-
// In LR, network records are missing connection timing, but we've smuggled it in via headers.
143-
if (global.isLightrider && record.lrStatistics) {
144-
if (record.protocol.startsWith('h3')) {
145-
return record.lrStatistics.TCPMs;
146-
} else {
147-
return [record.lrStatistics.TCPMs / 2, record.lrStatistics.TCPMs / 2];
148-
}
138+
static _estimateRTTViaConnectionTiming(info) {
139+
const {timing, connectionReused, record} = info;
140+
if (connectionReused) return;
141+
142+
// In LR, network records are missing connection timing, but we've smuggled it in via headers.
143+
if (global.isLightrider && record.lrStatistics) {
144+
if (record.protocol.startsWith('h3')) {
145+
return record.lrStatistics.TCPMs;
146+
} else {
147+
return [record.lrStatistics.TCPMs / 2, record.lrStatistics.TCPMs / 2];
149148
}
149+
}
150150

151-
const {connectStart, sslStart, sslEnd, connectEnd} = timing;
152-
if (connectEnd >= 0 && connectStart >= 0 && record.protocol.startsWith('h3')) {
153-
// These values are equal to sslStart and sslEnd for h3.
154-
return connectEnd - connectStart;
155-
} else if (sslStart >= 0 && sslEnd >= 0 && sslStart !== connectStart) {
156-
// SSL can also be more than 1 RT but assume False Start was used.
157-
return [connectEnd - sslStart, sslStart - connectStart];
158-
} else if (connectStart >= 0 && connectEnd >= 0) {
159-
return connectEnd - connectStart;
160-
}
161-
});
151+
const {connectStart, sslStart, sslEnd, connectEnd} = timing;
152+
if (connectEnd >= 0 && connectStart >= 0 && record.protocol.startsWith('h3')) {
153+
// These values are equal to sslStart and sslEnd for h3.
154+
return connectEnd - connectStart;
155+
} else if (sslStart >= 0 && sslEnd >= 0 && sslStart !== connectStart) {
156+
// SSL can also be more than 1 RT but assume False Start was used.
157+
return [connectEnd - sslStart, sslStart - connectStart];
158+
} else if (connectStart >= 0 && connectEnd >= 0) {
159+
return connectEnd - connectStart;
160+
}
162161
}
163162

164163
/**
165164
* Estimates the observed RTT to each origin based on how long a download took on a fresh connection.
166165
* NOTE: this will tend to overestimate the actual RTT quite significantly as the download can be
167166
* slow for other reasons as well such as bandwidth constraints.
168167
*
169-
* @param {LH.Artifacts.NetworkRequest[]} records
170-
* @return {Map<string, number[]>}
168+
* @param {RequestInfo} info
169+
* @return {number|undefined}
171170
*/
172-
static _estimateRTTByOriginViaDownloadTiming(records) {
173-
return NetworkAnalyzer._estimateValueByOrigin(records, ({record, timing, connectionReused}) => {
174-
if (connectionReused) return;
175-
// Only look at downloads that went past the initial congestion window
176-
if (record.transferSize <= INITIAL_CWD) return;
177-
if (!Number.isFinite(timing.receiveHeadersEnd) || timing.receiveHeadersEnd < 0) return;
171+
static _estimateRTTViaDownloadTiming(info) {
172+
const {timing, connectionReused, record} = info;
173+
if (connectionReused) return;
178174

179-
// Compute the amount of time downloading everything after the first congestion window took
180-
const totalTime = record.networkEndTime - record.networkRequestTime;
181-
const downloadTimeAfterFirstByte = totalTime - timing.receiveHeadersEnd;
182-
const numberOfRoundTrips = Math.log2(record.transferSize / INITIAL_CWD);
175+
// Only look at downloads that went past the initial congestion window
176+
if (record.transferSize <= INITIAL_CWD) return;
177+
if (!Number.isFinite(timing.receiveHeadersEnd) || timing.receiveHeadersEnd < 0) return;
183178

184-
// Ignore requests that required a high number of round trips since bandwidth starts to play
185-
// a larger role than latency
186-
if (numberOfRoundTrips > 5) return;
187-
return downloadTimeAfterFirstByte / numberOfRoundTrips;
188-
});
179+
// Compute the amount of time downloading everything after the first congestion window took
180+
const totalTime = record.networkEndTime - record.networkRequestTime;
181+
const downloadTimeAfterFirstByte = totalTime - timing.receiveHeadersEnd;
182+
const numberOfRoundTrips = Math.log2(record.transferSize / INITIAL_CWD);
183+
184+
// Ignore requests that required a high number of round trips since bandwidth starts to play
185+
// a larger role than latency
186+
if (numberOfRoundTrips > 5) return;
187+
188+
return downloadTimeAfterFirstByte / numberOfRoundTrips;
189189
}
190190

191191
/**
@@ -194,21 +194,21 @@ class NetworkAnalyzer {
194194
* NOTE: this will tend to overestimate the actual RTT as the request can be delayed for other
195195
* reasons as well such as more SSL handshakes if TLS False Start is not enabled.
196196
*
197-
* @param {LH.Artifacts.NetworkRequest[]} records
198-
* @return {Map<string, number[]>}
197+
* @param {RequestInfo} info
198+
* @return {number|undefined}
199199
*/
200-
static _estimateRTTByOriginViaSendStartTiming(records) {
201-
return NetworkAnalyzer._estimateValueByOrigin(records, ({record, timing, connectionReused}) => {
202-
if (connectionReused) return;
203-
if (!Number.isFinite(timing.sendStart) || timing.sendStart < 0) return;
204-
205-
// Assume everything before sendStart was just DNS + (SSL)? + TCP handshake
206-
// 1 RT for DNS, 1 RT (maybe) for SSL, 1 RT for TCP
207-
let roundTrips = 1;
208-
if (!record.protocol.startsWith('h3')) roundTrips += 1; // TCP
209-
if (record.parsedURL.scheme === 'https') roundTrips += 1;
210-
return timing.sendStart / roundTrips;
211-
});
200+
static _estimateRTTViaSendStartTiming(info) {
201+
const {timing, connectionReused, record} = info;
202+
if (connectionReused) return;
203+
204+
if (!Number.isFinite(timing.sendStart) || timing.sendStart < 0) return;
205+
206+
// Assume everything before sendStart was just DNS + (SSL)? + TCP handshake
207+
// 1 RT for DNS, 1 RT (maybe) for SSL, 1 RT for TCP
208+
let roundTrips = 1;
209+
if (!record.protocol.startsWith('h3')) roundTrips += 1; // TCP
210+
if (record.parsedURL.scheme === 'https') roundTrips += 1;
211+
return timing.sendStart / roundTrips;
212212
}
213213

214214
/**
@@ -217,34 +217,33 @@ class NetworkAnalyzer {
217217
* NOTE: this is the most inaccurate way to estimate the RTT, but in some environments it's all
218218
* we have access to :(
219219
*
220-
* @param {LH.Artifacts.NetworkRequest[]} records
221-
* @return {Map<string, number[]>}
220+
* @param {RequestInfo} info
221+
* @return {number|undefined}
222222
*/
223-
static _estimateRTTByOriginViaHeadersEndTiming(records) {
224-
return NetworkAnalyzer._estimateValueByOrigin(records, ({record, timing, connectionReused}) => {
225-
if (!Number.isFinite(timing.receiveHeadersEnd) || timing.receiveHeadersEnd < 0) return;
226-
if (!record.resourceType) return;
227-
228-
const serverResponseTimePercentage =
229-
SERVER_RESPONSE_PERCENTAGE_OF_TTFB[record.resourceType] ||
230-
DEFAULT_SERVER_RESPONSE_PERCENTAGE;
231-
const estimatedServerResponseTime = timing.receiveHeadersEnd * serverResponseTimePercentage;
232-
233-
// When connection was reused...
234-
// TTFB = 1 RT for request + server response time
235-
let roundTrips = 1;
236-
237-
// When connection was fresh...
238-
// TTFB = DNS + (SSL)? + TCP handshake + 1 RT for request + server response time
239-
if (!connectionReused) {
240-
roundTrips += 1; // DNS
241-
if (!record.protocol.startsWith('h3')) roundTrips += 1; // TCP
242-
if (record.parsedURL.scheme === 'https') roundTrips += 1; // SSL
243-
}
223+
static _estimateRTTViaHeadersEndTiming(info) {
224+
const {timing, connectionReused, record} = info;
225+
if (!Number.isFinite(timing.receiveHeadersEnd) || timing.receiveHeadersEnd < 0) return;
226+
if (!record.resourceType) return;
227+
228+
const serverResponseTimePercentage =
229+
SERVER_RESPONSE_PERCENTAGE_OF_TTFB[record.resourceType] ||
230+
DEFAULT_SERVER_RESPONSE_PERCENTAGE;
231+
const estimatedServerResponseTime = timing.receiveHeadersEnd * serverResponseTimePercentage;
232+
233+
// When connection was reused...
234+
// TTFB = 1 RT for request + server response time
235+
let roundTrips = 1;
236+
237+
// When connection was fresh...
238+
// TTFB = DNS + (SSL)? + TCP handshake + 1 RT for request + server response time
239+
if (!connectionReused) {
240+
roundTrips += 1; // DNS
241+
if (!record.protocol.startsWith('h3')) roundTrips += 1; // TCP
242+
if (record.parsedURL.scheme === 'https') roundTrips += 1; // SSL
243+
}
244244

245-
// subtract out our estimated server response time
246-
return Math.max((timing.receiveHeadersEnd - estimatedServerResponseTime) / roundTrips, 3);
247-
});
245+
// subtract out our estimated server response time
246+
return Math.max((timing.receiveHeadersEnd - estimatedServerResponseTime) / roundTrips, 3);
248247
}
249248

250249
/**
@@ -350,32 +349,61 @@ class NetworkAnalyzer {
350349
useHeadersEndEstimates = true,
351350
} = options || {};
352351

353-
let estimatesByOrigin = NetworkAnalyzer._estimateRTTByOriginViaConnectionTiming(records);
354-
if (!estimatesByOrigin.size || forceCoarseEstimates) {
355-
estimatesByOrigin = new Map();
356-
const estimatesViaDownload = NetworkAnalyzer._estimateRTTByOriginViaDownloadTiming(records);
357-
const estimatesViaSendStart = NetworkAnalyzer._estimateRTTByOriginViaSendStartTiming(records);
358-
const estimatesViaTTFB = NetworkAnalyzer._estimateRTTByOriginViaHeadersEndTiming(records);
352+
const connectionWasReused = NetworkAnalyzer.estimateIfConnectionWasReused(records);
353+
const groupedByOrigin = NetworkAnalyzer.groupByOrigin(records);
359354

360-
for (const [origin, estimates] of estimatesViaDownload.entries()) {
361-
if (!useDownloadEstimates) continue;
362-
estimatesByOrigin.set(origin, estimates);
355+
const estimatesByOrigin = new Map();
356+
for (const [origin, originRecords] of groupedByOrigin.entries()) {
357+
/** @type {number[]} */
358+
const originEstimates = [];
359+
360+
/**
361+
* @param {(e: RequestInfo) => number[]|number|undefined} estimator
362+
*/
363+
// eslint-disable-next-line no-inner-declarations
364+
function collectEstimates(estimator, multiplier = 1) {
365+
for (const record of originRecords) {
366+
const timing = record.timing;
367+
if (!timing) continue;
368+
369+
const estimates = estimator({
370+
record,
371+
timing,
372+
connectionReused: connectionWasReused.get(record.requestId),
373+
});
374+
if (estimates === undefined) continue;
375+
376+
if (!Array.isArray(estimates)) {
377+
originEstimates.push(estimates * multiplier);
378+
} else {
379+
originEstimates.push(...estimates.map(e => e * multiplier));
380+
}
381+
}
363382
}
364383

365-
for (const [origin, estimates] of estimatesViaSendStart.entries()) {
366-
if (!useSendStartEstimates) continue;
367-
const existing = estimatesByOrigin.get(origin) || [];
368-
estimatesByOrigin.set(origin, existing.concat(estimates));
384+
if (!forceCoarseEstimates) {
385+
collectEstimates(this._estimateRTTViaConnectionTiming);
369386
}
370387

371-
for (const [origin, estimates] of estimatesViaTTFB.entries()) {
372-
if (!useHeadersEndEstimates) continue;
373-
const existing = estimatesByOrigin.get(origin) || [];
374-
estimatesByOrigin.set(origin, existing.concat(estimates));
388+
// Connection timing can be missing for a few reasons:
389+
// - Origin was preconnected, which we don't have instrumentation for.
390+
// - Trace began recording after a connection has already been established (for example, in timespan mode)
391+
// - Perhaps Chrome established a connection already in the background (service worker? Just guessing here)
392+
// - Not provided in LR netstack.
393+
if (!originEstimates.length) {
394+
if (useDownloadEstimates) {
395+
collectEstimates(this._estimateRTTViaDownloadTiming, coarseEstimateMultiplier);
396+
}
397+
if (useSendStartEstimates) {
398+
collectEstimates(this._estimateRTTViaSendStartTiming, coarseEstimateMultiplier);
399+
}
400+
if (useHeadersEndEstimates) {
401+
collectEstimates(this._estimateRTTViaHeadersEndTiming, coarseEstimateMultiplier);
402+
}
375403
}
376404

377-
for (const estimates of estimatesByOrigin.values()) {
378-
estimates.forEach((x, i) => (estimates[i] = x * coarseEstimateMultiplier));
405+
if (originEstimates.length) {
406+
estimatesByOrigin.set(origin, originEstimates);
379407
}
380408
}
381409

core/test/audits/__snapshots__/metrics-test.js.snap

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@ exports[`Performance: metrics evaluates valid input (with image lcp) correctly 1
44
Object {
55
"cumulativeLayoutShift": 0,
66
"cumulativeLayoutShiftMainFrame": 0,
7-
"firstContentfulPaint": 3313,
7+
"firstContentfulPaint": 3364,
88
"firstContentfulPaintAllFrames": undefined,
99
"firstContentfulPaintAllFramesTs": undefined,
1010
"firstContentfulPaintTs": undefined,
11-
"firstMeaningfulPaint": 3313,
11+
"firstMeaningfulPaint": 3364,
1212
"firstMeaningfulPaintTs": undefined,
13-
"interactive": 6380,
13+
"interactive": 6354,
1414
"interactiveTs": undefined,
15-
"largestContentfulPaint": 5621,
15+
"largestContentfulPaint": 5567,
1616
"largestContentfulPaintAllFrames": undefined,
1717
"largestContentfulPaintAllFramesTs": undefined,
1818
"largestContentfulPaintTs": undefined,
19-
"lcpLoadEnd": 5572,
20-
"lcpLoadStart": 5449,
19+
"lcpLoadEnd": 5519,
20+
"lcpLoadStart": 5397,
2121
"maxPotentialFID": 160,
2222
"observedCumulativeLayoutShift": 0,
2323
"observedCumulativeLayoutShiftMainFrame": 0,
@@ -49,7 +49,7 @@ Object {
4949
"observedTimeOriginTs": 760620643599,
5050
"observedTraceEnd": 4778,
5151
"observedTraceEndTs": 760625421283,
52-
"speedIndex": 6313,
52+
"speedIndex": 6330,
5353
"speedIndexTs": undefined,
5454
"timeToFirstByte": 2394,
5555
"timeToFirstByteTs": undefined,
@@ -118,15 +118,15 @@ exports[`Performance: metrics evaluates valid input (with lcp) correctly 1`] = `
118118
Object {
119119
"cumulativeLayoutShift": 0,
120120
"cumulativeLayoutShiftMainFrame": 0,
121-
"firstContentfulPaint": 2291,
121+
"firstContentfulPaint": 2294,
122122
"firstContentfulPaintAllFrames": undefined,
123123
"firstContentfulPaintAllFramesTs": undefined,
124124
"firstContentfulPaintTs": undefined,
125-
"firstMeaningfulPaint": 2761,
125+
"firstMeaningfulPaint": 2764,
126126
"firstMeaningfulPaintTs": undefined,
127-
"interactive": 4446,
127+
"interactive": 4457,
128128
"interactiveTs": undefined,
129-
"largestContentfulPaint": 2761,
129+
"largestContentfulPaint": 2764,
130130
"largestContentfulPaintAllFrames": undefined,
131131
"largestContentfulPaintAllFramesTs": undefined,
132132
"largestContentfulPaintTs": undefined,
@@ -163,7 +163,7 @@ Object {
163163
"observedTimeOriginTs": 713037023064,
164164
"observedTraceEnd": 7416,
165165
"observedTraceEndTs": 713044439102,
166-
"speedIndex": 3683,
166+
"speedIndex": 3684,
167167
"speedIndexTs": undefined,
168168
"timeToFirstByte": 611,
169169
"timeToFirstByteTs": undefined,

core/test/audits/__snapshots__/predictive-perf-test.js.snap

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,23 @@
22

33
exports[`Performance: predictive performance audit should compute the predicted values 1`] = `
44
Object {
5-
"optimisticFCP": 2291,
6-
"optimisticFMP": 2291,
7-
"optimisticLCP": 2291,
5+
"optimisticFCP": 2294,
6+
"optimisticFMP": 2294,
7+
"optimisticLCP": 2294,
88
"optimisticSI": 1393,
9-
"optimisticTTI": 3792,
10-
"pessimisticFCP": 2291,
11-
"pessimisticFMP": 3230,
12-
"pessimisticLCP": 3230,
13-
"pessimisticSI": 3049,
14-
"pessimisticTTI": 5099,
15-
"roughEstimateOfFCP": 2291,
16-
"roughEstimateOfFMP": 2761,
17-
"roughEstimateOfLCP": 2761,
9+
"optimisticTTI": 3795,
10+
"pessimisticFCP": 2294,
11+
"pessimisticFMP": 3233,
12+
"pessimisticLCP": 3233,
13+
"pessimisticSI": 3052,
14+
"pessimisticTTI": 5119,
15+
"roughEstimateOfFCP": 2294,
16+
"roughEstimateOfFMP": 2764,
17+
"roughEstimateOfLCP": 2764,
1818
"roughEstimateOfLCPLoadEnd": undefined,
1919
"roughEstimateOfLCPLoadStart": undefined,
20-
"roughEstimateOfSI": 3683,
20+
"roughEstimateOfSI": 3684,
2121
"roughEstimateOfTTFB": 611,
22-
"roughEstimateOfTTI": 4446,
22+
"roughEstimateOfTTI": 4457,
2323
}
2424
`;

0 commit comments

Comments
 (0)