// Per-frame time sampling infra. Also GC'd: hopefully will not perturb things too badly. var numSamples = 500; var delays = new Array(numSamples); var gcs = new Array(numSamples); var minorGCs = new Array(numSamples); var gcBytes = new Array(numSamples); var mallocBytes = new Array(numSamples); var sampleIndex = 0; var sampleTime = 16; // ms var gHistogram = new Map(); // {ms: count} var features = { trackingSizes: ('mozMemory' in performance), showingGCs: ('mozMemory' in performance), }; // Draw state. var stopped = 0; var start; var prev; var latencyGraph; var memoryGraph; var ctx; var memoryCtx; // Current test state. var activeTest = undefined; var testDuration = undefined; // ms var testState = 'idle'; // One of 'idle' or 'running'. var testStart = undefined; // ms var testQueue = []; // Global defaults var globalDefaultGarbageTotal = "8M"; var globalDefaultGarbagePerFrame = "8K"; function Graph(ctx) { this.ctx = ctx; var { width, height } = ctx.canvas; this.layout = { xAxisLabel_Y: height - 20, }; } Graph.prototype.xpos = index => index * 2; Graph.prototype.clear = function () { var { width, height } = this.ctx.canvas; this.ctx.clearRect(0, 0, width, height); }; Graph.prototype.drawScale = function (delay) { this.drawHBar(delay, `${delay}ms`, 'rgb(150,150,150)'); } Graph.prototype.draw60fps = function () { this.drawHBar(1000/60, '60fps', '#00cf61', 25); } Graph.prototype.draw30fps = function () { this.drawHBar(1000/30, '30fps', '#cf0061', 25); } Graph.prototype.drawAxisLabels = function (x_label, y_label) { var ctx = this.ctx; var { width, height } = ctx.canvas; ctx.fillText(x_label, width / 2, this.layout.xAxisLabel_Y); ctx.save(); ctx.rotate(Math.PI/2); var start = height / 2 - ctx.measureText(y_label).width / 2; ctx.fillText(y_label, start, -width+20); ctx.restore(); } Graph.prototype.drawFrame = function () { var ctx = this.ctx; var { width, height } = ctx.canvas; // Draw frame to show size ctx.strokeStyle = 'rgb(0,0,0)'; ctx.fillStyle = 'rgb(0,0,0)'; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(width, 0); ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.closePath(); ctx.stroke(); } function LatencyGraph(ctx) { Graph.call(this, ctx); console.log(this.ctx); } LatencyGraph.prototype = Object.create(Graph.prototype); Object.defineProperty(LatencyGraph.prototype, 'constructor', { enumerable: false, value: LatencyGraph }); LatencyGraph.prototype.ypos = function (delay) { var { height } = this.ctx.canvas; var r = height + 100 - Math.log(delay) * 64; if (r < 5) return 5; return r; } LatencyGraph.prototype.drawHBar = function (delay, label, color='rgb(0,0,0)', label_offset=0) { var ctx = this.ctx; ctx.fillStyle = color; ctx.strokeStyle = color; ctx.fillText(label, this.xpos(numSamples) + 4 + label_offset, this.ypos(delay) + 3); ctx.beginPath(); ctx.moveTo(this.xpos(0), this.ypos(delay)); ctx.lineTo(this.xpos(numSamples) + label_offset, this.ypos(delay)); ctx.stroke(); ctx.strokeStyle = 'rgb(0,0,0)'; ctx.fillStyle = 'rgb(0,0,0)'; } LatencyGraph.prototype.draw = function () { var ctx = this.ctx; this.clear(); this.drawFrame(); for (var delay of [ 10, 20, 30, 50, 100, 200, 400, 800 ]) this.drawScale(delay); this.draw60fps(); this.draw30fps(); var worst = 0, worstpos = 0; ctx.beginPath(); for (var i = 0; i < numSamples; i++) { ctx.lineTo(this.xpos(i), this.ypos(delays[i])); if (delays[i] >= worst) { worst = delays[i]; worstpos = i; } } ctx.stroke(); // Draw vertical lines marking minor and major GCs if (features.showingGCs) { var { width, height } = ctx.canvas; ctx.strokeStyle = 'rgb(255,100,0)'; var idx = sampleIndex % numSamples; var gcCount = gcs[idx]; for (var i = 0; i < numSamples; i++) { idx = (sampleIndex + i) % numSamples; if (gcCount < gcs[idx]) { ctx.beginPath(); ctx.moveTo(this.xpos(idx), 0); ctx.lineTo(this.xpos(idx), this.layout.xAxisLabel_Y); ctx.stroke(); } gcCount = gcs[idx]; } ctx.strokeStyle = 'rgb(0,255,100)'; idx = sampleIndex % numSamples; gcCount = gcs[idx]; for (var i = 0; i < numSamples; i++) { idx = (sampleIndex + i) % numSamples; if (gcCount < minorGCs[idx]) { ctx.beginPath(); ctx.moveTo(this.xpos(idx), 0); ctx.lineTo(this.xpos(idx), 20); ctx.stroke(); } gcCount = minorGCs[idx]; } } ctx.fillStyle = 'rgb(255,0,0)'; if (worst) ctx.fillText(`${worst.toFixed(2)}ms`, this.xpos(worstpos) - 10, this.ypos(worst) - 14); // Mark and label the slowest frame ctx.beginPath(); var where = sampleIndex % numSamples; ctx.arc(this.xpos(where), this.ypos(delays[where]), 5, 0, Math.PI*2, true); ctx.fill(); ctx.fillStyle = 'rgb(0,0,0)'; this.drawAxisLabels('Time', 'Pause between frames (log scale)'); } function MemoryGraph(ctx) { Graph.call(this, ctx); this.worstEver = this.bestEver = performance.mozMemory.zone.gcBytes; this.limit = Math.max(this.worstEver, performance.mozMemory.zone.gcAllocTrigger); } MemoryGraph.prototype = Object.create(Graph.prototype); Object.defineProperty(MemoryGraph.prototype, 'constructor', { enumerable: false, value: MemoryGraph }); MemoryGraph.prototype.ypos = function (size) { var { height } = this.ctx.canvas; var range = this.limit - this.bestEver; var percent = (size - this.bestEver) / range; return (1 - percent) * height * 0.9 + 20; } MemoryGraph.prototype.drawHBar = function (size, label, color='rgb(150,150,150)') { var ctx = this.ctx; var y = this.ypos(size); ctx.fillStyle = color; ctx.strokeStyle = color; ctx.fillText(label, this.xpos(numSamples) + 4, y + 3); ctx.beginPath(); ctx.moveTo(this.xpos(0), y); ctx.lineTo(this.xpos(numSamples), y); ctx.stroke(); ctx.strokeStyle = 'rgb(0,0,0)'; ctx.fillStyle = 'rgb(0,0,0)'; } function format_gcBytes(bytes) { if (bytes < 4000) return `${bytes} bytes`; else if (bytes < 4e6) return `${(bytes / 1024).toFixed(2)} KB`; else if (bytes < 4e9) return `${(bytes / 1024 / 1024).toFixed(2)} MB`; else return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; }; MemoryGraph.prototype.draw = function () { var ctx = this.ctx; this.clear(); this.drawFrame(); var worst = 0, worstpos = 0; for (var i = 0; i < numSamples; i++) { if (gcBytes[i] >= worst) { worst = gcBytes[i]; worstpos = i; } if (gcBytes[i] < this.bestEver) { this.bestEver = gcBytes[i]; } } if (this.worstEver < worst) { this.worstEver = worst; this.limit = Math.max(this.worstEver, performance.mozMemory.zone.gcAllocTrigger); } this.drawHBar(this.bestEver, `${format_gcBytes(this.bestEver)} min`, '#00cf61'); this.drawHBar(this.worstEver, `${format_gcBytes(this.worstEver)} max`, '#cc1111'); this.drawHBar(performance.mozMemory.zone.gcAllocTrigger, `${format_gcBytes(performance.mozMemory.zone.gcAllocTrigger)} trigger`, '#cc11cc'); ctx.fillStyle = 'rgb(255,0,0)'; if (worst) ctx.fillText(format_gcBytes(worst), this.xpos(worstpos) - 10, this.ypos(worst) - 14); ctx.beginPath(); var where = sampleIndex % numSamples; ctx.arc(this.xpos(where), this.ypos(gcBytes[where]), 5, 0, Math.PI*2, true); ctx.fill(); ctx.beginPath(); for (var i = 0; i < numSamples; i++) { if (i == (sampleIndex + 1) % numSamples) ctx.moveTo(this.xpos(i), this.ypos(gcBytes[i])); else ctx.lineTo(this.xpos(i), this.ypos(gcBytes[i])); if (i == where) ctx.stroke(); } ctx.stroke(); this.drawAxisLabels('Time', 'Heap Memory Usage'); } function stopstart() { if (stopped) { window.requestAnimationFrame(handler); prev = performance.now(); start += prev - stopped; document.getElementById('stop').value = 'Pause'; stopped = 0; } else { document.getElementById('stop').value = 'Resume'; stopped = performance.now(); } } var previous = 0; function handler(timestamp) { if (stopped) return; if (testState === 'running' && (timestamp - testStart) > testDuration) end_test(timestamp); if (testState == 'running') document.getElementById("test-progress").textContent = ((testDuration - (timestamp - testStart))/1000).toFixed(1) + " sec"; activeTest.makeGarbage(activeTest.garbagePerFrame); var elt = document.getElementById('data'); var delay = timestamp - prev; prev = timestamp; // Take the histogram at 10us intervals so that we have enough resolution to capture. // a 16.66[666] target with adequate accuracy. update_histogram(gHistogram, Math.round(delay * 100)); var t = timestamp - start; var newIndex = Math.round(t / sampleTime); while (sampleIndex < newIndex) { sampleIndex++; var idx = sampleIndex % numSamples; delays[idx] = delay; if (features.trackingSizes) gcBytes[idx] = performance.mozMemory.gcBytes; if (features.showingGCs) { gcs[idx] = performance.mozMemory.gcNumber; minorGCs[idx] = performance.mozMemory.minorGCCount; } } latencyGraph.draw(); if (memoryGraph) memoryGraph.draw(); window.requestAnimationFrame(handler); } function summarize(arr) { if (arr.length == 0) return []; var result = []; var run_start = 0; var prev = arr[0]; for (var i = 1; i <= arr.length; i++) { if (i == arr.length || arr[i] != prev) { if (i == run_start + 1) { result.push(arr[i]); } else { result.push(prev + " x " + (i - run_start)); } run_start = i; } if (i != arr.length) prev = arr[i]; } return result; } function update_histogram(histogram, delay) { var current = histogram.has(delay) ? histogram.get(delay) : 0; histogram.set(delay, ++current); } function reset_draw_state() { for (var i = 0; i < numSamples; i++) delays[i] = 0; start = prev = performance.now(); sampleIndex = 0; } function onunload() { if (activeTest) activeTest.unload(); activeTest = undefined; } function onload() { // Load initial test duration. duration_changed(); // Load initial garbage size. garbage_total_changed(); garbage_per_frame_changed(); // Populate the test selection dropdown. var select = document.getElementById("test-selection"); for (var [name, test] of tests) { test.name = name; var option = document.createElement("option"); option.id = name; option.text = name; option.title = test.description; select.add(option); } // Load the initial test. change_active_test('noAllocation'); // Polyfill rAF. var requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame; window.requestAnimationFrame = requestAnimationFrame; // Acquire our canvas. var canvas = document.getElementById('graph'); latencyGraph = new LatencyGraph(canvas.getContext('2d')); if (!performance.mozMemory) { document.getElementById('memgraph-disabled').style.display = 'block'; document.getElementById('track-sizes-div').style.display = 'none'; } trackHeapSizes(document.getElementById('track-sizes').checked); // Start drawing. reset_draw_state(); window.requestAnimationFrame(handler); } function run_one_test() { start_test_cycle([activeTest.name]); } function run_all_tests() { start_test_cycle(tests.keys()); } function start_test_cycle(tests_to_run) { // Convert from an iterable to an array for pop. testQueue = []; for (var key of tests_to_run) testQueue.push(key); testState = 'running'; testStart = performance.now(); gHistogram.clear(); start_test(testQueue.shift()); reset_draw_state(); } function start_test(testName) { change_active_test(testName); console.log(`Running test: ${testName}`); document.getElementById("test-selection").value = testName; } function end_test(timestamp) { document.getElementById("test-progress").textContent = "(not running)"; report_test_result(activeTest, gHistogram); gHistogram.clear(); console.log(`Ending test ${activeTest.name}`); if (testQueue.length) { start_test(testQueue.shift()); testStart = timestamp; } else { testState = 'idle'; testStart = 0; } reset_draw_state(); } function report_test_result(test, histogram) { var resultList = document.getElementById('results-display'); var resultElem = document.createElement("div"); var score = compute_test_score(histogram); var sparks = compute_test_spark_histogram(histogram); var params = `(${format_units(test.garbagePerFrame)},${format_units(test.garbageTotal)})`; resultElem.innerHTML = `${score.toFixed(3)} ms/s : ${sparks} : ${test.name}${params} - ${test.description}`; resultList.appendChild(resultElem); } // Compute a score based on the total ms we missed frames by per second. function compute_test_score(histogram) { var score = 0; for (var [delay, count] of histogram) { delay = delay / 100; score += Math.abs((delay - 16.66) * count); } score = score / (testDuration / 1000); return Math.round(score * 1000) / 1000; } // Build a spark-lines histogram for the test results to show with the aggregate score. function compute_test_spark_histogram(histogram) { var ranges = [ [-99999999, 16.6], [16.6, 16.8], [16.8, 25], [25, 33.4], [33.4, 60], [60, 100], [100, 300], [300, 99999999], ]; var rescaled = new Map(); for (var [delay, count] of histogram) { delay = delay / 100; for (var i = 0; i < ranges.length; ++i) { var low = ranges[i][0]; var high = ranges[i][1]; if (low <= delay && delay < high) { update_histogram(rescaled, i); break; } } } var total = 0; for (var [i, count] of rescaled) total += count; var sparks = "▁▂▃▄▅▆▇█"; var colors = ['#aaaa00', '#007700', '#dd0000', '#ff0000', '#ff0000', '#ff0000', '#ff0000', '#ff0000']; var line = ""; for (var i = 0; i < ranges.length; ++i) { var amt = rescaled.has(i) ? rescaled.get(i) : 0; var spark = sparks.charAt(parseInt(amt/total*8)); line += `${spark}`; } return line; } function reload_active_test() { activeTest.unload(); activeTest.load(activeTest.garbageTotal); } function change_active_test(new_test_name) { if (activeTest) activeTest.unload(); activeTest = tests.get(new_test_name); if (!activeTest.garbagePerFrame) activeTest.garbagePerFrame = parse_units(activeTest.defaultGarbagePerFrame || globalDefaultGarbagePerFrame); if (!activeTest.garbageTotal) activeTest.garbageTotal = parse_units(activeTest.defaultGarbageTotal || globalDefaultGarbageTotal); document.getElementById("garbage-per-frame").value = format_units(activeTest.garbagePerFrame); document.getElementById("garbage-total").value = format_units(activeTest.garbageTotal); activeTest.load(activeTest.garbageTotal); } function duration_changed() { var durationInput = document.getElementById('test-duration'); testDuration = parseInt(durationInput.value) * 1000; console.log(`Updated test duration to: ${testDuration / 1000} seconds`); } function test_changed() { var select = document.getElementById("test-selection"); console.log(`Switching to test: ${select.value}`); change_active_test(select.value); gHistogram.clear(); reset_draw_state(); } function parse_units(v) { if (v.length == 0) return NaN; var lastChar = v[v.length - 1].toLowerCase(); if (!isNaN(parseFloat(lastChar))) return parseFloat(v); var units = parseFloat(v.substr(0, v.length - 1)); if (lastChar == "k") return units * 1e3; if (lastChar == "m") return units * 1e6; if (lastChar == "g") return units * 1e9; return NaN; } function format_units(n) { n = String(n); if (n.length > 9 && n.substr(-9) == "000000000") return n.substr(0, n.length - 9) + "G"; else if (n.length > 9 && n.substr(-6) == "000000") return n.substr(0, n.length - 6) + "M"; else if (n.length > 3 && n.substr(-3) == "000") return n.substr(0, n.length - 3) + "K"; else return String(n); } function garbage_total_changed() { var value = parse_units(document.getElementById('garbage-total').value); if (isNaN(value)) return; if (activeTest) { activeTest.garbageTotal = value; console.log(`Updated garbage-total to ${activeTest.garbageTotal} items`); reload_active_test(); } gHistogram.clear(); reset_draw_state(); } function garbage_per_frame_changed() { var value = parse_units(document.getElementById('garbage-per-frame').value); if (isNaN(value)) return; if (activeTest) { activeTest.garbagePerFrame = value; console.log(`Updated garbage-per-frame to ${activeTest.garbagePerFrame} items`); } } function trackHeapSizes(track) { features.trackingSizes = track; var canvas = document.getElementById('memgraph'); if (features.trackingSizes) { canvas.style.display = 'block'; memoryGraph = new MemoryGraph(canvas.getContext('2d')); } else { canvas.style.display = 'none'; memoryGraph = null; } }