diff --git a/src/benchmark/benchmark.css b/src/benchmark/benchmark.css
new file mode 100644
index 000000000..e843b5e94
--- /dev/null
+++ b/src/benchmark/benchmark.css
@@ -0,0 +1,7 @@
+@media (min-width: 960px) {
+ .profile-tables {
+ position: absolute;
+ top: 0;
+ left: 520px;
+ }
+}
diff --git a/src/benchmark/benchmark.js b/src/benchmark/benchmark.js
new file mode 100644
index 000000000..d8540404a
--- /dev/null
+++ b/src/benchmark/benchmark.js
@@ -0,0 +1,460 @@
+const Scratch = window.Scratch = window.Scratch || {};
+
+const ASSET_SERVER = 'https://cdn.assets.scratch.mit.edu/';
+const PROJECT_SERVER = 'https://cdn.projects.scratch.mit.edu/';
+
+const SLOW = .1;
+
+const loadProject = function () {
+ let id = location.hash.substring(1);
+ if (id.length < 1 || !isFinite(id)) {
+ id = '119615668';
+ }
+ Scratch.vm.downloadProjectId(id);
+};
+
+/**
+ * @param {Asset} asset - calculate a URL for this asset.
+ * @returns {string} a URL to download a project file.
+ */
+const getProjectUrl = function (asset) {
+ const assetIdParts = asset.assetId.split('.');
+ const assetUrlParts = [PROJECT_SERVER, 'internalapi/project/', assetIdParts[0], '/get/'];
+ if (assetIdParts[1]) {
+ assetUrlParts.push(assetIdParts[1]);
+ }
+ return assetUrlParts.join('');
+};
+
+/**
+ * @param {Asset} asset - calculate a URL for this asset.
+ * @returns {string} a URL to download a project asset (PNG, WAV, etc.)
+ */
+const getAssetUrl = function (asset) {
+ const assetUrlParts = [
+ ASSET_SERVER,
+ 'internalapi/asset/',
+ asset.assetId,
+ '.',
+ asset.dataFormat,
+ '/get/'
+ ];
+ return assetUrlParts.join('');
+};
+
+class LoadingProgress {
+ constructor (callback) {
+ this.total = 0;
+ this.complete = 0;
+ this.callback = callback;
+ }
+
+ on (storage) {
+ const _this = this;
+ const _load = storage.webHelper.load;
+ storage.webHelper.load = function (...args) {
+ const result = _load.call(this, ...args);
+ _this.total += 1;
+ _this.callback(_this);
+ result.then(() => {
+ _this.complete += 1;
+ _this.callback(_this);
+ });
+ return result;
+ };
+ }
+}
+
+class StatTable {
+ constructor ({table, keys, viewOf, isSlow}) {
+ this.table = table;
+ if (keys) {
+ this.keys = keys;
+ }
+ if (viewOf) {
+ this.viewOf = viewOf;
+ }
+ if (isSlow) {
+ this.isSlow = isSlow;
+ }
+ }
+
+ render () {
+ const table = this.table;
+ Array.from(table.children)
+ .forEach(node => table.removeChild(node));
+ const keys = this.keys();
+ for (const key of keys) {
+ this.viewOf(key).render({
+ table,
+ isSlow: frame => this.isSlow(key, frame)
+ });
+ }
+ }
+}
+
+class StatView {
+ constructor (name) {
+ this.name = name;
+ this.executions = 0;
+ this.selfTime = 0;
+ this.totalTime = 0;
+ }
+
+ update (selfTime, totalTime) {
+ this.executions++;
+ this.selfTime += selfTime;
+ this.totalTime += totalTime;
+ }
+
+ render ({table, isSlow}) {
+ const row = document.createElement('tr');
+ let cell = document.createElement('td');
+ cell.innerText = this.name;
+ row.appendChild(cell);
+
+ if (isSlow(this)) {
+ row.setAttribute('class', 'slow');
+ }
+
+ cell = document.createElement('td');
+ cell.innerText = (this.selfTime / 1000).toPrecision(3);
+ row.appendChild(cell);
+
+ cell = document.createElement('td');
+ cell.innerText = (this.totalTime / 1000).toPrecision(3);
+ row.appendChild(cell);
+
+ cell = document.createElement('td');
+ cell.innerText = this.executions;
+ row.appendChild(cell);
+
+ table.appendChild(row);
+ }
+}
+
+class RunningStats {
+ constructor (profiler) {
+ this.stepThreadsInnerId = profiler.idByName('Sequencer.stepThreads#inner');
+ this.blockFunctionId = profiler.idByName('blockFunction');
+ this.stpeThreadsId = profiler.idByName('Sequencer.stepThreads');
+
+ this.recordedTime = 0;
+ this.executed = {
+ steps: 0,
+ blocks: 0
+ };
+ }
+
+ update (id, selfTime, totalTime) {
+ if (id === this.stpeThreadsId) {
+ this.recordedTime += totalTime;
+ } else if (id === this.stepThreadsInnerId) {
+ this.executed.steps++;
+ } else if (id === this.blockFunctionId) {
+ this.executed.blocks++;
+ }
+ }
+}
+
+const WORK_TIME = 0.75;
+
+class RunningStatsView {
+ constructor ({runningStats, maxRecordedTime, dom}) {
+ this.recordedTimeDom =
+ dom.getElementsByClassName('profile-count-amount-recorded')[0];
+ this.stepsLoopedDom =
+ dom.getElementsByClassName('profile-count-steps-looped')[0];
+ this.blocksExecutedDom =
+ dom.getElementsByClassName('profile-count-blocks-executed')[0];
+
+ this.maxRecordedTime = maxRecordedTime;
+ this.maxWorkedTime = maxRecordedTime * WORK_TIME;
+ this.runningStats = runningStats;
+ }
+
+ render () {
+ const {
+ runningStats,
+ recordedTimeDom,
+ stepsLoopedDom,
+ blocksExecutedDom
+ } = this;
+ const {executed} = runningStats;
+ const fractionWorked = runningStats.recordedTime / this.maxWorkedTime;
+ recordedTimeDom.innerText = `${(fractionWorked * 100).toFixed(1)} %`;
+ stepsLoopedDom.innerText = executed.steps;
+ blocksExecutedDom.innerText = executed.blocks;
+ }
+}
+
+class Frames {
+ constructor (profiler) {
+ this.profiler = profiler;
+
+ this.frames = [];
+ }
+
+ update (id, selfTime, totalTime) {
+ if (!this.frames[id]) {
+ this.frames[id] = new StatView(this.profiler.nameById(id));
+ }
+ this.frames[id].update(selfTime, totalTime);
+ }
+}
+
+const frameOrder = [
+ 'blockFunction',
+ 'execute',
+ 'Sequencer.stepThread',
+ 'Sequencer.stepThreads#inner',
+ 'Sequencer.stepThreads',
+ 'RenderWebGL.draw',
+ 'Runtime._step'
+];
+
+class FramesTable extends StatTable {
+ constructor (options) {
+ super(options);
+
+ this.profiler = options.profiler;
+ this.frames = options.frames;
+ }
+
+ keys () {
+ const keys = Object.keys(this.frames.frames)
+ .map(id => this.profiler.nameById(Number(id)));
+ keys.sort((a, b) => frameOrder.indexOf(a) - frameOrder.indexOf(b));
+ return keys;
+ }
+
+ viewOf (key) {
+ return this.frames.frames[this.profiler.idByName(key)];
+ }
+
+ isSlow (key, frame) {
+ return ([
+ 'Sequencer.stepThreads',
+ 'Sequencer.stepThreads#inner',
+ 'Sequencer.stepThread',
+ 'execute'
+ ].indexOf(key) > 0 &&
+ frame.selfTime / frame.totalTime > SLOW);
+ }
+}
+
+class Opcodes {
+ constructor (profiler) {
+ this.blockFunctionId = profiler.idByName('blockFunction');
+
+ this.opcodes = {};
+ }
+
+ update (id, selfTime, totalTime, arg) {
+ if (id === this.blockFunctionId) {
+ if (!this.opcodes[arg]) {
+ this.opcodes[arg] = new StatView(arg);
+ }
+ this.opcodes[arg].update(selfTime, totalTime);
+ }
+ }
+}
+
+class OpcodeTable extends StatTable {
+ constructor (options) {
+ super(options);
+
+ this.profiler = options.profiler;
+ this.opcodes = options.opcodes;
+ this.frames = options.frames;
+ }
+
+ keys () {
+ const keys = Object.keys(this.opcodes.opcodes);
+ keys.sort();
+ return keys;
+ }
+
+ viewOf (key) {
+ return this.opcodes.opcodes[key];
+ }
+
+ isSlow (key) {
+ const blockFunctionTotalTime = this.frames.frames[this.profiler.idByName('blockFunction')].totalTime;
+ const rowTotalTime = this.opcodes.opcodes[key].totalTime;
+ const percentOfRun = rowTotalTime / blockFunctionTotalTime;
+ return percentOfRun > SLOW;
+ }
+}
+
+class ProfilerRun {
+ constructor ({vm, maxRecordedTime, warmUpTime}) {
+ this.vm = vm;
+ this.maxRecordedTime = maxRecordedTime;
+ this.warmUpTime = warmUpTime;
+
+ vm.runtime.enableProfiling();
+ const profiler = this.profiler = vm.runtime.profiler;
+ vm.runtime.profiler = null;
+
+ const runningStats = this.runningStats = new RunningStats(profiler);
+ const runningStatsView = this.runningStatsView = new RunningStatsView({
+ dom: document.getElementsByClassName('profile-count-group')[0],
+
+ runningStats,
+ maxRecordedTime: 6000
+ });
+
+ const frames = this.frames = new Frames(profiler);
+ this.frameTable = new FramesTable({
+ table: document
+ .getElementsByClassName('profile-count-frame-table')[0]
+ .getElementsByTagName('tbody')[0],
+
+ profiler,
+ frames
+ });
+
+ const opcodes = this.opcodes = new Opcodes(profiler);
+ this.opcodeTable = new OpcodeTable({
+ table: document
+ .getElementsByClassName('profile-count-opcode-table')[0]
+ .getElementsByTagName('tbody')[0],
+
+ profiler,
+ opcodes,
+ frames
+ });
+
+ const stepId = profiler.idByName('Runtime._step');
+ profiler.onFrame = ({id, selfTime, totalTime, arg}) => {
+ if (id === stepId) {
+ runningStatsView.render();
+ }
+ runningStats.update(id, selfTime, totalTime, arg);
+ opcodes.update(id, selfTime, totalTime, arg);
+ frames.update(id, selfTime, totalTime, arg);
+ };
+ }
+
+ run () {
+ loadProject();
+
+ this.vm.on('workspaceUpdate', () => {
+ setTimeout(() => {
+ this.vm.greenFlag();
+ }, 100);
+ setTimeout(() => {
+ this.vm.runtime.profiler = this.profiler;
+ }, 100 + this.warmUpTime);
+ setTimeout(() => {
+ this.vm.stopAll();
+ clearTimeout(this.vm.runtime._steppingInterval);
+ this.vm.runtime.profiler = null;
+
+ this.frameTable.render();
+ this.opcodeTable.render();
+ }, 100 + this.warmUpTime + this.maxRecordedTime);
+ });
+ }
+}
+
+window.onload = function () {
+ // Lots of global variables to make debugging easier
+ // Instantiate the VM.
+ const vm = new window.VirtualMachine();
+ Scratch.vm = vm;
+
+ vm.setTurboMode(true);
+
+ const storage = new ScratchStorage(); /* global ScratchStorage */
+ const AssetType = storage.AssetType;
+ storage.addWebSource([AssetType.Project], getProjectUrl);
+ storage.addWebSource([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl);
+ vm.attachStorage(storage);
+
+ new LoadingProgress(progress => {
+ document.getElementsByClassName('loading-total')[0]
+ .innerText = progress.total;
+ document.getElementsByClassName('loading-complete')[0]
+ .innerText = progress.complete;
+ }).on(storage);
+
+ new ProfilerRun({
+ vm,
+ warmUpTime: 4000,
+ maxRecordedTime: 6000
+ }).run();
+
+ // Instantiate the renderer and connect it to the VM.
+ const canvas = document.getElementById('scratch-stage');
+ const renderer = new window.RenderWebGL(canvas);
+ Scratch.renderer = renderer;
+ vm.attachRenderer(renderer);
+ const audioEngine = new window.AudioEngine();
+ vm.attachAudioEngine(audioEngine);
+
+ // Feed mouse events as VM I/O events.
+ document.addEventListener('mousemove', e => {
+ const rect = canvas.getBoundingClientRect();
+ const coordinates = {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top,
+ canvasWidth: rect.width,
+ canvasHeight: rect.height
+ };
+ Scratch.vm.postIOData('mouse', coordinates);
+ });
+ canvas.addEventListener('mousedown', e => {
+ const rect = canvas.getBoundingClientRect();
+ const data = {
+ isDown: true,
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top,
+ canvasWidth: rect.width,
+ canvasHeight: rect.height
+ };
+ Scratch.vm.postIOData('mouse', data);
+ e.preventDefault();
+ });
+ canvas.addEventListener('mouseup', e => {
+ const rect = canvas.getBoundingClientRect();
+ const data = {
+ isDown: false,
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top,
+ canvasWidth: rect.width,
+ canvasHeight: rect.height
+ };
+ Scratch.vm.postIOData('mouse', data);
+ e.preventDefault();
+ });
+
+ // Feed keyboard events as VM I/O events.
+ document.addEventListener('keydown', e => {
+ // Don't capture keys intended for Blockly inputs.
+ if (e.target !== document && e.target !== document.body) {
+ return;
+ }
+ Scratch.vm.postIOData('keyboard', {
+ keyCode: e.keyCode,
+ isDown: true
+ });
+ e.preventDefault();
+ });
+ document.addEventListener('keyup', e => {
+ // Always capture up events,
+ // even those that have switched to other targets.
+ Scratch.vm.postIOData('keyboard', {
+ keyCode: e.keyCode,
+ isDown: false
+ });
+ // E.g., prevent scroll.
+ if (e.target !== document && e.target !== document.body) {
+ e.preventDefault();
+ }
+ });
+
+ // Run threads
+ vm.start();
+};
diff --git a/src/benchmark/index.html b/src/benchmark/index.html
new file mode 100644
index 000000000..7ddc98dc3
--- /dev/null
+++ b/src/benchmark/index.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ Scratch VM Benchmark
+
+
+
+
+ Scratch VM Benchmark
+
+
+
+ 0 / 0
+
+
+
+
+ ...
+
+
+
+ ...
+
+
+
+ ...
+
+
+
+
+
+
+ Frame | Self Time | Total Time | Executions |
+
+
+ |
+
+
+
+
+
+
+ opcode | Self Time | Total Time | Executions |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webpack.config.js b/webpack.config.js
index f2d4c880c..6b32c6d83 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -130,5 +130,68 @@ module.exports = [
from: 'src/playground'
}])
])
+ }),
+ // Benchmark
+ defaultsDeep({}, base, {
+ target: 'web',
+ entry: {
+ 'scratch-vm': './src/index.js',
+ 'vendor': [
+ // FPS counter
+ 'stats.js/build/stats.min.js',
+ // Syntax highlighter
+ 'highlightjs/highlight.pack.min.js',
+ // Scratch Blocks
+ 'scratch-blocks/dist/vertical.js',
+ // Audio
+ 'scratch-audio',
+ // Renderer
+ 'scratch-render'
+ ]
+ },
+ output: {
+ path: path.resolve(__dirname, 'benchmark'),
+ filename: '[name].js'
+ },
+ module: {
+ rules: base.module.rules.concat([
+ {
+ test: require.resolve('./src/index.js'),
+ loader: 'expose-loader?VirtualMachine'
+ },
+ {
+ test: require.resolve('stats.js/build/stats.min.js'),
+ loader: 'script-loader'
+ },
+ {
+ test: require.resolve('highlightjs/highlight.pack.min.js'),
+ loader: 'script-loader'
+ },
+ {
+ test: require.resolve('scratch-blocks/dist/vertical.js'),
+ loader: 'expose-loader?Blockly'
+ },
+ {
+ test: require.resolve('scratch-audio'),
+ loader: 'expose-loader?AudioEngine'
+ },
+ {
+ test: require.resolve('scratch-render'),
+ loader: 'expose-loader?RenderWebGL'
+ }
+ ])
+ },
+ plugins: base.plugins.concat([
+ new CopyWebpackPlugin([{
+ from: 'node_modules/scratch-blocks/media',
+ to: 'media'
+ }, {
+ from: 'node_modules/highlightjs/styles/zenburn.css'
+ }, {
+ from: 'node_modules/scratch-storage/dist/web'
+ }, {
+ from: 'src/benchmark'
+ }])
+ ])
})
];