Generify bench

This commit is contained in:
Wilson Lin 2021-08-08 21:01:37 +10:00
parent 4fc9496829
commit a28e69ddb0
55 changed files with 618 additions and 396 deletions

8
bench/.gitignore vendored
View File

@ -1,7 +1 @@
/minify-html-bench/Cargo.lock /results/
/minify-html-bench/target/
/min/
/results*/
node_modules/
package-lock.json
perf.data*

View File

@ -1 +0,0 @@
10

View File

@ -7,10 +7,14 @@ for i in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
echo performance | sudo dd status=none of="$i" echo performance | sudo dd status=none of="$i"
done done
node sizes.js results_dir="$PWD/results"
# We need sudo to use `nice` but want to keep using current `node`, so use explicit path in case sudo decides to ignore PATH. input_dir="$PWD/inputs"
node_path="$(which node)" iterations=100
echo "Using Node.js at $node_path"
sudo --preserve-env=HTML_ONLY nice -n -20 taskset -c 1 "$node_path" speeds.js pushd runners
sudo chown -R "$USER:$USER" results for r in *; do
node graph.js 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

View File

@ -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

View File

@ -1,54 +1,71 @@
const {promises: fs} = require('fs'); const { promises: fs } = require("fs");
const request = require('request-promise-native'); const childProcess = require("child_process");
const path = require('path'); const path = require("path");
const tests = { const tests = {
"Amazon": "https://www.amazon.com/", Amazon: "https://www.amazon.com/",
"BBC": "https://www.bbc.co.uk/", BBC: "https://www.bbc.co.uk/",
"Bootstrap": "https://getbootstrap.com/docs/3.4/css/", Bootstrap: "https://getbootstrap.com/docs/3.4/css/",
"Bing": "https://www.bing.com/", Bing: "https://www.bing.com/",
"Coding Horror": "https://blog.codinghorror.com/", "Coding Horror": "https://blog.codinghorror.com/",
"ECMA-262": "https://www.ecma-international.org/ecma-262/10.0/index.html", "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/", "Hacker News": "https://news.ycombinator.com/",
"NY Times": "https://www.nytimes.com/", "NY Times": "https://www.nytimes.com/",
"Reddit": "https://www.reddit.com/", Reddit: "https://www.reddit.com/",
"Stack Overflow": "https://www.stackoverflow.com/", "Stack Overflow": "https://www.stackoverflow.com/",
"Twitter": "https://twitter.com/", Twitter: "https://twitter.com/",
"Wikipedia": "https://en.wikipedia.org/wiki/Soil", Wikipedia: "https://en.wikipedia.org/wiki/Soil",
}; };
const fetchTest = async (name, url) => { const fetchTest = (name, url) =>
const html = await request({ new Promise((resolve, reject) => {
url, // Use curl to follow redirects without needing a Node.js library.
gzip: true, childProcess.execFile(
headers: { "curl",
'Accept': '*/*', [
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; rv:71.0) Gecko/20100101 Firefox/71.0', "-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 () => { (async () => {
const existing = await fs.readdir(path.join(__dirname, 'tests')); const existing = await fs.readdir(path.join(__dirname, "tests"));
await Promise.all(existing.map(e => fs.unlink(path.join(__dirname, 'tests', e)))); 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. // 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. // Apply some fixes to HTML.
const fixed = html const fixed = html
// Fix early termination of conditional comment in Amazon. // Fix early termination of conditional comment in Amazon.
.replace('--></style>\n<![endif]-->', '</style>\n<![endif]-->') .replace("--></style>\n<![endif]-->", "</style>\n<![endif]-->")
// Fix closing of void tag in Amazon. // Fix closing of void tag in Amazon.
.replace(/><\/hr>/g, '/>') .replace(/><\/hr>/g, "/>")
// Fix extra '</div>' in BBC. // Fix extra '</div>' in BBC.
.replace('</a></span></small></div></div></div></footer>', '</a></span></small></div></div></footer>') .replace(
"</a></span></small></div></div></div></footer>",
"</a></span></small></div></div></footer>"
)
// Fix broken attribute value in Stack Overflow. // Fix broken attribute value in Stack Overflow.
.replace('height=151"', 'height="151"') .replace('height=151"', 'height="151"');
; await fs.writeFile(path.join(__dirname, "tests", name), fixed);
await fs.writeFile(path.join(__dirname, 'tests', name), fixed);
} }
})() })().catch(console.error);
.catch(console.error);

View File

@ -1,30 +1,30 @@
const results = require('./results'); const results = require("./results");
const https = require('https'); const https = require("https");
const colours = { const colours = {
'minify-html': '#041f60', "minify-html": "#041f60",
'@minify-html/js': '#1f77b4', "@minify-html/js": "#1f77b4",
'minimize': '#ff7f0e', minimize: "#ff7f0e",
'html-minifier': '#2ca02c', "html-minifier": "#2ca02c",
}; };
const COLOUR_SPEED_PRIMARY = '#2e61bd'; const COLOUR_SPEED_PRIMARY = "#2e61bd";
const COLOUR_SPEED_SECONDARY = 'rgb(188, 188, 188)'; const COLOUR_SPEED_SECONDARY = "rgb(188, 188, 188)";
const COLOUR_SIZE_PRIMARY = '#64acce'; const COLOUR_SIZE_PRIMARY = "#64acce";
const COLOUR_SIZE_SECONDARY = 'rgb(224, 224, 224)'; const COLOUR_SIZE_SECONDARY = "rgb(224, 224, 224)";
const breakdownChartOptions = (title) => ({ const breakdownChartOptions = (title) => ({
options: { options: {
legend: { legend: {
display: true, display: true,
labels: { labels: {
fontColor: '#000', fontColor: "#000",
}, },
}, },
title: { title: {
display: true, display: true,
text: title, text: title,
fontColor: '#333', fontColor: "#333",
fontSize: 24, fontSize: 24,
}, },
scales: { scales: {
@ -32,10 +32,10 @@ const breakdownChartOptions = (title) => ({
{ {
barPercentage: 0.25, barPercentage: 0.25,
gridLines: { gridLines: {
color: '#e2e2e2', color: "#e2e2e2",
}, },
ticks: { ticks: {
fontColor: '#666', fontColor: "#666",
fontSize: 20, fontSize: 20,
}, },
}, },
@ -43,10 +43,10 @@ const breakdownChartOptions = (title) => ({
yAxes: [ yAxes: [
{ {
gridLines: { gridLines: {
color: '#ccc', color: "#ccc",
}, },
ticks: { ticks: {
fontColor: '#666', fontColor: "#666",
fontSize: 20, fontSize: 20,
}, },
}, },
@ -59,7 +59,7 @@ const axisLabel = (fontColor, labelString) => ({
display: true, display: true,
fontColor, fontColor,
fontSize: 24, fontSize: 24,
fontStyle: 'bold', fontStyle: "bold",
labelString, labelString,
padding: 16, padding: 16,
}); });
@ -76,31 +76,31 @@ const combinedChartOptions = () => ({
display: false, display: false,
}, },
ticks: { ticks: {
fontColor: '#555', fontColor: "#555",
fontSize: 24, fontSize: 24,
}, },
}, },
], ],
yAxes: [ yAxes: [
{ {
id: 'y1', id: "y1",
type: 'linear', type: "linear",
scaleLabel: axisLabel(COLOUR_SPEED_PRIMARY, 'Performance'), scaleLabel: axisLabel(COLOUR_SPEED_PRIMARY, "Performance"),
position: 'left', position: "left",
ticks: { ticks: {
callback: "$$$_____REPLACE_WITH_TICK_CALLBACK_____$$$", callback: "$$$_____REPLACE_WITH_TICK_CALLBACK_____$$$",
fontColor: COLOUR_SPEED_PRIMARY, fontColor: COLOUR_SPEED_PRIMARY,
fontSize: 24, fontSize: 24,
}, },
gridLines: { gridLines: {
color: '#eee', color: "#eee",
}, },
}, },
{ {
id: 'y2', id: "y2",
type: 'linear', type: "linear",
scaleLabel: axisLabel(COLOUR_SIZE_PRIMARY, 'Average size reduction'), scaleLabel: axisLabel(COLOUR_SIZE_PRIMARY, "Average size reduction"),
position: 'right', position: "right",
ticks: { ticks: {
callback: "$$$_____REPLACE_WITH_TICK_CALLBACK_____$$$", callback: "$$$_____REPLACE_WITH_TICK_CALLBACK_____$$$",
fontColor: COLOUR_SIZE_PRIMARY, fontColor: COLOUR_SIZE_PRIMARY,
@ -108,90 +108,116 @@ const combinedChartOptions = () => ({
}, },
gridLines: { gridLines: {
display: false, display: false,
} },
}, },
], ],
}, },
}, },
}); });
const renderChart = (cfg) => new Promise((resolve, reject) => { const renderChart = (cfg) =>
const req = https.request('https://quickchart.io/chart', { new Promise((resolve, reject) => {
method: 'POST', const req = https.request("https://quickchart.io/chart", {
headers: { method: "POST",
'Content-Type': 'application/json', 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 () => { (async () => {
const averageSpeeds = results.getSpeedResults().getAverageRelativeSpeedPerMinifier('@minify-html/js'); const averageSpeeds = results
const averageSizes = results.getSizeResults().getAverageRelativeSizePerMinifier(); .getSpeedResults()
.getAverageRelativeSpeedPerMinifier("@minify-html/js");
const averageSizes = results
.getSizeResults()
.getAverageRelativeSizePerMinifier();
const averageLabels = ["minimize", "html-minifier", "@minify-html/js"]; const averageLabels = ["minimize", "html-minifier", "@minify-html/js"];
results.writeAverageCombinedGraph(await renderChart({ results.writeAverageCombinedGraph(
type: 'bar', await renderChart({
data: { type: "bar",
labels: averageLabels, data: {
datasets: [ labels: averageLabels,
{ datasets: [
yAxisID: 'y1', {
backgroundColor: averageLabels.map((n) => n === "@minify-html/js" ? COLOUR_SPEED_PRIMARY : COLOUR_SPEED_SECONDARY), yAxisID: "y1",
data: averageLabels.map((n) => averageSpeeds.get(n)), backgroundColor: averageLabels.map((n) =>
}, n === "@minify-html/js"
{ ? COLOUR_SPEED_PRIMARY
yAxisID: 'y2', : COLOUR_SPEED_SECONDARY
backgroundColor: averageLabels.map((n) => n === "@minify-html/js" ? COLOUR_SIZE_PRIMARY : COLOUR_SIZE_SECONDARY), ),
data: averageLabels.map((n) => 1 - averageSizes.get(n)), data: averageLabels.map((n) => averageSpeeds.get(n)),
}, },
], {
}, yAxisID: "y2",
...combinedChartOptions(), 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'); const speeds = results
results.writeSpeedsGraph(await renderChart({ .getSpeedResults()
type: 'bar', .getRelativeFileSpeedsPerMinifier("@minify-html/js");
data: { results.writeSpeedsGraph(
labels: speeds[0][1].map(([n]) => n), await renderChart({
datasets: speeds.map(([minifier, fileSpeeds]) => ({ type: "bar",
label: minifier, data: {
backgroundColor: colours[minifier], labels: speeds[0][1].map(([n]) => n),
data: fileSpeeds.map(([_, speed]) => speed), datasets: speeds.map(([minifier, fileSpeeds]) => ({
})), label: minifier,
}, backgroundColor: colours[minifier],
...breakdownChartOptions('Operations per second (higher is better)'), data: fileSpeeds.map(([_, speed]) => speed),
})); })),
},
...breakdownChartOptions("Operations per second (higher is better)"),
})
);
const sizes = results.getSizeResults().getRelativeFileSizesPerMinifier(); const sizes = results.getSizeResults().getRelativeFileSizesPerMinifier();
results.writeSizesGraph(await renderChart({ results.writeSizesGraph(
type: 'bar', await renderChart({
data: { type: "bar",
labels: sizes[0][1].map(([n]) => n), data: {
datasets: sizes.map(([minifier, fileSizes]) => ({ labels: sizes[0][1].map(([n]) => n),
label: minifier, datasets: sizes.map(([minifier, fileSizes]) => ({
backgroundColor: colours[minifier], label: minifier,
data: fileSizes.map(([_, size]) => size), backgroundColor: colours[minifier],
})), data: fileSizes.map(([_, size]) => size),
}, })),
...breakdownChartOptions('Minified size (lower is better)'), },
})); ...breakdownChartOptions("Minified size (lower is better)"),
})
);
})(); })();

View File

@ -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),
};

View File

@ -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();
}

View File

@ -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"
}
}

View File

@ -1,21 +1,21 @@
const minifiers = require('./minifiers'); const minifiers = require("./minifiers");
const tests = require('./tests'); const tests = require("./tests");
const {join} = require('path'); const { join } = require("path");
const {mkdirSync, readFileSync, writeFileSync} = require('fs'); const { mkdirSync, readFileSync, writeFileSync } = require("fs");
const RESULTS_DIR = join(__dirname, 'results'); const RESULTS_DIR = join(__dirname, "results");
const SPEEDS_JSON = join(RESULTS_DIR, 'speeds.json'); const SPEEDS_JSON = join(RESULTS_DIR, "speeds.json");
const SPEEDS_GRAPH = join(RESULTS_DIR, 'speeds.png'); const SPEEDS_GRAPH = join(RESULTS_DIR, "speeds.png");
const AVERAGE_COMBINED_GRAPH = join(RESULTS_DIR, 'average-combined.png'); const AVERAGE_COMBINED_GRAPH = join(RESULTS_DIR, "average-combined.png");
const AVERAGE_SPEEDS_GRAPH = join(RESULTS_DIR, 'average-speeds.png'); const AVERAGE_SPEEDS_GRAPH = join(RESULTS_DIR, "average-speeds.png");
const SIZES_JSON = join(RESULTS_DIR, 'sizes.json'); const SIZES_JSON = join(RESULTS_DIR, "sizes.json");
const SIZES_GRAPH = join(RESULTS_DIR, 'sizes.png'); const SIZES_GRAPH = join(RESULTS_DIR, "sizes.png");
const AVERAGE_SIZES_GRAPH = join(RESULTS_DIR, 'average-sizes.png'); const AVERAGE_SIZES_GRAPH = join(RESULTS_DIR, "average-sizes.png");
const minifierNames = Object.keys(minifiers); 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 = { module.exports = {
writeSpeedResults(speeds) { writeSpeedResults(speeds) {
@ -40,50 +40,58 @@ module.exports = {
writeFileSync(SIZES_GRAPH, data); writeFileSync(SIZES_GRAPH, data);
}, },
getSpeedResults() { getSpeedResults() {
const data = JSON.parse(readFileSync(SPEEDS_JSON, 'utf8')); const data = JSON.parse(readFileSync(SPEEDS_JSON, "utf8"));
return { return {
// Get minifier-speed pairs. // Get minifier-speed pairs.
getAverageRelativeSpeedPerMinifier(baselineMinifier) { getAverageRelativeSpeedPerMinifier(baselineMinifier) {
return new Map(minifierNames.map(minifier => [ return new Map(
minifier, minifierNames.map((minifier) => [
testNames minifier,
// Get operations per second for each test. testNames
.map(test => data[test][minifier] / data[test][baselineMinifier]) // Get operations per second for each test.
// Sum all test operations per second. .map(
.reduce((sum, c) => sum + c) (test) => data[test][minifier] / data[test][baselineMinifier]
// Divide by tests count to get average operations per second. )
/ testNames.length, // 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. // Get minifier-speeds pairs.
getRelativeFileSpeedsPerMinifier(baselineMinifier) { getRelativeFileSpeedsPerMinifier(baselineMinifier) {
return minifierNames.map(minifier => [ return minifierNames.map((minifier) => [
minifier, minifier,
testNames.map(test => [test, data[test][minifier] / data[test][baselineMinifier]]), testNames.map((test) => [
test,
data[test][minifier] / data[test][baselineMinifier],
]),
]); ]);
}, },
}; };
}, },
getSizeResults() { getSizeResults() {
const data = JSON.parse(readFileSync(SIZES_JSON, 'utf8')); const data = JSON.parse(readFileSync(SIZES_JSON, "utf8"));
return { return {
// Get minifier-size pairs. // Get minifier-size pairs.
getAverageRelativeSizePerMinifier() { getAverageRelativeSizePerMinifier() {
return new Map(minifierNames.map(minifier => [ return new Map(
minifier, minifierNames.map((minifier) => [
testNames minifier,
.map(test => data[test][minifier].relative) testNames
.reduce((sum, c) => sum + c) .map((test) => data[test][minifier].relative)
/ testNames.length, .reduce((sum, c) => sum + c) / testNames.length,
])); ])
);
}, },
// Get minifier-sizes pairs. // Get minifier-sizes pairs.
getRelativeFileSizesPerMinifier() { getRelativeFileSizesPerMinifier() {
return minifierNames.map(minifier => [ return minifierNames.map((minifier) => [
minifier, minifier,
testNames.map(test => [test, data[test][minifier].relative]), testNames.map((test) => [test, data[test][minifier].relative]),
]); ]);
}, },
}; };

9
bench/runners/README.md Normal file
View File

@ -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.

View File

@ -0,0 +1,2 @@
/package-lock.json
node_modules/

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
npm i

View File

@ -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));

View File

@ -0,0 +1,7 @@
{
"private": true,
"dependencies": {
"esbuild": "0.12.19",
"html-minifier": "4.0.0"
}
}

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
node index.js

View File

@ -0,0 +1,2 @@
/package-lock.json
node_modules/

View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
pushd ../../../nodejs
npm run build
popd
npm i

View File

@ -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));

View File

@ -0,0 +1,6 @@
{
"private": true,
"dependencies": {
"@minify-html/js": "file:../../../nodejs"
}
}

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
node index.js

View File

@ -0,0 +1,2 @@
/Cargo.lock
/target/

View File

@ -6,8 +6,7 @@ authors = ["Wilson Lin <code@wilsonl.in>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
minify-html = { path = "../../rust/main" } minify-html = { path = "../../../rust/main" }
structopt = "0.3.5"
serde = { version = "1.0.104", features = ["derive"] } serde = { version = "1.0.104", features = ["derive"] }
serde_json = "1.0.44" serde_json = "1.0.44"

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
cargo build --release

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
cargo run --release

View File

@ -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::<usize>().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();
}

View File

@ -0,0 +1,2 @@
/Cargo.lock
/target/

View File

@ -0,0 +1,11 @@
[package]
name = "minify-html-onepass-bench"
publish = false
version = "0.0.1"
authors = ["Wilson Lin <code@wilsonl.in>"]
edition = "2018"
[dependencies]
minify-html-onepass = { path = "../../../rust/onepass" }
serde = { version = "1.0.104", features = ["derive"] }
serde_json = "1.0.44"

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
cargo build --release

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
cargo run --release

View File

@ -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::<usize>().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();
}

2
bench/runners/minimize/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/package-lock.json
node_modules/

5
bench/runners/minimize/build Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
npm i

View File

@ -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));

View File

@ -0,0 +1,7 @@
{
"private": true,
"dependencies": {
"esbuild": "0.12.19",
"minimize": "2.2.0"
}
}

5
bench/runners/minimize/run Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -Eeuxo pipefail
node index.js

View File

@ -1,15 +1,15 @@
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
const minifiers = require('./minifiers'); const minifiers = require("./minifiers");
const results = require('./results'); const results = require("./results");
const tests = require('./tests'); const tests = require("./tests");
const sizes = {}; const sizes = {};
const setSize = (program, test, result) => { const setSize = (program, test, result) => {
if (!sizes[test]) { if (!sizes[test]) {
sizes[test] = { sizes[test] = {
original: { original: {
absolute: tests.find(t => t.name === test).contentAsString.length, absolute: tests.find((t) => t.name === test).contentAsString.length,
relative: 1, relative: 1,
}, },
}; };
@ -28,8 +28,8 @@ const setSize = (program, test, result) => {
const min = await minifiers[m](t.contentAsString, t.contentAsBuffer); 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. // If `min` is a Buffer, convert to string (interpret as UTF-8) to get canonical length.
setSize(m, t.name, min.toString().length); setSize(m, t.name, min.toString().length);
const minPath = path.join(__dirname, 'min', m, `${t.name}.html`); const minPath = path.join(__dirname, "min", m, `${t.name}.html`);
fs.mkdirSync(path.dirname(minPath), {recursive: true}); fs.mkdirSync(path.dirname(minPath), { recursive: true });
fs.writeFileSync(minPath, min); fs.writeFileSync(minPath, min);
} catch (err) { } catch (err) {
console.error(`Failed to run ${m} on test ${t.name}:`); console.error(`Failed to run ${m} on test ${t.name}:`);

View File

@ -1,23 +1,27 @@
const benchmark = require('benchmark'); const benchmark = require("benchmark");
const childProcess = require('child_process'); const childProcess = require("child_process");
const minimist = require('minimist'); const minimist = require("minimist");
const path = require('path'); const path = require("path");
const minifiers = require('./minifiers'); const minifiers = require("./minifiers");
const results = require('./results'); const results = require("./results");
const tests = require('./tests'); const tests = require("./tests");
const args = minimist(process.argv.slice(2)); const args = minimist(process.argv.slice(2));
const shouldRunRust = !!args.rust; const shouldRunRust = !!args.rust;
const cmd = (command, ...args) => { const cmd = (command, ...args) => {
const throwErr = msg => { const throwErr = (msg) => {
throw new Error(`${msg}\n ${command} ${args.join(' ')}`); throw new Error(`${msg}\n ${command} ${args.join(" ")}`);
}; };
const {status, signal, error, stdout, stderr} = childProcess.spawnSync(command, args.map(String), { const { status, signal, error, stdout, stderr } = childProcess.spawnSync(
stdio: ['ignore', 'pipe', 'pipe'], command,
encoding: 'utf8', args.map(String),
}); {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}
);
if (error) { if (error) {
throwErr(error.message); throwErr(error.message);
} }
@ -33,42 +37,57 @@ const cmd = (command, ...args) => {
return stdout; return stdout;
}; };
const fromEntries = entries => { const fromEntries = (entries) => {
if (Object.fromEntries) return Object.fromEntries(entries); if (Object.fromEntries) return Object.fromEntries(entries);
const obj = {}; const obj = {};
for (const [prop, val] of entries) obj[prop] = val; for (const [prop, val] of entries) obj[prop] = val;
return obj; return obj;
}; };
const runTest = test => new Promise((resolve, reject) => { const runTest = (test) =>
// Run JS libraries. new Promise((resolve, reject) => {
const suite = new benchmark.Suite(); // Run JS libraries.
for (const m of Object.keys(minifiers)) { const suite = new benchmark.Suite();
suite.add(m, { for (const m of Object.keys(minifiers)) {
defer: true, suite.add(m, {
fn (deferred) { defer: true,
Promise.resolve(minifiers[m](test.contentAsString, test.contentAsBuffer)).then(() => deferred.resolve()); 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) suite
.run({'async': true}); .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 () => { (async () => {
const speeds = fromEntries(tests.map(t => [t.name, {}])); const speeds = fromEntries(tests.map((t) => [t.name, {}]));
// Run Rust library. // Run Rust library.
if (shouldRunRust) { if (shouldRunRust) {
for (const [testName, testOps] of JSON.parse(cmd( for (const [testName, testOps] of JSON.parse(
path.join(__dirname, 'minify-html-bench', 'target', 'release', 'minify-html-bench'), cmd(
'--iterations', 512, path.join(
'--tests', path.join(__dirname, 'tests'), __dirname,
))) { "minify-html-bench",
Object.assign(speeds[testName], {['minify-html']: testOps}); "target",
"release",
"minify-html-bench"
),
"--iterations",
512,
"--tests",
path.join(__dirname, "tests")
)
)) {
Object.assign(speeds[testName], { ["minify-html"]: testOps });
} }
} }

View File

@ -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));

0
bench/compare.sh → debug/diff/compare.sh Executable file → Normal file
View File

1
debug/prof/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/perf.data*

0
bench/profile.sh → debug/prof/profile.sh Executable file → Normal file
View File