diff --git a/bench/.gitignore b/bench/.gitignore index 5c3fe88..6628455 100644 --- a/bench/.gitignore +++ b/bench/.gitignore @@ -1,7 +1 @@ -/minify-html-bench/Cargo.lock -/minify-html-bench/target/ -/min/ -/results*/ -node_modules/ -package-lock.json -perf.data* +/results/ diff --git a/bench/.nvmrc b/bench/.nvmrc deleted file mode 100644 index f599e28..0000000 --- a/bench/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -10 diff --git a/bench/bench.sh b/bench/bench.sh index abb2910..8d9b527 100755 --- a/bench/bench.sh +++ b/bench/bench.sh @@ -7,10 +7,14 @@ for i in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do echo performance | sudo dd status=none of="$i" done -node sizes.js -# We need sudo to use `nice` but want to keep using current `node`, so use explicit path in case sudo decides to ignore PATH. -node_path="$(which node)" -echo "Using Node.js at $node_path" -sudo --preserve-env=HTML_ONLY nice -n -20 taskset -c 1 "$node_path" speeds.js -sudo chown -R "$USER:$USER" results -node graph.js +results_dir="$PWD/results" +input_dir="$PWD/inputs" +iterations=100 + +pushd runners +for r in *; do + pushd "$r" + ./build + sudo --preserve-env=MHB_HTML_ONLY,PATH MHB_ITERATIONS=$iterations MHB_INPUT_DIR="$input_dir" nice -n -20 taskset -c 1 ./run | tee "$results_dir/$r.json" + popd +done diff --git a/bench/build.sh b/bench/build.sh deleted file mode 100755 index e18de76..0000000 --- a/bench/build.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -set -e - -pushd "$(dirname "$0")" - -pushd ../nodejs -npm run build -popd - -pushd minify-html-bench -cargo build --release -popd - -popd diff --git a/bench/fetch.js b/bench/fetch.js index 1cd1032..52820bb 100644 --- a/bench/fetch.js +++ b/bench/fetch.js @@ -1,54 +1,71 @@ -const {promises: fs} = require('fs'); -const request = require('request-promise-native'); -const path = require('path'); +const { promises: fs } = require("fs"); +const childProcess = require("child_process"); +const path = require("path"); const tests = { - "Amazon": "https://www.amazon.com/", - "BBC": "https://www.bbc.co.uk/", - "Bootstrap": "https://getbootstrap.com/docs/3.4/css/", - "Bing": "https://www.bing.com/", + Amazon: "https://www.amazon.com/", + BBC: "https://www.bbc.co.uk/", + Bootstrap: "https://getbootstrap.com/docs/3.4/css/", + Bing: "https://www.bing.com/", "Coding Horror": "https://blog.codinghorror.com/", "ECMA-262": "https://www.ecma-international.org/ecma-262/10.0/index.html", - "Google": "https://www.google.com/", + Google: "https://www.google.com/", "Hacker News": "https://news.ycombinator.com/", "NY Times": "https://www.nytimes.com/", - "Reddit": "https://www.reddit.com/", + Reddit: "https://www.reddit.com/", "Stack Overflow": "https://www.stackoverflow.com/", - "Twitter": "https://twitter.com/", - "Wikipedia": "https://en.wikipedia.org/wiki/Soil", + Twitter: "https://twitter.com/", + Wikipedia: "https://en.wikipedia.org/wiki/Soil", }; -const fetchTest = async (name, url) => { - const html = await request({ - url, - gzip: true, - headers: { - 'Accept': '*/*', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; rv:71.0) Gecko/20100101 Firefox/71.0', - }, +const fetchTest = (name, url) => + new Promise((resolve, reject) => { + // Use curl to follow redirects without needing a Node.js library. + childProcess.execFile( + "curl", + [ + "-H", + `User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; rv:71.0) Gecko/20100101 Firefox/71.0`, + "-H", + "Accept: */*", + "-fLSs", + url, + ], + (error, stdout, stderr) => { + if (error) { + return reject(error); + } + if (stderr) { + return reject(new Error(`stderr: ${stderr}`)); + } + resolve([name, stdout]); + } + ); }); - console.log(`Fetched ${name}`); - return [name, html]; -}; (async () => { - const existing = await fs.readdir(path.join(__dirname, 'tests')); - await Promise.all(existing.map(e => fs.unlink(path.join(__dirname, 'tests', e)))); + const existing = await fs.readdir(path.join(__dirname, "tests")); + await Promise.all( + existing.map((e) => fs.unlink(path.join(__dirname, "tests", e))) + ); // Format after fetching as formatting is synchronous and can take so long that connections get dropped by server due to inactivity. - for (const [name, html] of await Promise.all(Object.entries(tests).map(([name, url]) => fetchTest(name, url)))) { + for (const [name, html] of await Promise.all( + Object.entries(tests).map(([name, url]) => fetchTest(name, url)) + )) { // Apply some fixes to HTML. const fixed = html // Fix early termination of conditional comment in Amazon. - .replace('-->\n', '\n') + .replace("-->\n", "\n") // Fix closing of void tag in Amazon. - .replace(/><\/hr>/g, '/>') + .replace(/><\/hr>/g, "/>") // Fix extra '' in BBC. - .replace('', '') + .replace( + "", + "" + ) // Fix broken attribute value in Stack Overflow. - .replace('height=151"', 'height="151"') - ; - await fs.writeFile(path.join(__dirname, 'tests', name), fixed); + .replace('height=151"', 'height="151"'); + await fs.writeFile(path.join(__dirname, "tests", name), fixed); } -})() - .catch(console.error); +})().catch(console.error); diff --git a/bench/graph.js b/bench/graph.js index 4c34c99..84a7f1b 100644 --- a/bench/graph.js +++ b/bench/graph.js @@ -1,30 +1,30 @@ -const results = require('./results'); -const https = require('https'); +const results = require("./results"); +const https = require("https"); const colours = { - 'minify-html': '#041f60', - '@minify-html/js': '#1f77b4', - 'minimize': '#ff7f0e', - 'html-minifier': '#2ca02c', + "minify-html": "#041f60", + "@minify-html/js": "#1f77b4", + minimize: "#ff7f0e", + "html-minifier": "#2ca02c", }; -const COLOUR_SPEED_PRIMARY = '#2e61bd'; -const COLOUR_SPEED_SECONDARY = 'rgb(188, 188, 188)'; -const COLOUR_SIZE_PRIMARY = '#64acce'; -const COLOUR_SIZE_SECONDARY = 'rgb(224, 224, 224)'; +const COLOUR_SPEED_PRIMARY = "#2e61bd"; +const COLOUR_SPEED_SECONDARY = "rgb(188, 188, 188)"; +const COLOUR_SIZE_PRIMARY = "#64acce"; +const COLOUR_SIZE_SECONDARY = "rgb(224, 224, 224)"; const breakdownChartOptions = (title) => ({ options: { legend: { display: true, labels: { - fontColor: '#000', + fontColor: "#000", }, }, title: { display: true, text: title, - fontColor: '#333', + fontColor: "#333", fontSize: 24, }, scales: { @@ -32,10 +32,10 @@ const breakdownChartOptions = (title) => ({ { barPercentage: 0.25, gridLines: { - color: '#e2e2e2', + color: "#e2e2e2", }, ticks: { - fontColor: '#666', + fontColor: "#666", fontSize: 20, }, }, @@ -43,10 +43,10 @@ const breakdownChartOptions = (title) => ({ yAxes: [ { gridLines: { - color: '#ccc', + color: "#ccc", }, ticks: { - fontColor: '#666', + fontColor: "#666", fontSize: 20, }, }, @@ -59,7 +59,7 @@ const axisLabel = (fontColor, labelString) => ({ display: true, fontColor, fontSize: 24, - fontStyle: 'bold', + fontStyle: "bold", labelString, padding: 16, }); @@ -76,31 +76,31 @@ const combinedChartOptions = () => ({ display: false, }, ticks: { - fontColor: '#555', + fontColor: "#555", fontSize: 24, }, }, ], yAxes: [ { - id: 'y1', - type: 'linear', - scaleLabel: axisLabel(COLOUR_SPEED_PRIMARY, 'Performance'), - position: 'left', + id: "y1", + type: "linear", + scaleLabel: axisLabel(COLOUR_SPEED_PRIMARY, "Performance"), + position: "left", ticks: { callback: "$$$_____REPLACE_WITH_TICK_CALLBACK_____$$$", fontColor: COLOUR_SPEED_PRIMARY, fontSize: 24, }, gridLines: { - color: '#eee', + color: "#eee", }, }, { - id: 'y2', - type: 'linear', - scaleLabel: axisLabel(COLOUR_SIZE_PRIMARY, 'Average size reduction'), - position: 'right', + id: "y2", + type: "linear", + scaleLabel: axisLabel(COLOUR_SIZE_PRIMARY, "Average size reduction"), + position: "right", ticks: { callback: "$$$_____REPLACE_WITH_TICK_CALLBACK_____$$$", fontColor: COLOUR_SIZE_PRIMARY, @@ -108,90 +108,116 @@ const combinedChartOptions = () => ({ }, gridLines: { display: false, - } + }, }, ], }, }, }); -const renderChart = (cfg) => new Promise((resolve, reject) => { - const req = https.request('https://quickchart.io/chart', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, +const renderChart = (cfg) => + new Promise((resolve, reject) => { + const req = https.request("https://quickchart.io/chart", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + req.on("error", reject); + req.on("response", (res) => { + const err = res.headers["x-quickchart-error"]; + if (res.statusCode < 200 || res.statusCode > 299 || err) { + return reject(new Error(err || `Status ${res.statusCode}`)); + } + const chunks = []; + res.on("error", reject); + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve(Buffer.concat(chunks))); + }); + req.end( + JSON.stringify({ + backgroundColor: "white", + chart: JSON.stringify(cfg).replaceAll( + '"$$$_____REPLACE_WITH_TICK_CALLBACK_____$$$"', + "function(value) {return Math.round(value * 10000) / 100 + '%';}" + ), + width: 1333, + height: 768, + format: "png", + }) + ); }); - req.on('error', reject); - req.on('response', res => { - const err = res.headers['x-quickchart-error']; - if (res.statusCode < 200 || res.statusCode > 299 || err) { - return reject(new Error(err || `Status ${res.statusCode}`)); - } - const chunks = []; - res.on('error', reject); - res.on('data', c => chunks.push(c)); - res.on('end', () => resolve(Buffer.concat(chunks))); - }); - req.end(JSON.stringify({ - backgroundColor: 'white', - chart: JSON.stringify(cfg).replaceAll('"$$$_____REPLACE_WITH_TICK_CALLBACK_____$$$"', "function(value) {return Math.round(value * 10000) / 100 + '%';}"), - width: 1333, - height: 768, - format: 'png', - })); -}); (async () => { - const averageSpeeds = results.getSpeedResults().getAverageRelativeSpeedPerMinifier('@minify-html/js'); - const averageSizes = results.getSizeResults().getAverageRelativeSizePerMinifier(); + const averageSpeeds = results + .getSpeedResults() + .getAverageRelativeSpeedPerMinifier("@minify-html/js"); + const averageSizes = results + .getSizeResults() + .getAverageRelativeSizePerMinifier(); const averageLabels = ["minimize", "html-minifier", "@minify-html/js"]; - results.writeAverageCombinedGraph(await renderChart({ - type: 'bar', - data: { - labels: averageLabels, - datasets: [ - { - yAxisID: 'y1', - backgroundColor: averageLabels.map((n) => n === "@minify-html/js" ? COLOUR_SPEED_PRIMARY : COLOUR_SPEED_SECONDARY), - data: averageLabels.map((n) => averageSpeeds.get(n)), - }, - { - yAxisID: 'y2', - backgroundColor: averageLabels.map((n) => n === "@minify-html/js" ? COLOUR_SIZE_PRIMARY : COLOUR_SIZE_SECONDARY), - data: averageLabels.map((n) => 1 - averageSizes.get(n)), - }, - ], - }, - ...combinedChartOptions(), - })); + results.writeAverageCombinedGraph( + await renderChart({ + type: "bar", + data: { + labels: averageLabels, + datasets: [ + { + yAxisID: "y1", + backgroundColor: averageLabels.map((n) => + n === "@minify-html/js" + ? COLOUR_SPEED_PRIMARY + : COLOUR_SPEED_SECONDARY + ), + data: averageLabels.map((n) => averageSpeeds.get(n)), + }, + { + yAxisID: "y2", + backgroundColor: averageLabels.map((n) => + n === "@minify-html/js" + ? COLOUR_SIZE_PRIMARY + : COLOUR_SIZE_SECONDARY + ), + data: averageLabels.map((n) => 1 - averageSizes.get(n)), + }, + ], + }, + ...combinedChartOptions(), + }) + ); - const speeds = results.getSpeedResults().getRelativeFileSpeedsPerMinifier('@minify-html/js'); - results.writeSpeedsGraph(await renderChart({ - type: 'bar', - data: { - labels: speeds[0][1].map(([n]) => n), - datasets: speeds.map(([minifier, fileSpeeds]) => ({ - label: minifier, - backgroundColor: colours[minifier], - data: fileSpeeds.map(([_, speed]) => speed), - })), - }, - ...breakdownChartOptions('Operations per second (higher is better)'), - })); + const speeds = results + .getSpeedResults() + .getRelativeFileSpeedsPerMinifier("@minify-html/js"); + results.writeSpeedsGraph( + await renderChart({ + type: "bar", + data: { + labels: speeds[0][1].map(([n]) => n), + datasets: speeds.map(([minifier, fileSpeeds]) => ({ + label: minifier, + backgroundColor: colours[minifier], + data: fileSpeeds.map(([_, speed]) => speed), + })), + }, + ...breakdownChartOptions("Operations per second (higher is better)"), + }) + ); const sizes = results.getSizeResults().getRelativeFileSizesPerMinifier(); - results.writeSizesGraph(await renderChart({ - type: 'bar', - data: { - labels: sizes[0][1].map(([n]) => n), - datasets: sizes.map(([minifier, fileSizes]) => ({ - label: minifier, - backgroundColor: colours[minifier], - data: fileSizes.map(([_, size]) => size), - })), - }, - ...breakdownChartOptions('Minified size (lower is better)'), - })); + results.writeSizesGraph( + await renderChart({ + type: "bar", + data: { + labels: sizes[0][1].map(([n]) => n), + datasets: sizes.map(([minifier, fileSizes]) => ({ + label: minifier, + backgroundColor: colours[minifier], + data: fileSizes.map(([_, size]) => size), + })), + }, + ...breakdownChartOptions("Minified size (lower is better)"), + }) + ); })(); diff --git a/bench/tests/Amazon b/bench/inputs/Amazon similarity index 100% rename from bench/tests/Amazon rename to bench/inputs/Amazon diff --git a/bench/tests/BBC b/bench/inputs/BBC similarity index 100% rename from bench/tests/BBC rename to bench/inputs/BBC diff --git a/bench/tests/Bing b/bench/inputs/Bing similarity index 100% rename from bench/tests/Bing rename to bench/inputs/Bing diff --git a/bench/tests/Bootstrap b/bench/inputs/Bootstrap similarity index 100% rename from bench/tests/Bootstrap rename to bench/inputs/Bootstrap diff --git a/bench/tests/Coding Horror b/bench/inputs/Coding Horror similarity index 100% rename from bench/tests/Coding Horror rename to bench/inputs/Coding Horror diff --git a/bench/tests/ECMA-262 b/bench/inputs/ECMA-262 similarity index 100% rename from bench/tests/ECMA-262 rename to bench/inputs/ECMA-262 diff --git a/bench/tests/Google b/bench/inputs/Google similarity index 100% rename from bench/tests/Google rename to bench/inputs/Google diff --git a/bench/tests/Hacker News b/bench/inputs/Hacker News similarity index 100% rename from bench/tests/Hacker News rename to bench/inputs/Hacker News diff --git a/bench/tests/NY Times b/bench/inputs/NY Times similarity index 100% rename from bench/tests/NY Times rename to bench/inputs/NY Times diff --git a/bench/tests/Reddit b/bench/inputs/Reddit similarity index 100% rename from bench/tests/Reddit rename to bench/inputs/Reddit diff --git a/bench/tests/Stack Overflow b/bench/inputs/Stack Overflow similarity index 100% rename from bench/tests/Stack Overflow rename to bench/inputs/Stack Overflow diff --git a/bench/tests/Twitter b/bench/inputs/Twitter similarity index 100% rename from bench/tests/Twitter rename to bench/inputs/Twitter diff --git a/bench/tests/Wikipedia b/bench/inputs/Wikipedia similarity index 100% rename from bench/tests/Wikipedia rename to bench/inputs/Wikipedia diff --git a/bench/minifiers.js b/bench/minifiers.js deleted file mode 100644 index cea099a..0000000 --- a/bench/minifiers.js +++ /dev/null @@ -1,90 +0,0 @@ -const esbuild = require('esbuild'); -const htmlMinifier = require('html-minifier'); -const minifyHtml = require('@minify-html/js'); -const minimize = require('minimize'); - -const testJsAndCssMinification = process.env.HTML_ONLY !== '1'; - -const jsMime = new Set([ - undefined, - 'application/ecmascript', - 'application/javascript', - 'application/x-ecmascript', - 'application/x-javascript', - 'text/ecmascript', - 'text/javascript', - 'text/javascript1.0', - 'text/javascript1.1', - 'text/javascript1.2', - 'text/javascript1.3', - 'text/javascript1.4', - 'text/javascript1.5', - 'text/jscript', - 'text/livescript', - 'text/x-ecmascript', - 'text/x-javascript', -]); - -const minifyHtmlCfg = minifyHtml.createConfiguration({ - minify_js: testJsAndCssMinification, - minify_css: testJsAndCssMinification, -}); - -const esbuildCss = code => esbuild.transformSync(code, { - loader: "css", - minify: true, -}).code; -const esbuildJs = code => esbuild.transformSync(code, { - loader: "js", - minify: true, -}).code.replace(/<\/script/g, "<\\/script"); - -const htmlMinifierCfg = { - collapseBooleanAttributes: true, - collapseInlineTagWhitespace: true, - collapseWhitespace: true, - // minify-html can do context-aware whitespace removal, which is safe when configured correctly to match how whitespace is used in the document. - // html-minifier cannot, so whitespace must be collapsed conservatively. - // Alternatively, minify-html can also be made to remove whitespace regardless of context. - conservativeCollapse: true, - customEventAttributes: [], - decodeEntities: true, - ignoreCustomComments: [], - ignoreCustomFragments: [/<\?[\s\S]*?\?>/], - minifyCSS: testJsAndCssMinification && esbuildCss, - minifyJS: testJsAndCssMinification && esbuildJs, - processConditionalComments: true, - removeAttributeQuotes: true, - removeComments: true, - removeEmptyAttributes: true, - removeOptionalTags: true, - removeRedundantAttributes: true, - removeScriptTypeAttributes: true, - removeStyleLinkTypeAttributes: true, - removeTagWhitespace: true, - useShortDoctype: true, -}; - -module.exports = { - '@minify-html/js': (_, buffer) => minifyHtml.minify(buffer, minifyHtmlCfg), - 'html-minifier': content => htmlMinifier.minify(content, htmlMinifierCfg), - 'minimize': testJsAndCssMinification - ? (content) => new minimize({ - plugins: [ - { - id: 'esbuild', - element: (node, next) => { - if (node.type === 'text' && node.parent) { - if (node.parent.type === 'script' && jsMime.has(node.parent.attribs.type)) { - node.data = esbuildJs(node.data); - } else if (node.parent.type === 'style') { - node.data = esbuildCss(node.data); - } - } - next(); - }, - }, - ], - }).parse(content) - : content => new minimize().parse(content), -}; diff --git a/bench/minify-html-bench/src/main.rs b/bench/minify-html-bench/src/main.rs deleted file mode 100644 index 9120eaa..0000000 --- a/bench/minify-html-bench/src/main.rs +++ /dev/null @@ -1,34 +0,0 @@ -use minify_html::{Cfg, minify}; -use std::fs; -use std::io::{stdout}; -use std::time::Instant; -use structopt::StructOpt; - -#[derive(StructOpt)] -struct Args { - #[structopt(long, parse(from_os_str))] - tests: std::path::PathBuf, - #[structopt(long)] - iterations: usize, -} - -fn main() { - let args = Args::from_args(); - let tests = fs::read_dir(args.tests).unwrap().map(|d| d.unwrap()); - - let mut results: Vec<(String, f64)> = Vec::new(); - - for t in tests { - let source = fs::read(t.path()).unwrap(); - let start = Instant::now(); - for _ in 0..args.iterations { - let data = source.to_vec(); - minify(&data, &Cfg::new()); - }; - let elapsed = start.elapsed().as_secs_f64(); - let ops = args.iterations as f64 / elapsed; - results.push((t.file_name().to_str().unwrap().to_string(), ops)); - }; - - serde_json::to_writer(stdout(), &results).unwrap(); -} diff --git a/bench/package.json b/bench/package.json deleted file mode 100644 index e6a6ebd..0000000 --- a/bench/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "private": true, - "dependencies": { - "@minify-html/js": "file:../nodejs", - "benchmark": "2.1.4", - "esbuild": "^0.12.18", - "html-minifier": "4.0.0", - "minimize": "2.2.0", - "minimist": "^1.2.5", - "request": "^2.88.2", - "request-promise-native": "^1.0.9" - }, - "scripts": { - "start": "node bench.js" - } -} diff --git a/bench/results.js b/bench/results.js index 21d814a..7680812 100644 --- a/bench/results.js +++ b/bench/results.js @@ -1,21 +1,21 @@ -const minifiers = require('./minifiers'); -const tests = require('./tests'); -const {join} = require('path'); -const {mkdirSync, readFileSync, writeFileSync} = require('fs'); +const minifiers = require("./minifiers"); +const tests = require("./tests"); +const { join } = require("path"); +const { mkdirSync, readFileSync, writeFileSync } = require("fs"); -const RESULTS_DIR = join(__dirname, 'results'); -const SPEEDS_JSON = join(RESULTS_DIR, 'speeds.json'); -const SPEEDS_GRAPH = join(RESULTS_DIR, 'speeds.png'); -const AVERAGE_COMBINED_GRAPH = join(RESULTS_DIR, 'average-combined.png'); -const AVERAGE_SPEEDS_GRAPH = join(RESULTS_DIR, 'average-speeds.png'); -const SIZES_JSON = join(RESULTS_DIR, 'sizes.json'); -const SIZES_GRAPH = join(RESULTS_DIR, 'sizes.png'); -const AVERAGE_SIZES_GRAPH = join(RESULTS_DIR, 'average-sizes.png'); +const RESULTS_DIR = join(__dirname, "results"); +const SPEEDS_JSON = join(RESULTS_DIR, "speeds.json"); +const SPEEDS_GRAPH = join(RESULTS_DIR, "speeds.png"); +const AVERAGE_COMBINED_GRAPH = join(RESULTS_DIR, "average-combined.png"); +const AVERAGE_SPEEDS_GRAPH = join(RESULTS_DIR, "average-speeds.png"); +const SIZES_JSON = join(RESULTS_DIR, "sizes.json"); +const SIZES_GRAPH = join(RESULTS_DIR, "sizes.png"); +const AVERAGE_SIZES_GRAPH = join(RESULTS_DIR, "average-sizes.png"); const minifierNames = Object.keys(minifiers); -const testNames = tests.map(t => t.name); +const testNames = tests.map((t) => t.name); -mkdirSync(RESULTS_DIR, {recursive: true}); +mkdirSync(RESULTS_DIR, { recursive: true }); module.exports = { writeSpeedResults(speeds) { @@ -40,50 +40,58 @@ module.exports = { writeFileSync(SIZES_GRAPH, data); }, getSpeedResults() { - const data = JSON.parse(readFileSync(SPEEDS_JSON, 'utf8')); + const data = JSON.parse(readFileSync(SPEEDS_JSON, "utf8")); return { // Get minifier-speed pairs. getAverageRelativeSpeedPerMinifier(baselineMinifier) { - return new Map(minifierNames.map(minifier => [ - minifier, - testNames - // Get operations per second for each test. - .map(test => data[test][minifier] / data[test][baselineMinifier]) - // Sum all test operations per second. - .reduce((sum, c) => sum + c) - // Divide by tests count to get average operations per second. - / testNames.length, - ])); + return new Map( + minifierNames.map((minifier) => [ + minifier, + testNames + // Get operations per second for each test. + .map( + (test) => data[test][minifier] / data[test][baselineMinifier] + ) + // Sum all test operations per second. + .reduce((sum, c) => sum + c) / + // Divide by tests count to get average operations per second. + testNames.length, + ]) + ); }, // Get minifier-speeds pairs. getRelativeFileSpeedsPerMinifier(baselineMinifier) { - return minifierNames.map(minifier => [ + return minifierNames.map((minifier) => [ minifier, - testNames.map(test => [test, data[test][minifier] / data[test][baselineMinifier]]), + testNames.map((test) => [ + test, + data[test][minifier] / data[test][baselineMinifier], + ]), ]); }, }; }, getSizeResults() { - const data = JSON.parse(readFileSync(SIZES_JSON, 'utf8')); + const data = JSON.parse(readFileSync(SIZES_JSON, "utf8")); return { // Get minifier-size pairs. getAverageRelativeSizePerMinifier() { - return new Map(minifierNames.map(minifier => [ - minifier, - testNames - .map(test => data[test][minifier].relative) - .reduce((sum, c) => sum + c) - / testNames.length, - ])); + return new Map( + minifierNames.map((minifier) => [ + minifier, + testNames + .map((test) => data[test][minifier].relative) + .reduce((sum, c) => sum + c) / testNames.length, + ]) + ); }, // Get minifier-sizes pairs. getRelativeFileSizesPerMinifier() { - return minifierNames.map(minifier => [ + return minifierNames.map((minifier) => [ minifier, - testNames.map(test => [test, data[test][minifier].relative]), + testNames.map((test) => [test, data[test][minifier].relative]), ]); }, }; diff --git a/bench/runners/README.md b/bench/runners/README.md new file mode 100644 index 0000000..36eb196 --- /dev/null +++ b/bench/runners/README.md @@ -0,0 +1,9 @@ +# Benchmark runners + +- Each directory should have an executable `./build` and `./run`. +- The runners should use the following environment variables: + - `MHB_ITERATIONS`: times to run each input. + - `MHB_INPUT_DIR`: path to directory containing inputs. Files should be read from this directory and used as the inputs. + - `MHB_HTML_ONLY`: if set to `1`, `minify_css` and `minify_js` should be disabled. +- The output should be a JSON array of pairs, where each pair represents the input name and execution time in seconds (as a floating point value). + - The execution time should be measured using high-precision monotonic system clocks where possible. diff --git a/bench/runners/html-minifier/.gitignore b/bench/runners/html-minifier/.gitignore new file mode 100644 index 0000000..7578d1f --- /dev/null +++ b/bench/runners/html-minifier/.gitignore @@ -0,0 +1,2 @@ +/package-lock.json +node_modules/ diff --git a/bench/runners/html-minifier/build b/bench/runners/html-minifier/build new file mode 100755 index 0000000..1c693da --- /dev/null +++ b/bench/runners/html-minifier/build @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +npm i diff --git a/bench/runners/html-minifier/index.js b/bench/runners/html-minifier/index.js new file mode 100644 index 0000000..e3f2a55 --- /dev/null +++ b/bench/runners/html-minifier/index.js @@ -0,0 +1,57 @@ +const esbuild = require("esbuild"); +const fs = require("fs"); +const htmlMinifier = require("html-minifier"); +const path = require("path"); + +const iterations = parseInt(process.env.MHB_ITERATIONS, 10); +const inputDir = process.env.MHB_INPUT_DIR; +const htmlOnly = process.env.MHB_HTML_ONLY === "1"; + +const htmlMinifierCfg = { + collapseBooleanAttributes: true, + collapseInlineTagWhitespace: true, + collapseWhitespace: true, + // minify-html can do context-aware whitespace removal, which is safe when configured correctly to match how whitespace is used in the document. + // html-minifier cannot, so whitespace must be collapsed conservatively. + // Alternatively, minify-html can also be made to remove whitespace regardless of context. + conservativeCollapse: true, + customEventAttributes: [], + decodeEntities: true, + ignoreCustomComments: [], + ignoreCustomFragments: [/<\?[\s\S]*?\?>/], + minifyCSS: !htmlOnly && esbuildCss, + minifyJS: !htmlOnly && esbuildJs, + processConditionalComments: true, + removeAttributeQuotes: true, + removeComments: true, + removeEmptyAttributes: true, + removeOptionalTags: true, + removeRedundantAttributes: true, + removeScriptTypeAttributes: true, + removeStyleLinkTypeAttributes: true, + removeTagWhitespace: true, + useShortDoctype: true, +}; + +const esbuildCss = (code) => + esbuild.transformSync(code, { + loader: "css", + minify: true, + }).code; + +const esbuildJs = (code) => + esbuild.transformSync(code, { + loader: "js", + minify: true, + }).code; + +const results = fs.readdirSync(inputDir).map((name) => { + const src = fs.readFileSync(path.join(inputDir, name), "utf8"); + const start = process.hrtime.bigint(); + for (let i = 0; i < iterations; i++) { + htmlMinifier.minify(src, htmlMinifierCfg); + } + const elapsed = process.hrtime.bigint() - start; + return [name, Number(elapsed) / 1_000_000_000]; +}); +console.log(JSON.stringify(results)); diff --git a/bench/runners/html-minifier/package.json b/bench/runners/html-minifier/package.json new file mode 100644 index 0000000..5ca503d --- /dev/null +++ b/bench/runners/html-minifier/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "dependencies": { + "esbuild": "0.12.19", + "html-minifier": "4.0.0" + } +} diff --git a/bench/runners/html-minifier/run b/bench/runners/html-minifier/run new file mode 100755 index 0000000..14ca842 --- /dev/null +++ b/bench/runners/html-minifier/run @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +node index.js diff --git a/bench/runners/minify-html (Node.js)/.gitignore b/bench/runners/minify-html (Node.js)/.gitignore new file mode 100644 index 0000000..7578d1f --- /dev/null +++ b/bench/runners/minify-html (Node.js)/.gitignore @@ -0,0 +1,2 @@ +/package-lock.json +node_modules/ diff --git a/bench/runners/minify-html (Node.js)/build b/bench/runners/minify-html (Node.js)/build new file mode 100755 index 0000000..f633ef9 --- /dev/null +++ b/bench/runners/minify-html (Node.js)/build @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +pushd ../../../nodejs +npm run build +popd + +npm i diff --git a/bench/runners/minify-html (Node.js)/index.js b/bench/runners/minify-html (Node.js)/index.js new file mode 100644 index 0000000..b3cee82 --- /dev/null +++ b/bench/runners/minify-html (Node.js)/index.js @@ -0,0 +1,23 @@ +const fs = require("fs"); +const minifyHtml = require("@minify-html/js"); +const path = require("path"); + +const iterations = parseInt(process.env.MHB_ITERATIONS, 10); +const inputDir = process.env.MHB_INPUT_DIR; +const htmlOnly = process.env.MHB_HTML_ONLY === "1"; + +const minifyHtmlCfg = minifyHtml.createConfiguration({ + minify_css: !htmlOnly, + minify_js: !htmlOnly, +}); + +const results = fs.readdirSync(inputDir).map((name) => { + const src = fs.readFileSync(path.join(inputDir, name)); + const start = process.hrtime.bigint(); + for (let i = 0; i < iterations; i++) { + minifyHtml.minify(src, minifyHtmlCfg); + } + const elapsed = process.hrtime.bigint() - start; + return [name, Number(elapsed) / 1_000_000_000]; +}); +console.log(JSON.stringify(results)); diff --git a/bench/runners/minify-html (Node.js)/package.json b/bench/runners/minify-html (Node.js)/package.json new file mode 100644 index 0000000..b0625b3 --- /dev/null +++ b/bench/runners/minify-html (Node.js)/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "@minify-html/js": "file:../../../nodejs" + } +} diff --git a/bench/runners/minify-html (Node.js)/run b/bench/runners/minify-html (Node.js)/run new file mode 100755 index 0000000..14ca842 --- /dev/null +++ b/bench/runners/minify-html (Node.js)/run @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +node index.js diff --git a/bench/runners/minify-html (Rust)/.gitignore b/bench/runners/minify-html (Rust)/.gitignore new file mode 100644 index 0000000..042776a --- /dev/null +++ b/bench/runners/minify-html (Rust)/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target/ diff --git a/bench/minify-html-bench/Cargo.toml b/bench/runners/minify-html (Rust)/Cargo.toml similarity index 80% rename from bench/minify-html-bench/Cargo.toml rename to bench/runners/minify-html (Rust)/Cargo.toml index f23bdeb..970ba53 100644 --- a/bench/minify-html-bench/Cargo.toml +++ b/bench/runners/minify-html (Rust)/Cargo.toml @@ -6,8 +6,7 @@ authors = ["Wilson Lin "] edition = "2018" [dependencies] -minify-html = { path = "../../rust/main" } -structopt = "0.3.5" +minify-html = { path = "../../../rust/main" } serde = { version = "1.0.104", features = ["derive"] } serde_json = "1.0.44" diff --git a/bench/runners/minify-html (Rust)/build b/bench/runners/minify-html (Rust)/build new file mode 100755 index 0000000..c3568a4 --- /dev/null +++ b/bench/runners/minify-html (Rust)/build @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +cargo build --release diff --git a/bench/runners/minify-html (Rust)/run b/bench/runners/minify-html (Rust)/run new file mode 100755 index 0000000..820dfb2 --- /dev/null +++ b/bench/runners/minify-html (Rust)/run @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +cargo run --release diff --git a/bench/runners/minify-html (Rust)/src/main.rs b/bench/runners/minify-html (Rust)/src/main.rs new file mode 100644 index 0000000..14980b3 --- /dev/null +++ b/bench/runners/minify-html (Rust)/src/main.rs @@ -0,0 +1,33 @@ +use std::{env, fs}; +use std::io::stdout; +use std::time::Instant; + +use minify_html::{Cfg, minify}; + +fn main() { + let iterations = env::var("MHB_ITERATIONS").unwrap().parse::().unwrap(); + let input_dir = env::var("MHB_INPUT_DIR").unwrap(); + let html_only = env::var("MHB_HTML_ONLY").unwrap() == "1"; + + let tests = fs::read_dir(input_dir).unwrap().map(|d| d.unwrap()); + + let mut results: Vec<(String, f64)> = Vec::new(); + let mut cfg = Cfg::new(); + if !html_only { + cfg.minify_css = true; + cfg.minify_js = true; + }; + + for t in tests { + let source = fs::read(t.path()).unwrap(); + let start = Instant::now(); + for _ in 0..iterations { + let data = source.to_vec(); + minify(&source, &cfg); + }; + let elapsed = start.elapsed().as_secs_f64(); + results.push((t.file_name().into_string().unwrap(), elapsed)); + }; + + serde_json::to_writer(stdout(), &results).unwrap(); +} diff --git a/bench/runners/minify-html-onepass (Rust)/.gitignore b/bench/runners/minify-html-onepass (Rust)/.gitignore new file mode 100644 index 0000000..042776a --- /dev/null +++ b/bench/runners/minify-html-onepass (Rust)/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target/ diff --git a/bench/runners/minify-html-onepass (Rust)/Cargo.toml b/bench/runners/minify-html-onepass (Rust)/Cargo.toml new file mode 100644 index 0000000..485dafc --- /dev/null +++ b/bench/runners/minify-html-onepass (Rust)/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "minify-html-onepass-bench" +publish = false +version = "0.0.1" +authors = ["Wilson Lin "] +edition = "2018" + +[dependencies] +minify-html-onepass = { path = "../../../rust/onepass" } +serde = { version = "1.0.104", features = ["derive"] } +serde_json = "1.0.44" diff --git a/bench/runners/minify-html-onepass (Rust)/build b/bench/runners/minify-html-onepass (Rust)/build new file mode 100755 index 0000000..c3568a4 --- /dev/null +++ b/bench/runners/minify-html-onepass (Rust)/build @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +cargo build --release diff --git a/bench/runners/minify-html-onepass (Rust)/run b/bench/runners/minify-html-onepass (Rust)/run new file mode 100755 index 0000000..820dfb2 --- /dev/null +++ b/bench/runners/minify-html-onepass (Rust)/run @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +cargo run --release diff --git a/bench/runners/minify-html-onepass (Rust)/src/main.rs b/bench/runners/minify-html-onepass (Rust)/src/main.rs new file mode 100644 index 0000000..071d639 --- /dev/null +++ b/bench/runners/minify-html-onepass (Rust)/src/main.rs @@ -0,0 +1,32 @@ +use std::{env, fs}; +use std::io::stdout; +use std::time::Instant; + +use minify_html_onepass::{Cfg, in_place}; + +fn main() { + let iterations = env::var("MHB_ITERATIONS").unwrap().parse::().unwrap(); + let input_dir = env::var("MHB_INPUT_DIR").unwrap(); + let html_only = env::var("MHB_HTML_ONLY").unwrap() == "1"; + + let tests = fs::read_dir(input_dir).unwrap().map(|d| d.unwrap()); + + let mut results: Vec<(String, f64)> = Vec::new(); + let mut cfg = Cfg { + minify_css: !html_only, + minify_js: !html_only, + }; + + for t in tests { + let source = fs::read(t.path()).unwrap(); + let start = Instant::now(); + for _ in 0..iterations { + let mut data = source.to_vec(); + let _ = in_place(&mut data, &cfg).unwrap(); + }; + let elapsed = start.elapsed().as_secs_f64(); + results.push((t.file_name().into_string().unwrap(), elapsed)); + }; + + serde_json::to_writer(stdout(), &results).unwrap(); +} diff --git a/bench/runners/minimize/.gitignore b/bench/runners/minimize/.gitignore new file mode 100644 index 0000000..7578d1f --- /dev/null +++ b/bench/runners/minimize/.gitignore @@ -0,0 +1,2 @@ +/package-lock.json +node_modules/ diff --git a/bench/runners/minimize/build b/bench/runners/minimize/build new file mode 100755 index 0000000..1c693da --- /dev/null +++ b/bench/runners/minimize/build @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +npm i diff --git a/bench/runners/minimize/index.js b/bench/runners/minimize/index.js new file mode 100644 index 0000000..b705a42 --- /dev/null +++ b/bench/runners/minimize/index.js @@ -0,0 +1,70 @@ +const esbuild = require("esbuild"); +const fs = require("fs"); +const minimize = require("minimize"); +const path = require("path"); + +const iterations = parseInt(process.env.MHB_ITERATIONS, 10); +const inputDir = process.env.MHB_INPUT_DIR; +const htmlOnly = process.env.MHB_HTML_ONLY === "1"; + +const jsMime = new Set([ + undefined, + "application/ecmascript", + "application/javascript", + "application/x-ecmascript", + "application/x-javascript", + "text/ecmascript", + "text/javascript", + "text/javascript1.0", + "text/javascript1.1", + "text/javascript1.2", + "text/javascript1.3", + "text/javascript1.4", + "text/javascript1.5", + "text/jscript", + "text/livescript", + "text/x-ecmascript", + "text/x-javascript", +]); + +const esbuildCss = (code) => + esbuild.transformSync(code, { + loader: "css", + minify: true, + }).code; + +const esbuildJs = (code) => + esbuild.transformSync(code, { + loader: "js", + minify: true, + }).code; + +const jsCssPlugin = { + id: "esbuild", + element: (node, next) => { + if (node.type === "text" && node.parent) { + if ( + node.parent.type === "script" && + jsMime.has(node.parent.attribs.type) + ) { + node.data = esbuildJs(node.data); + } else if (node.parent.type === "style") { + node.data = esbuildCss(node.data); + } + } + next(); + }, +}; + +const plugins = htmlOnly ? [] : [jsCssPlugin]; + +const results = fs.readdirSync(inputDir).map((name) => { + const src = fs.readFileSync(path.join(inputDir, name), "utf8"); + const start = process.hrtime.bigint(); + for (let i = 0; i < iterations; i++) { + new minimize({ plugins }).parse(src); + } + const elapsed = process.hrtime.bigint() - start; + return [name, Number(elapsed) / 1_000_000_000]; +}); +console.log(JSON.stringify(results)); diff --git a/bench/runners/minimize/package.json b/bench/runners/minimize/package.json new file mode 100644 index 0000000..1026f79 --- /dev/null +++ b/bench/runners/minimize/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "dependencies": { + "esbuild": "0.12.19", + "minimize": "2.2.0" + } +} diff --git a/bench/runners/minimize/run b/bench/runners/minimize/run new file mode 100755 index 0000000..14ca842 --- /dev/null +++ b/bench/runners/minimize/run @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -Eeuxo pipefail + +node index.js diff --git a/bench/sizes.js b/bench/sizes.js index 83b72eb..74588b9 100644 --- a/bench/sizes.js +++ b/bench/sizes.js @@ -1,15 +1,15 @@ -const fs = require('fs'); -const path = require('path'); -const minifiers = require('./minifiers'); -const results = require('./results'); -const tests = require('./tests'); +const fs = require("fs"); +const path = require("path"); +const minifiers = require("./minifiers"); +const results = require("./results"); +const tests = require("./tests"); const sizes = {}; const setSize = (program, test, result) => { if (!sizes[test]) { sizes[test] = { original: { - absolute: tests.find(t => t.name === test).contentAsString.length, + absolute: tests.find((t) => t.name === test).contentAsString.length, relative: 1, }, }; @@ -28,8 +28,8 @@ const setSize = (program, test, result) => { const min = await minifiers[m](t.contentAsString, t.contentAsBuffer); // If `min` is a Buffer, convert to string (interpret as UTF-8) to get canonical length. setSize(m, t.name, min.toString().length); - const minPath = path.join(__dirname, 'min', m, `${t.name}.html`); - fs.mkdirSync(path.dirname(minPath), {recursive: true}); + const minPath = path.join(__dirname, "min", m, `${t.name}.html`); + fs.mkdirSync(path.dirname(minPath), { recursive: true }); fs.writeFileSync(minPath, min); } catch (err) { console.error(`Failed to run ${m} on test ${t.name}:`); diff --git a/bench/speeds.js b/bench/speeds.js index b1bb587..e571eb8 100644 --- a/bench/speeds.js +++ b/bench/speeds.js @@ -1,23 +1,27 @@ -const benchmark = require('benchmark'); -const childProcess = require('child_process'); -const minimist = require('minimist'); -const path = require('path'); -const minifiers = require('./minifiers'); -const results = require('./results'); -const tests = require('./tests'); +const benchmark = require("benchmark"); +const childProcess = require("child_process"); +const minimist = require("minimist"); +const path = require("path"); +const minifiers = require("./minifiers"); +const results = require("./results"); +const tests = require("./tests"); const args = minimist(process.argv.slice(2)); const shouldRunRust = !!args.rust; const cmd = (command, ...args) => { - const throwErr = msg => { - throw new Error(`${msg}\n ${command} ${args.join(' ')}`); + const throwErr = (msg) => { + throw new Error(`${msg}\n ${command} ${args.join(" ")}`); }; - const {status, signal, error, stdout, stderr} = childProcess.spawnSync(command, args.map(String), { - stdio: ['ignore', 'pipe', 'pipe'], - encoding: 'utf8', - }); + const { status, signal, error, stdout, stderr } = childProcess.spawnSync( + command, + args.map(String), + { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + } + ); if (error) { throwErr(error.message); } @@ -33,42 +37,57 @@ const cmd = (command, ...args) => { return stdout; }; -const fromEntries = entries => { +const fromEntries = (entries) => { if (Object.fromEntries) return Object.fromEntries(entries); const obj = {}; for (const [prop, val] of entries) obj[prop] = val; return obj; }; -const runTest = test => new Promise((resolve, reject) => { - // Run JS libraries. - const suite = new benchmark.Suite(); - for (const m of Object.keys(minifiers)) { - suite.add(m, { - defer: true, - fn (deferred) { - Promise.resolve(minifiers[m](test.contentAsString, test.contentAsBuffer)).then(() => deferred.resolve()); - }, - }); - } - suite - .on('cycle', event => console.info(test.name, event.target.toString())) - .on('complete', () => resolve(fromEntries(suite.map(b => [b.name, b.hz])))) - .on('error', reject) - .run({'async': true}); -}); +const runTest = (test) => + new Promise((resolve, reject) => { + // Run JS libraries. + const suite = new benchmark.Suite(); + for (const m of Object.keys(minifiers)) { + suite.add(m, { + defer: true, + fn(deferred) { + Promise.resolve( + minifiers[m](test.contentAsString, test.contentAsBuffer) + ).then(() => deferred.resolve()); + }, + }); + } + suite + .on("cycle", (event) => console.info(test.name, event.target.toString())) + .on("complete", () => + resolve(fromEntries(suite.map((b) => [b.name, b.hz]))) + ) + .on("error", reject) + .run({ async: true }); + }); (async () => { - const speeds = fromEntries(tests.map(t => [t.name, {}])); + const speeds = fromEntries(tests.map((t) => [t.name, {}])); // Run Rust library. if (shouldRunRust) { - for (const [testName, testOps] of JSON.parse(cmd( - path.join(__dirname, 'minify-html-bench', 'target', 'release', 'minify-html-bench'), - '--iterations', 512, - '--tests', path.join(__dirname, 'tests'), - ))) { - Object.assign(speeds[testName], {['minify-html']: testOps}); + for (const [testName, testOps] of JSON.parse( + cmd( + path.join( + __dirname, + "minify-html-bench", + "target", + "release", + "minify-html-bench" + ), + "--iterations", + 512, + "--tests", + path.join(__dirname, "tests") + ) + )) { + Object.assign(speeds[testName], { ["minify-html"]: testOps }); } } diff --git a/bench/tests.js b/bench/tests.js deleted file mode 100644 index 1ae9017..0000000 --- a/bench/tests.js +++ /dev/null @@ -1,9 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const testsDir = path.join(__dirname, 'tests'); -module.exports = fs.readdirSync(testsDir).filter(f => !/^\./.test(f)).map(name => ({ - name, - contentAsString: fs.readFileSync(path.join(testsDir, name), 'utf8'), - contentAsBuffer: fs.readFileSync(path.join(testsDir, name)), -})).sort((a, b) => a.name.localeCompare(b.name)); diff --git a/bench/compare.sh b/debug/diff/compare.sh old mode 100755 new mode 100644 similarity index 100% rename from bench/compare.sh rename to debug/diff/compare.sh diff --git a/debug/prof/.gitignore b/debug/prof/.gitignore new file mode 100644 index 0000000..01f87cc --- /dev/null +++ b/debug/prof/.gitignore @@ -0,0 +1 @@ +/perf.data* diff --git a/bench/profile.sh b/debug/prof/profile.sh old mode 100755 new mode 100644 similarity index 100% rename from bench/profile.sh rename to debug/prof/profile.sh