Skip to content

Commit

Permalink
feat(predictive-perf): add network estimation
Browse files Browse the repository at this point in the history
  • Loading branch information
patrickhulce committed Aug 18, 2017
1 parent 2ec4f79 commit c34c085
Show file tree
Hide file tree
Showing 11 changed files with 976 additions and 59 deletions.
69 changes: 67 additions & 2 deletions lighthouse-core/audits/predictive-perf.js
Expand Up @@ -8,6 +8,7 @@
const Audit = require('./audit');
const Util = require('../report/v2/renderer/util.js');
const PageDependencyGraph = require('../gather/computed/page-dependency-graph.js');
const Node = require('../gather/computed/dependency-graph/node.js');

// Parameters (in ms) for log-normal CDF scoring. To see the curve:
// https://www.desmos.com/calculator/rjp0lbit8y
Expand All @@ -28,15 +29,78 @@ class PredictivePerf extends Audit {
};
}

/**
* @param {!Node} graph
* @param {!TraceOfTabArtifact} traceOfTab
* @return {!Node}
*/
static getOptimisticFMPGraph(graph, traceOfTab) {
const fmp = traceOfTab.timestamps.firstMeaningfulPaint;
return graph.cloneWithRelationships(node => {
if (node.endTime > fmp) return false;
if (node.type !== Node.TYPES.NETWORK) return true;
return node.record.priority() === 'VeryHigh'; // proxy for render-blocking
});
}

/**
* @param {!Node} graph
* @param {!TraceOfTabArtifact} traceOfTab
* @return {!Node}
*/
static getPessimisticFMPGraph(graph, traceOfTab) {
const fmp = traceOfTab.timestamps.firstMeaningfulPaint;
return graph.cloneWithRelationships(node => {
return node.endTime <= fmp;
});
}

/**
* @param {!Node} graph
* @return {!Node}
*/
static getOptimisticTTCIGraph(graph) {
return graph.cloneWithRelationships(node => {
return node.record._resourceType && node.record._resourceType._name === 'script' ||
node.record.priority() === 'High' ||
node.record.priority() === 'VeryHigh';
});
}

/**
* @param {!Node} graph
* @return {!Node}
*/
static getPessimisticTTCIGraph(graph) {
return graph;
}

/**
* @param {!Artifacts} artifacts
* @return {!AuditResult}
*/
static audit(artifacts) {
const trace = artifacts.traces[Audit.DEFAULT_PASS];
const devtoolsLogs = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
return artifacts.requestPageDependencyGraph(trace, devtoolsLogs).then(graph => {
const rawValue = PageDependencyGraph.computeGraphDuration(graph);
return Promise.all([
artifacts.requestPageDependencyGraph(trace, devtoolsLogs),
artifacts.requestTraceOfTab(trace),
]).then(([graph, traceOfTab]) => {
const graphs = {
optimisticFMP: PredictivePerf.getOptimisticFMPGraph(graph, traceOfTab),
pessimisticFMP: PredictivePerf.getPessimisticFMPGraph(graph, traceOfTab),
optimisticTTCI: PredictivePerf.getOptimisticTTCIGraph(graph, traceOfTab),
pessimisticTTCI: PredictivePerf.getPessimisticTTCIGraph(graph, traceOfTab),
};

let sum = 0;
const values = {};
Object.keys(graphs).forEach(key => {
values[key] = PageDependencyGraph.computeGraphDuration(graphs[key]);
sum += values[key];
});

const rawValue = sum / 4;
const score = Audit.computeLogNormalScore(
rawValue,
SCORING_POINT_OF_DIMINISHING_RETURNS,
Expand All @@ -47,6 +111,7 @@ class PredictivePerf extends Audit {
score,
rawValue,
displayValue: Util.formatMilliseconds(rawValue),
extendedInfo: {value: values},
};
});
}
Expand Down
@@ -0,0 +1,284 @@
/**
* @license Copyright 2017 Google Inc. 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.
*/
'use strict';

const Node = require('../node');
const TcpConnection = require('./tcp-connection');

// see https://cs.chromium.org/search/?q=kDefaultMaxNumDelayableRequestsPerClient&sq=package:chromium&type=cs
const DEFAULT_MAXIMUM_CONCURRENT_REQUESTS = 10;
const DEFAULT_RESPONSE_TIME = 30;
const DEFAULT_RTT = 150;
const DEFAULT_THROUGHPUT = 1600 * 1024; // 1.6 Mbps

function groupBy(items, keyFunc) {
const grouped = new Map();
items.forEach(item => {
const key = keyFunc(item);
const group = grouped.get(key) || [];
group.push(item);
grouped.set(key, group);
});

return grouped;
}

class Estimator {
/**
* @param {!Node} graph
* @param {{rtt: number, throughput: number, defaultResponseTime: number,
* maximumConcurrentRequests: number}=} options
*/
constructor(graph, options) {
this._graph = graph;
this._options = Object.assign(
{
rtt: DEFAULT_RTT,
throughput: DEFAULT_THROUGHPUT,
defaultResponseTime: DEFAULT_RESPONSE_TIME,
maximumConcurrentRequests: DEFAULT_MAXIMUM_CONCURRENT_REQUESTS,
},
options
);

this._rtt = this._options.rtt;
this._throughput = this._options.throughput;
this._defaultResponseTime = this._options.defaultResponseTime;
this._maximumConcurrentRequests = Math.min(
TcpConnection.maximumSaturatedConnections(this._rtt, this._throughput),
this._options.maximumConcurrentRequests
);
}

/**
* @param {!WebInspector.NetworkRequest} record
* @return {number}
*/
static getResponseTime(record) {
const timing = record._timing;
return (timing && timing.receiveHeadersEnd - timing.sendEnd) || Infinity;
}

_initializeNetworkRecords() {
const records = [];

this._graph.getRootNode().traverse(node => {
if (node.type === Node.TYPES.NETWORK) {
records.push(node.record);
}
});

this._networkRecords = records;
return records;
}

_initializeNetworkConnections() {
const connections = new Map();
const recordsByConnection = groupBy(
this._networkRecords,
record => record.connectionId
);

for (const [connectionId, records] of recordsByConnection.entries()) {
const isSsl = records[0].parsedURL.scheme === 'https';
let responseTime = records.reduce(
(min, record) => Math.min(min, Estimator.getResponseTime(record)),
Infinity
);

if (!Number.isFinite(responseTime)) {
responseTime = this._defaultResponseTime;
}

const connection = new TcpConnection(
this._rtt,
this._throughput,
responseTime,
isSsl
);

connections.set(connectionId, connection);
}

this._connections = connections;
return connections;
}

_initializeAuxiliaryData() {
this._nodeAuxiliaryData = new Map();
this._nodesCompleted = new Set();
this._nodesInProcess = new Set();
this._nodesInQueue = new Set(); // TODO: replace this with priority queue
this._connectionsInUse = new Set();
}

/**
* @param {!Node} node
*/
_enqueueNodeIfPossible(node) {
const dependencies = node.getDependencies();
if (
!this._nodesCompleted.has(node) &&
dependencies.every(dependency => this._nodesCompleted.has(dependency))
) {
this._nodesInQueue.add(node);
}
}

/**
* @param {!Node} node
* @param {number} totalElapsedTime
*/
_startNodeIfPossible(node, totalElapsedTime) {
if (node.type !== Node.TYPES.NETWORK) return;

const connection = this._connections.get(node.record.connectionId);

if (
this._nodesInProcess.size >= this._maximumConcurrentRequests ||
this._connectionsInUse.has(connection)
) {
return;
}

this._nodesInQueue.delete(node);
this._nodesInProcess.add(node);
this._nodeAuxiliaryData.set(node, {
startTime: totalElapsedTime,
timeElapsed: 0,
timeElapsedOvershoot: 0,
bytesDownloaded: 0,
});

this._connectionsInUse.add(connection);
}

_updateNetworkCapacity() {
for (const connection of this._connectionsInUse) {
connection.setThroughput(this._throughput / this._nodesInProcess.size);
}
}

/**
* @param {!Node} node
* @return {number}
*/
_estimateTimeRemaining(node) {
if (node.type !== Node.TYPES.NETWORK) throw new Error('Unsupported');

const auxiliaryData = this._nodeAuxiliaryData.get(node);
const connection = this._connections.get(node.record.connectionId);
const calculation = connection.calculateTimeToDownload(
node.record.transferSize - auxiliaryData.bytesDownloaded,
auxiliaryData.timeElapsed
);

const estimate = calculation.timeElapsed + auxiliaryData.timeElapsedOvershoot;
auxiliaryData.estimatedTimeElapsed = estimate;
return estimate;
}

/**
* @return {number}
*/
_findNextNodeCompletionTime() {
let minimumTime = Infinity;
for (const node of this._nodesInProcess) {
minimumTime = Math.min(minimumTime, this._estimateTimeRemaining(node));
}

return minimumTime;
}

/**
* @param {!Node} node
* @param {number} timePeriodLength
* @param {number} totalElapsedTime
*/
_updateProgressMadeInTimePeriod(node, timePeriodLength, totalElapsedTime) {
if (node.type !== Node.TYPES.NETWORK) throw new Error('Unsupported');

const auxiliaryData = this._nodeAuxiliaryData.get(node);
const connection = this._connections.get(node.record.connectionId);
const calculation = connection.calculateTimeToDownload(
node.record.transferSize - auxiliaryData.bytesDownloaded,
auxiliaryData.timeElapsed,
timePeriodLength - auxiliaryData.timeElapsedOvershoot
);

connection.setCongestionWindow(calculation.congestionWindow);

if (auxiliaryData.estimatedTimeElapsed === timePeriodLength) {
auxiliaryData.endTime = totalElapsedTime;

connection.setWarmed(true);
this._connectionsInUse.delete(connection);

this._nodesCompleted.add(node);
this._nodesInProcess.delete(node);

for (const dependent of node.getDependents()) {
this._enqueueNodeIfPossible(dependent);
}
} else {
auxiliaryData.timeElapsed += calculation.timeElapsed;
auxiliaryData.timeElapsedOvershoot +=
calculation.timeElapsed - timePeriodLength;
auxiliaryData.bytesDownloaded += calculation.bytesDownloaded;
}
}

/**
* @return {number}
*/
estimate() {
// initialize all the necessary data containers
this._initializeNetworkRecords();
this._initializeNetworkConnections();
this._initializeAuxiliaryData();

const nodesInQueue = this._nodesInQueue;
const nodesInProcess = this._nodesInProcess;

// add root node to queue
nodesInQueue.add(this._graph.getRootNode());

let depth = 0;
let totalElapsedTime = 0;
while (nodesInQueue.size || nodesInProcess.size) {
depth++;

// move all possible queued nodes to in process
for (const node of nodesInQueue) {
this._startNodeIfPossible(node, totalElapsedTime);
}

// set the available throughput for all connections based on # inflight
this._updateNetworkCapacity();

// find the time that the next node will finish
const minimumTime = this._findNextNodeCompletionTime();
totalElapsedTime += minimumTime;

// update how far each node will progress until that point
for (const node of nodesInProcess) {
this._updateProgressMadeInTimePeriod(
node,
minimumTime,
totalElapsedTime
);
}

if (depth > 10000) {
throw new Error('Maximum depth exceeded: estimate');
}
}

return totalElapsedTime;
}
}

module.exports = Estimator;

0 comments on commit c34c085

Please sign in to comment.