Modernize benchmarks (#2132)

* Introduce benchmarks
This commit is contained in:
Igor Savin
2026-03-18 00:43:46 +02:00
committed by GitHub
parent 70b8f1145d
commit bf93bafeca
37 changed files with 1063 additions and 4239 deletions
+1
View File
@@ -14,5 +14,6 @@ node_modules
# Generated files
/coverage/
/dist/
/tests/bench/results/
/tests/integration/*/dist/
/spec/tmp/*
+2 -3
View File
@@ -116,10 +116,9 @@
}
},
{
"files": ["tests/bench/**/*.js"],
"files": ["tests/bench/**/*.mjs"],
"rules": {
"no-console": "off",
"no-var": "off"
"no-console": "off"
}
},
{
+30
View File
@@ -104,6 +104,36 @@ does have some big performance advantages. Justin Marney, a.k.a.
rewritten Handlebars (current version) is faster than the old version,
with many performance tests being 5 to 7 times faster than the Mustache equivalent.
### Benchmarks
The project includes a comprehensive benchmark suite (powered by [tinybench](https://github.com/tinylibs/tinybench)) that measures compilation, execution, precompilation, and end-to-end performance across templates of varying size and complexity.
```bash
# Run benchmarks (auto-labels with current git branch)
npm run bench
# Run with a custom label
npm run bench -- --label my-optimization
# Compare results
npm run bench:compare
# Or specify files explicitly
npm run bench:compare -- bench/results/bench-*-main.md bench/results/bench-*-feat.md
```
Results are saved as timestamped Markdown files in `bench/results/`. Each report includes ops/sec, avg latency, p50/p75/p99 percentiles, and sample counts.
Typical workflow for comparing branches:
```bash
git checkout main && npm run bench
git checkout my-feature && npm run bench
npm run bench:compare
```
When run without arguments, `bench:compare` auto-selects two result files: if a file labelled "main" exists it is always used as the baseline, otherwise the older file is the baseline. The comparison uses p75 latency for the diff to filter outliers, and marks changes with `!` (>2%) and `!!` (>5%).
## Upgrading
See [release-notes.md](https://github.com/handlebars-lang/handlebars.js/blob/master/release-notes.md) for upgrade notes.
@@ -173,6 +173,9 @@ JavaScriptCompiler.prototype = {
}
}
// Release AST/compiler references only needed during compilation for dedup
this.context.environments.length = 0;
if (this.environment.usePartial) {
ret.usePartial = true;
}
+24 -3522
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -51,6 +51,9 @@
"test:browser-smoke": "playwright test --config tests/browser/playwright.config.js",
"test:serve": "npx serve -l 9999 .",
"test:integration": "npm run build && ./tests/integration/run-integration-tests.sh",
"bench": "node tests/bench/perf.mjs",
"bench:compare": "node tests/bench/compare.mjs",
"bench:size": "node tests/bench/size.mjs",
"--- combined tasks ---": "",
"check-before-pull-request": "concurrently --kill-others-on-fail npm:lint npm:test"
},
@@ -70,22 +73,19 @@
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"aws-sdk": "^2.1.49",
"benchmark": "~1.0",
"concurrently": "^5.0.0",
"dustjs-linkedin": "^2.0.2",
"eslint": "^10.0.3",
"eslint-plugin-compat": "^7.0.1",
"fs-extra": "^8.1.0",
"husky": "^3.1.0",
"lint-staged": "^16.3.2",
"mock-stdin": "^0.3.0",
"mustache": "^2.1.3",
"oxfmt": "^0.36.0",
"oxlint": "^1.51.0",
"semver": "^5.0.1",
"tinybench": "^6.0.0",
"typescript": "^3.4.3",
"uglify-js": "^3.19.3",
"underscore": "^1.5.1",
"vitest": "^4.0.18"
},
"peerDependencies": {
-29
View File
@@ -1,29 +0,0 @@
const metrics = require('../tests/bench');
const { createRegisterAsyncTaskFn } = require('./util/async-grunt-task');
module.exports = function (grunt) {
const registerAsyncTask = createRegisterAsyncTaskFn(grunt);
registerAsyncTask('metrics', function () {
const onlyExecuteName = grunt.option('name');
const events = {};
const promises = Object.keys(metrics).map(async (name) => {
if (name.startsWith('_')) {
return;
}
if (onlyExecuteName != null && name !== onlyExecuteName) {
return;
}
return new Promise((resolve) => {
metrics[name](grunt, function (data) {
events[name] = data;
resolve();
});
});
});
return Promise.all(promises);
});
};
+250
View File
@@ -0,0 +1,250 @@
import {
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
statSync,
} from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
import { formatOps } from './report.mjs';
// ─── Resolve files ───────────────────────────────────────────────────────────
const args = process.argv.slice(2);
let baselinePath, currentPath;
if (args.length >= 2) {
[baselinePath, currentPath] = args;
} else {
const resultsDir = join(__dirname, 'results');
let files;
try {
files = readdirSync(resultsDir)
.filter((f) => f.endsWith('.md') && f.startsWith('bench-'))
.map((f) => join(resultsDir, f));
} catch {
files = [];
}
if (files.length < 2) {
console.error(
files.length === 0
? 'No benchmark results found in bench/results/.'
: 'Only one benchmark result found. Run benchmarks on another branch first.'
);
console.error('');
console.error('Usage: node bench/compare.mjs [<baseline.md> <current.md>]');
console.error('');
console.error(
'With no arguments, auto-selects the two most recent results.'
);
console.error('A result labelled "main" is always used as the baseline.');
process.exit(1);
}
// Find the "main" labelled file if any
const mainFile = files.find((f) => {
const content = readFileSync(f, 'utf8');
const match = content.match(/^- \*\*Label:\*\* (.+)$/m);
return match && match[1] === 'main';
});
if (mainFile) {
// Baseline is main, current is the most recent non-main file
baselinePath = mainFile;
const others = files
.filter((f) => f !== mainFile)
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
currentPath = others[0];
} else {
// Sort by mtime, older = baseline, newer = current
files.sort((a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs);
baselinePath = files[files.length - 2];
currentPath = files[files.length - 1];
}
console.log(`Auto-selected files:`);
console.log(` baseline: ${basename(baselinePath)}`);
console.log(` current: ${basename(currentPath)}`);
console.log();
}
// ─── Parse ───────────────────────────────────────────────────────────────────
function parseOps(str) {
str = str.trim();
if (str.endsWith('M')) return parseFloat(str) * 1_000_000;
if (str.endsWith('K')) return parseFloat(str) * 1_000;
return parseFloat(str);
}
function parseNs(str) {
str = str.trim();
if (str.endsWith('ms')) return parseFloat(str) * 1_000_000;
if (str.endsWith('µs')) return parseFloat(str) * 1_000;
if (str.endsWith('ns')) return parseFloat(str);
return parseFloat(str);
}
function parseReport(filepath) {
const content = readFileSync(filepath, 'utf8');
const lines = content.split('\n');
let label = null;
const labelMatch = content.match(/^- \*\*Label:\*\* (.+)$/m);
if (labelMatch) label = labelMatch[1];
const sections = {};
let currentSection = null;
for (const line of lines) {
if (line.startsWith('## ')) {
currentSection = line.slice(3).trim();
sections[currentSection] = {};
continue;
}
if (
!currentSection ||
!line.startsWith('|') ||
line.startsWith('|---') ||
line.startsWith('| Benchmark')
) {
continue;
}
const cells = line
.split('|')
.map((c) => c.trim())
.filter(Boolean);
if (cells.length < 8) continue;
const [name, opsSec, avg, p50, p75, p99, rme, samples] = cells;
sections[currentSection][name] = {
opsSec: parseOps(opsSec),
avg: parseNs(avg),
p50: parseNs(p50),
p75: parseNs(p75),
p99: parseNs(p99),
rme: parseFloat(rme),
samples: parseInt(samples, 10),
};
}
return { label, sections };
}
// ─── Compare ─────────────────────────────────────────────────────────────────
function pctChange(baseline, current) {
if (baseline === 0) return 0;
return ((current - baseline) / baseline) * 100;
}
function formatPct(pct) {
const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(1)}%`;
}
function indicator(pct, higherIsBetter) {
const effective = higherIsBetter ? pct : -pct;
if (effective > 5) return ' !!';
if (effective > 2) return ' !';
if (effective < -5) return ' !!';
if (effective < -2) return ' !';
return '';
}
// ─── Output ──────────────────────────────────────────────────────────────────
const baseline = parseReport(baselinePath);
const current = parseReport(currentPath);
const baseLabel = baseline.label || 'baseline';
const curLabel = current.label || 'current';
console.log(`Benchmark Comparison: ${baseLabel} vs ${curLabel}`);
console.log('='.repeat(50));
console.log(`Baseline: ${baselinePath}`);
console.log(`Current: ${currentPath}`);
console.log();
console.log('Legend: ! = >2% change, !! = >5% change');
console.log();
const mdLines = [];
mdLines.push(`# Benchmark Comparison: ${baseLabel} vs ${curLabel}`);
mdLines.push('');
mdLines.push(`- **Baseline:** ${baseLabel} (${baselinePath})`);
mdLines.push(`- **Current:** ${curLabel} (${currentPath})`);
mdLines.push(`- **Legend:** ! = >2% change, !! = >5% change`);
mdLines.push('');
const allSections = [
...new Set([
...Object.keys(baseline.sections),
...Object.keys(current.sections),
]),
].sort();
for (const section of allSections) {
const baseBenches = baseline.sections[section] || {};
const curBenches = current.sections[section] || {};
const allNames = [
...new Set([...Object.keys(baseBenches), ...Object.keys(curBenches)]),
].sort();
if (allNames.size === 0) continue;
console.log(`## ${section}`);
console.log();
const header = `| Benchmark | ${baseLabel} ops/sec | ${curLabel} ops/sec | ops/sec | p75 latency |`;
const sep = '|---|---|---|---|---|';
console.log(header);
console.log(sep);
mdLines.push(`## ${section}`);
mdLines.push('');
mdLines.push(header);
mdLines.push(sep);
for (const name of allNames) {
const b = baseBenches[name];
const c = curBenches[name];
if (!b || !c) {
const row = `| ${name} | ${b ? formatOps(b.opsSec) : 'n/a'} | ${c ? formatOps(c.opsSec) : 'n/a'} | - | - |`;
console.log(row);
mdLines.push(row);
continue;
}
const opsPct = pctChange(b.opsSec, c.opsSec);
const p75Pct = pctChange(b.p75, c.p75);
const opsStr = `${formatPct(opsPct)}${indicator(opsPct, true)}`;
const p75Str = `${formatPct(p75Pct)}${indicator(p75Pct, false)}`;
const row = `| ${name} | ${formatOps(b.opsSec)} | ${formatOps(c.opsSec)} | ${opsStr} | ${p75Str} |`;
console.log(row);
mdLines.push(row);
}
console.log();
mdLines.push('');
}
const resultsDir = join(__dirname, 'results');
mkdirSync(resultsDir, { recursive: true });
const curLabelSlug = curLabel.replace(/[^a-zA-Z0-9-_]/g, '_');
const baseLabelSlug = baseLabel.replace(/[^a-zA-Z0-9-_]/g, '_');
const outPath = join(
resultsDir,
`compare-${baseLabelSlug}-vs-${curLabelSlug}.md`
);
writeFileSync(outPath, mdLines.join('\n'));
console.log(`Comparison saved to: ${outPath}`);
-43
View File
@@ -1,43 +0,0 @@
var async = require('neo-async'),
fs = require('fs'),
zlib = require('zlib');
module.exports = function (grunt, callback) {
var distFiles = fs.readdirSync('dist'),
distSizes = {};
async.each(
distFiles,
function (file, callback) {
var content;
try {
content = fs.readFileSync('dist/' + file);
} catch (err) {
if (err.code === 'EISDIR') {
callback();
return;
} else {
throw err;
}
}
file = file.replace(/\.js/, '').replace(/\./g, '_');
distSizes[file] = content.length;
zlib.gzip(content, function (err, data) {
if (err) {
throw err;
}
distSizes[file + '_gz'] = data.length;
callback();
});
},
function () {
grunt.log.writeln(
'Distribution sizes: ' + JSON.stringify(distSizes, undefined, 2)
);
callback([distSizes]);
}
);
};
-14
View File
@@ -1,14 +0,0 @@
var fs = require('fs');
var metrics = fs.readdirSync(__dirname);
metrics.forEach(function (metric) {
if (metric === 'index.js' || !/(.*)\.js$/.test(metric)) {
return;
}
var name = RegExp.$1;
metric = require('./' + name);
if (metric instanceof Function) {
module.exports[name] = metric;
}
});
+264
View File
@@ -0,0 +1,264 @@
import { execSync } from 'node:child_process';
import { Bench } from 'tinybench';
import Handlebars from '../../lib/index.js';
import { templates as allTemplates } from './templates.mjs';
import {
printResults,
printSectionHeader,
saveMarkdownReport,
} from './report.mjs';
// ─── Configuration ───────────────────────────────────────────────────────────
// Compilation happens once at app startup — low warmup reflects real-world conditions.
// Execution happens thousands of times — higher warmup lets V8 optimize hot paths.
const COMPILE_BENCH_CONFIG = {
warmupIterations: 10,
iterations: 1000,
time: 3000,
};
const EXEC_BENCH_CONFIG = {
warmupIterations: 500,
iterations: 5000,
time: 3000,
};
// ─── CLI Args ────────────────────────────────────────────────────────────────
function getGitBranch() {
try {
return execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf8',
}).trim();
} catch {
return null;
}
}
const args = process.argv.slice(2);
const labelIdx = args.indexOf('--label');
const label = labelIdx !== -1 ? args[labelIdx + 1] : getGitBranch();
const grepIdx = args.indexOf('--grep');
let grepPattern = null;
if (grepIdx !== -1 && args[grepIdx + 1]) {
try {
grepPattern = new RegExp(args[grepIdx + 1], 'i');
} catch (e) {
console.error(`Invalid --grep pattern: ${e.message}`);
process.exit(1);
}
}
// ─── Filter templates ───────────────────────────────────────────────────────
const templates = Object.fromEntries(
Object.entries(allTemplates).filter(
([name]) => !grepPattern || grepPattern.test(name)
)
);
if (Object.keys(templates).length === 0) {
console.error(
`No templates match --grep ${grepPattern}. Available: ${Object.keys(allTemplates).join(', ')}`
);
process.exit(1);
}
if (grepPattern) {
console.log(
`Filtering templates: ${Object.keys(templates).length}/${Object.keys(allTemplates).length} match /${grepPattern.source}/i`
);
console.log();
}
// ─── Bench helpers ───────────────────────────────────────────────────────────
function newBench(config) {
return new Bench(config);
}
function createEnv(def) {
const hb = Handlebars.create();
if (def.helpers) {
hb.registerHelper(def.helpers);
}
if (def.partials) {
for (const [name, tpl] of Object.entries(def.partials)) {
hb.registerPartial(name, tpl);
}
}
return hb;
}
async function runSection(title, config, setup) {
printSectionHeader(title);
const bench = newBench(config);
setup(bench);
await bench.run();
printResults(bench);
console.log();
return { title, bench };
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function run() {
const now = new Date();
const headerLabel = label ? ` [${label}]` : '';
console.log(`Handlebars Performance Benchmark${headerLabel}`);
console.log('================================');
console.log(
`tinybench | compile: warmup ${COMPILE_BENCH_CONFIG.warmupIterations}, min ${COMPILE_BENCH_CONFIG.iterations} | exec: warmup ${EXEC_BENCH_CONFIG.warmupIterations}, min ${EXEC_BENCH_CONFIG.iterations} | time: ${EXEC_BENCH_CONFIG.time}ms per bench`
);
console.log(`Node ${process.version} | ${process.platform} ${process.arch}`);
console.log();
const allSections = [];
// ─── COMPILATION ────────────────────────────────────────────────────────────
allSections.push(
await runSection(
'COMPILATION (Handlebars.compile + first render)',
COMPILE_BENCH_CONFIG,
(bench) => {
for (const [name, def] of Object.entries(templates)) {
const hb = createEnv(def);
// Handlebars.compile() uses lazy evaluation — actual compilation
// (parsing, codegen, new Function()) only happens on first invocation.
bench.add(`compile: ${name}`, () => {
hb.compile(def.template)({});
});
}
}
)
);
// ─── EXECUTION + output verification ────────────────────────────────────────
const expectedOutputs = {};
allSections.push(
await runSection(
'EXECUTION (template rendering)',
EXEC_BENCH_CONFIG,
(bench) => {
for (const [name, def] of Object.entries(templates)) {
const compiled = createEnv(def).compile(def.template);
expectedOutputs[name] = compiled(def.context);
bench.add(`exec: ${name}`, () => {
compiled(def.context);
});
}
}
)
);
// Verify outputs haven't changed during benchmarking
let verifyFails = 0;
for (const [name, def] of Object.entries(templates)) {
const compiled = createEnv(def).compile(def.template);
const actual = compiled(def.context);
if (actual !== expectedOutputs[name]) {
console.warn(
` WARNING: output mismatch for "${name}"\n expected: ${JSON.stringify(expectedOutputs[name])}\n actual: ${JSON.stringify(actual)}`
);
verifyFails++;
}
}
if (verifyFails === 0) {
console.log('Output verification: all templates OK');
} else {
console.warn(`Output verification: ${verifyFails} mismatch(es)`);
}
console.log();
// ─── PRECOMPILATION ─────────────────────────────────────────────────────────
allSections.push(
await runSection(
'PRECOMPILATION (Handlebars.precompile)',
COMPILE_BENCH_CONFIG,
(bench) => {
for (const [name, def] of Object.entries(templates)) {
bench.add(`precompile: ${name}`, () => {
Handlebars.precompile(def.template);
});
}
}
)
);
// ─── END-TO-END ─────────────────────────────────────────────────────────────
allSections.push(
await runSection(
'END-TO-END (compile + render)',
COMPILE_BENCH_CONFIG,
(bench) => {
for (const [name, def] of Object.entries(templates)) {
const hb = createEnv(def);
bench.add(`e2e: ${name}`, () => {
const fn = hb.compile(def.template);
fn(def.context);
});
}
}
)
);
// ─── COMPILE OPTIONS ───────────────────────────────────────────────────────
allSections.push(
await runSection(
'COMPILE OPTIONS COMPARISON',
EXEC_BENCH_CONFIG,
(bench) => {
const src = allTemplates['complex (if/each/helpers)'].template;
const ctx = allTemplates['complex (if/each/helpers)'].context;
const defaultFn = Handlebars.compile(src);
bench.add('exec: default options', () => defaultFn(ctx));
const noEscapeFn = Handlebars.compile(src, { noEscape: true });
bench.add('exec: noEscape=true', () => noEscapeFn(ctx));
const strictFn = Handlebars.compile(src, {
strict: true,
assumeObjects: true,
});
bench.add('exec: strict + assumeObjects', () => strictFn(ctx));
const knownFn = Handlebars.compile(src, {
knownHelpers: { if: true, each: true },
knownHelpersOnly: false,
});
bench.add('exec: knownHelpers', () => knownFn(ctx));
const compatFn = Handlebars.compile(src, { compat: true });
bench.add('exec: compat=true', () => compatFn(ctx));
const noDataFn = Handlebars.compile(src, { data: false });
bench.add('exec: data=false', () => noDataFn(ctx));
}
)
);
const filepath = saveMarkdownReport(allSections, {
label,
compileConfig: COMPILE_BENCH_CONFIG,
execConfig: EXEC_BENCH_CONFIG,
date: now,
});
console.log(`Results saved to: ${filepath}`);
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
-24
View File
@@ -1,24 +0,0 @@
var _ = require('underscore'),
templates = require('./templates');
module.exports = function (grunt, callback) {
// Deferring to here in case we have a build for parser, etc as part of this grunt exec
var Handlebars = require('../../lib');
var templateSizes = {};
_.each(templates, function (info, template) {
var src = info.handlebars,
compiled = Handlebars.precompile(src, {}),
knownHelpers = Handlebars.precompile(src, {
knownHelpersOnly: true,
knownHelpers: info.helpers,
});
templateSizes[template] = compiled.length;
templateSizes['knownOnly_' + template] = knownHelpers.length;
});
grunt.log.writeln(
'Precompiled sizes: ' + JSON.stringify(templateSizes, undefined, 2)
);
callback([templateSizes]);
};
+126
View File
@@ -0,0 +1,126 @@
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// ─── Formatting ──────────────────────────────────────────────────────────────
export function formatNs(ns) {
if (ns < 1000) return `${ns.toFixed(0)}ns`;
if (ns < 1_000_000) return `${(ns / 1000).toFixed(2)}µs`;
return `${(ns / 1_000_000).toFixed(2)}ms`;
}
export function formatOps(ops) {
if (ops >= 1_000_000) return `${(ops / 1_000_000).toFixed(2)}M`;
if (ops >= 1000) return `${(ops / 1000).toFixed(2)}K`;
return ops.toFixed(0);
}
function taskToRow(task) {
const { latency, throughput } = task.result;
const toNs = (ms) => ms * 1_000_000;
return {
name: task.name,
opsSec: formatOps(throughput.mean),
avg: formatNs(toNs(latency.mean)),
p50: formatNs(toNs(latency.p50)),
p75: formatNs(toNs(latency.p75)),
p99: formatNs(toNs(latency.p99)),
rme: latency.rme.toFixed(2),
samples: latency.samplesCount,
};
}
function completedTasks(bench) {
return bench.tasks.filter((t) => t.result?.state === 'completed');
}
// ─── Console output ──────────────────────────────────────────────────────────
export function printResults(bench) {
for (const task of bench.tasks) {
if (!task.result || task.result.state !== 'completed') {
console.error(
` FAILED: ${task.name}`,
task.result?.error || 'no result'
);
}
}
const results = completedTasks(bench).map((task) => {
const row = taskToRow(task);
return {
Name: row.name,
'ops/sec': row.opsSec,
avg: row.avg,
p50: row.p50,
p75: row.p75,
p99: row.p99,
'±%': row.rme,
samples: row.samples,
};
});
console.table(results);
}
export function printSectionHeader(title) {
const padded = ` ${title} `;
const width = Math.max(padded.length + 4, 59);
const inner = padded.padEnd(width - 2);
console.log(`${'─'.repeat(width - 2)}`);
console.log(`${inner}`);
console.log(`${'─'.repeat(width - 2)}`);
console.log();
}
// ─── Markdown report ─────────────────────────────────────────────────────────
export function saveMarkdownReport(
sections,
{ label, compileConfig, execConfig, date }
) {
const resultsDir = join(__dirname, 'results');
mkdirSync(resultsDir, { recursive: true });
const timestamp = date.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const labelSlug = label ? `-${label.replace(/[^a-zA-Z0-9-_]/g, '_')}` : '';
const filename = `bench-${timestamp}${labelSlug}.md`;
const filepath = join(resultsDir, filename);
const lines = [];
const labelStr = label ? ` [${label}]` : '';
lines.push(`# Handlebars Benchmark Results${labelStr}`);
lines.push('');
lines.push(`- **Date:** ${date.toISOString()}`);
if (label) lines.push(`- **Label:** ${label}`);
lines.push(`- **Node:** ${process.version}`);
lines.push(`- **Platform:** ${process.platform} ${process.arch}`);
lines.push(
`- **Compile config:** warmup=${compileConfig.warmupIterations}, minIterations=${compileConfig.iterations}, time=${compileConfig.time}ms`
);
lines.push(
`- **Exec config:** warmup=${execConfig.warmupIterations}, minIterations=${execConfig.iterations}, time=${execConfig.time}ms`
);
lines.push('');
for (const { title, bench } of sections) {
lines.push(`## ${title}`);
lines.push('');
lines.push(
'| Benchmark | ops/sec | avg | p50 | p75 | p99 | ±% | samples |'
);
lines.push('|---|---|---|---|---|---|---|---|');
for (const row of completedTasks(bench).map(taskToRow)) {
lines.push(
`| ${row.name} | ${row.opsSec} | ${row.avg} | ${row.p50} | ${row.p75} | ${row.p99} | ${row.rme} | ${row.samples} |`
);
}
lines.push('');
}
writeFileSync(filepath, lines.join('\n'));
return filepath;
}
+94
View File
@@ -0,0 +1,94 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
import { gzip } from 'node:zlib';
import { promisify } from 'node:util';
import Handlebars from '../../lib/index.js';
import { templates } from './templates.mjs';
const gzipAsync = promisify(gzip);
// ─── Dist sizes ──────────────────────────────────────────────────────────────
async function measureDistSizes() {
const distDir = join(__dirname, '..', 'dist');
let files;
try {
files = readdirSync(distDir).filter((f) => {
try {
return statSync(join(distDir, f)).isFile();
} catch {
return false;
}
});
} catch {
console.warn('dist/ directory not found — run `npm run build` first.');
return [];
}
const results = [];
for (const file of files) {
const content = readFileSync(join(distDir, file));
const gzipped = await gzipAsync(content);
results.push({
File: file,
'Raw (bytes)': content.length,
'Gzip (bytes)': gzipped.length,
});
}
return results;
}
// ─── Precompile sizes ────────────────────────────────────────────────────────
function measurePrecompileSizes() {
const results = [];
for (const [name, def] of Object.entries(templates)) {
const compiled = Handlebars.precompile(def.template, {});
const helpers = {};
if (def.helpers) {
for (const k of Object.keys(def.helpers)) {
helpers[k] = true;
}
}
const knownOnly = Handlebars.precompile(def.template, {
knownHelpersOnly: true,
knownHelpers: helpers,
});
results.push({
Template: name,
'Default (chars)': compiled.length,
'knownHelpersOnly (chars)': knownOnly.length,
'Saved (chars)': compiled.length - knownOnly.length,
});
}
return results;
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function run() {
console.log('Handlebars Size Report');
console.log('=====================');
console.log();
const distSizes = await measureDistSizes();
if (distSizes.length > 0) {
console.log('## Distribution files');
console.table(distSizes);
console.log();
}
console.log('## Precompiled template sizes');
const precompileSizes = measurePrecompileSizes();
console.table(precompileSizes);
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
+265
View File
@@ -0,0 +1,265 @@
export const templates = {
// --- Small templates ---
'static string (no expressions)': {
template: 'Hello world',
context: {},
},
'simple variables': {
template: 'Hello {{name}}! You have {{count}} new messages.',
context: { name: 'Mick', count: 30 },
},
'dot paths': {
template:
'{{person.name.bar.baz}}{{person.age}}{{person.foo}}{{animal.age}}',
context: { person: { name: { bar: { baz: 'Larry' } }, age: 45 } },
},
'with helper': {
template: '{{#with person}}{{name}}{{age}}{{/with}}',
context: { person: { name: 'Larry', age: 45 } },
},
// --- Arguments & mustache-style ---
'arguments (positional + hash)': {
template:
'{{foo person "person" 1 true foo=bar foo="person" foo=1 foo=true}}',
context: { bar: true },
helpers: { foo: () => '' },
},
'depth-1 (../)': {
template: '{{#each names}}{{../foo}}{{/each}}',
context: {
names: [
{ name: 'Moe' },
{ name: 'Larry' },
{ name: 'Curly' },
{ name: 'Shemp' },
],
foo: 'bar',
},
},
'mustache-style section (array)': {
template: '{{#names}}{{name}}{{/names}}',
context: {
names: [
{ name: 'Moe' },
{ name: 'Larry' },
{ name: 'Curly' },
{ name: 'Shemp' },
],
},
},
'mustache-style section (object)': {
template: '{{#person}}{{name}}{{age}}{{/person}}',
context: { person: { name: 'Larry', age: 45 } },
},
// --- Medium templates ---
'each (small array, 4 items)': {
template: '{{#each names}}{{name}}{{/each}}',
context: {
names: [
{ name: 'Moe' },
{ name: 'Larry' },
{ name: 'Curly' },
{ name: 'Shemp' },
],
},
},
'each with @index/@key': {
template: '{{#each names}}{{@index}}:{{name}} {{/each}}',
context: {
names: [
{ name: 'Moe' },
{ name: 'Larry' },
{ name: 'Curly' },
{ name: 'Shemp' },
],
},
},
'if/else conditional': {
template:
'{{#if active}}<span class="active">{{name}}</span>{{else}}<span>{{name}}</span>{{/if}}',
context: { active: true, name: 'Widget' },
},
'partial (each + partial)': {
template: '{{#each peeps}}{{>variables}}{{/each}}',
context: {
peeps: [
{ name: 'Moe', count: 15 },
{ name: 'Larry', count: 5 },
{ name: 'Curly', count: 1 },
],
},
partials: {
variables: 'Hello {{name}}! You have {{count}} new messages.',
},
},
'nested depth (../../)': {
template:
'{{#each names}}{{#each name}}{{../bat}}{{../../foo}}{{/each}}{{/each}}',
context: {
names: [
{ bat: 'foo', name: ['Moe'] },
{ bat: 'foo', name: ['Larry'] },
{ bat: 'foo', name: ['Curly'] },
{ bat: 'foo', name: ['Shemp'] },
],
foo: 'bar',
},
},
subexpressions: {
template: '{{echo (header)}}',
context: { echo: () => {}, header: () => {} },
helpers: {
echo: (value) => 'foo ' + value,
header: () => 'Colors',
},
},
// --- Complex template ---
'complex (if/each/helpers)': {
template: `<h1>{{header}}</h1>
{{#if items}}
<ul>
{{#each items}}
{{#if current}}
<li><strong>{{name}}</strong></li>
{{^}}
<li><a href="{{url}}">{{name}}</a></li>
{{/if}}
{{/each}}
</ul>
{{^}}
<p>The list is empty.</p>
{{/if}}`,
context: {
header() {
return 'Colors';
},
hasItems: true,
items: [
{ name: 'red', current: true, url: '#Red' },
{ name: 'green', current: false, url: '#Green' },
{ name: 'blue', current: false, url: '#Blue' },
],
},
},
'recursive partials': {
template: '{{name}}{{#each kids}}{{>recursion}}{{/each}}',
context: {
name: '1',
kids: [{ name: '1.1', kids: [{ name: '1.1.1', kids: [] }] }],
},
partials: {
recursion: '{{name}}{{#each kids}}{{>recursion}}{{/each}}',
},
},
// --- Large/stress templates ---
'each (large array, 100 items)': {
template:
'{{#each items}}<div class="item"><h3>{{title}}</h3><p>{{description}}</p><span>{{price}}</span></div>{{/each}}',
context: {
items: Array.from({ length: 100 }, (_, i) => ({
title: `Item ${i}`,
description: `Description for item ${i} with some longer text to simulate real content`,
price: `$${(i * 9.99).toFixed(2)}`,
})),
},
},
'each (large array, 1000 items)': {
template: '{{#each items}}{{name}} {{/each}}',
context: {
items: Array.from({ length: 1000 }, (_, i) => ({ name: `item-${i}` })),
},
},
'deeply nested context (4 levels)': {
template: `{{#with level1}}
{{#with level2}}
{{#each items}}
{{#if active}}
{{../../title}}: {{name}} ({{../label}})
{{/if}}
{{/each}}
{{/with}}
{{/with}}`,
context: {
level1: {
title: 'Root',
level2: {
label: 'Section',
items: Array.from({ length: 20 }, (_, i) => ({
name: `item-${i}`,
active: i % 2 === 0,
})),
},
},
},
},
'many partials (10 partials)': {
template: Array.from({ length: 10 }, (_, i) => `{{>partial${i}}}`).join(
'\n'
),
context: { name: 'World', count: 42 },
partials: Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [
`partial${i}`,
`<section><h2>Section {{name}} #${i}</h2><p>Count: {{count}}</p></section>`,
])
),
},
'page template (mixed features)': {
template: `<!DOCTYPE html>
<html>
<head><title>{{title}}</title></head>
<body>
<header>{{>header}}</header>
<nav>
<ul>
{{#each nav}}
<li{{#if active}} class="active"{{/if}}><a href="{{url}}">{{label}}</a></li>
{{/each}}
</ul>
</nav>
<main>
{{#if showBanner}}<div class="banner">{{bannerText}}</div>{{/if}}
{{#each sections}}
<section>
<h2>{{title}}</h2>
{{#each items}}
<div class="card">
<h3>{{name}}</h3>
<p>{{description}}</p>
{{#if featured}}<span class="badge">Featured</span>{{/if}}
</div>
{{/each}}
</section>
{{/each}}
</main>
<footer>{{>footer}}</footer>
</body>
</html>`,
context: {
title: 'My Page',
showBanner: true,
bannerText: 'Welcome!',
nav: [
{ label: 'Home', url: '/', active: true },
{ label: 'About', url: '/about', active: false },
{ label: 'Contact', url: '/contact', active: false },
],
sections: Array.from({ length: 5 }, (_, si) => ({
title: `Section ${si + 1}`,
items: Array.from({ length: 8 }, (_, ii) => ({
name: `Card ${si * 8 + ii + 1}`,
description: `Description for card ${si * 8 + ii + 1}`,
featured: ii === 0,
})),
})),
},
partials: {
header: '<h1>{{title}}</h1>',
footer: '<p>&copy; 2026 {{title}}</p>',
},
},
};
-13
View File
@@ -1,13 +0,0 @@
module.exports = {
helpers: {
foo: function () {
return '';
},
},
context: {
bar: true,
},
handlebars:
'{{foo person "person" 1 true foo=bar foo="person" foo=1 foo=true}}',
};
-13
View File
@@ -1,13 +0,0 @@
module.exports = {
context: {
names: [
{ name: 'Moe' },
{ name: 'Larry' },
{ name: 'Curly' },
{ name: 'Shemp' },
],
},
handlebars: '{{#each names}}{{name}}{{/each}}',
dust: '{#names}{name}{/names}',
mustache: '{{#names}}{{name}}{{/names}}',
};
-11
View File
@@ -1,11 +0,0 @@
module.exports = {
context: {
names: [
{ name: 'Moe' },
{ name: 'Larry' },
{ name: 'Curly' },
{ name: 'Shemp' },
],
},
handlebars: '{{#names}}{{name}}{{/names}}',
};
-14
View File
@@ -1,14 +0,0 @@
<h1>{header}</h1>
{?items}
<ul>
{#items}
{#current}
<li><strong>{name}</strong></li>
{:else}
<li><a href="{url}">{name}</a></li>
{/current}
{/items}
</ul>
{:else}
<p>The list is empty.</p>
{/items}
-14
View File
@@ -1,14 +0,0 @@
<h1>{{header}}</h1>
{{#if items}}
<ul>
{{#each items}}
{{#if current}}
<li><strong>{{name}}</strong></li>
{{^}}
<li><a href="{{url}}">{{name}}</a></li>
{{/if}}
{{/each}}
</ul>
{{^}}
<p>The list is empty.</p>
{{/if}}
-19
View File
@@ -1,19 +0,0 @@
var fs = require('fs');
module.exports = {
context: {
header: function () {
return 'Colors';
},
hasItems: true, // To make things fairer in mustache land due to no `{{if}}` construct on arrays
items: [
{ name: 'red', current: true, url: '#Red' },
{ name: 'green', current: false, url: '#Green' },
{ name: 'blue', current: false, url: '#Blue' },
],
},
handlebars: fs.readFileSync(__dirname + '/complex.handlebars').toString(),
dust: fs.readFileSync(__dirname + '/complex.dust').toString(),
mustache: fs.readFileSync(__dirname + '/complex.mustache').toString(),
};
-13
View File
@@ -1,13 +0,0 @@
<h1>{{header}}</h1>
{{#hasItems}}
<ul>
{{#items}}
{{#current}}
<li><strong>{{name}}</strong></li>
{{/current}}
{{^current}}
<li><a href="{{url}}">{{name}}</a></li>
{{/current}}
{{/items}}
</ul>
{{/hasItems}}
-11
View File
@@ -1,11 +0,0 @@
module.exports = {
context: {
names: [
{ name: 'Moe' },
{ name: 'Larry' },
{ name: 'Curly' },
{ name: 'Shemp' },
],
},
handlebars: '{{#each names}}{{@index}}{{name}}{{/each}}',
};
-13
View File
@@ -1,13 +0,0 @@
module.exports = {
context: {
names: [
{ name: 'Moe' },
{ name: 'Larry' },
{ name: 'Curly' },
{ name: 'Shemp' },
],
foo: 'bar',
},
handlebars: '{{#each names}}{{../foo}}{{/each}}',
mustache: '{{#names}}{{foo}}{{/names}}',
};
-14
View File
@@ -1,14 +0,0 @@
module.exports = {
context: {
names: [
{ bat: 'foo', name: ['Moe'] },
{ bat: 'foo', name: ['Larry'] },
{ bat: 'foo', name: ['Curly'] },
{ bat: 'foo', name: ['Shemp'] },
],
foo: 'bar',
},
handlebars:
'{{#each names}}{{#each name}}{{../bat}}{{../../foo}}{{/each}}{{/each}}',
mustache: '{{#names}}{{#name}}{{bat}}{{foo}}{{/name}}{{/names}}',
};
-9
View File
@@ -1,9 +0,0 @@
var fs = require('fs');
var templates = fs.readdirSync(__dirname);
templates.forEach(function (template) {
if (template === 'index.js' || !/(.*)\.js$/.test(template)) {
return;
}
module.exports[RegExp.$1] = require('./' + RegExp.$1);
});
-4
View File
@@ -1,4 +0,0 @@
module.exports = {
context: { person: { name: 'Larry', age: 45 } },
handlebars: '{{#person}}{{name}}{{age}}{{/person}}',
};
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
context: { person: { name: 'Larry', age: 45 } },
handlebars: '{{#with person}}{{name}}{{age}}{{/with}}',
dust: '{#person}{name}{age}{/person}',
mustache: '{{#person}}{{name}}{{age}}{{/person}}',
};
@@ -1,13 +0,0 @@
module.exports = {
context: {
name: '1',
kids: [{ name: '1.1', kids: [{ name: '1.1.1', kids: [] }] }],
},
partials: {
mustache: { recursion: '{{name}}{{#kids}}{{>recursion}}{{/kids}}' },
handlebars: { recursion: '{{name}}{{#each kids}}{{>recursion}}{{/each}}' },
},
handlebars: '{{name}}{{#each kids}}{{>recursion}}{{/each}}',
dust: '{name}{#kids}{>recursion:./}{/kids}',
mustache: '{{name}}{{#kids}}{{>recursion}}{{/kids}}',
};
-19
View File
@@ -1,19 +0,0 @@
module.exports = {
context: {
peeps: [
{ name: 'Moe', count: 15 },
{ name: 'Larry', count: 5 },
{ name: 'Curly', count: 1 },
],
},
partials: {
mustache: { variables: 'Hello {{name}}! You have {{count}} new messages.' },
handlebars: {
variables: 'Hello {{name}}! You have {{count}} new messages.',
},
},
handlebars: '{{#each peeps}}{{>variables}}{{/each}}',
dust: '{#peeps}{>variables/}{/peeps}',
mustache: '{{#peeps}}{{>variables}}{{/peeps}}',
};
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
context: { person: { name: { bar: { baz: 'Larry' } }, age: 45 } },
handlebars:
'{{person.name.bar.baz}}{{person.age}}{{person.foo}}{{animal.age}}',
dust: '{person.name.bar.baz}{person.age}{person.foo}{animal.age}',
mustache: '{{person.name.bar.baz}}{{person.age}}{{person.foo}}{{animal.age}}',
};
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
context: {},
handlebars: 'Hello world',
dust: 'Hello world',
mustache: 'Hello world',
};
-13
View File
@@ -1,13 +0,0 @@
module.exports = {
helpers: {
echo: function (value) {
return 'foo ' + value;
},
header: function () {
return 'Colors';
},
},
handlebars: '{{echo (header)}}',
};
module.exports.context = module.exports.helpers;
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
context: { name: 'Mick', count: 30 },
handlebars: 'Hello {{name}}! You have {{count}} new messages.',
dust: 'Hello {name}! You have {count} new messages.',
mustache: 'Hello {{name}}! You have {{count}} new messages.',
};
-131
View File
@@ -1,131 +0,0 @@
var _ = require('underscore'),
runner = require('./util/template-runner'),
dust,
Handlebars,
Mustache;
try {
dust = require('dustjs-linkedin');
} catch {
/* NOP */
}
try {
Mustache = require('mustache');
} catch {
/* NOP */
}
function error() {
throw new Error('EWOT');
}
function makeSuite(bench, name, template, handlebarsOnly) {
// Create aliases to minimize any impact from having to walk up the closure tree.
var templateName = name,
context = template.context,
partials = template.partials,
handlebarsOut,
compatOut,
dustOut,
mustacheOut;
var handlebar = Handlebars.compile(template.handlebars, { data: false }),
compat = Handlebars.compile(template.handlebars, {
data: false,
compat: true,
}),
options = { helpers: template.helpers };
_.each(
template.partials && template.partials.handlebars,
function (partial, partialName) {
Handlebars.registerPartial(
partialName,
Handlebars.compile(partial, { data: false })
);
}
);
handlebarsOut = handlebar(context, options);
bench('handlebars', function () {
handlebar(context, options);
});
compatOut = compat(context, options);
bench('compat', function () {
compat(context, options);
});
if (handlebarsOnly) {
return;
}
if (dust) {
if (template.dust) {
dustOut = false;
dust.loadSource(dust.compile(template.dust, templateName));
dust.render(templateName, context, function (err, out) {
dustOut = out;
});
bench('dust', function () {
dust.render(templateName, context, function () {});
});
} else {
bench('dust', error);
}
}
if (Mustache) {
var mustacheSource = template.mustache,
mustachePartials = partials && partials.mustache;
if (mustacheSource) {
mustacheOut = Mustache.to_html(mustacheSource, context, mustachePartials);
bench('mustache', function () {
Mustache.to_html(mustacheSource, context, mustachePartials);
});
} else {
bench('mustache', error);
}
}
// Hack around whitespace until we have whitespace control
handlebarsOut = handlebarsOut.replace(/\s/g, '');
function compare(b, lang) {
if (b == null) {
return;
}
b = b.replace(/\s/g, '');
if (handlebarsOut !== b) {
throw new Error(
'Template output mismatch: ' +
name +
'\n\nHandlebars: ' +
handlebarsOut +
'\n\n' +
lang +
': ' +
b
);
}
}
compare(compatOut, 'compat');
compare(dustOut, 'dust');
compare(mustacheOut, 'mustache');
}
module.exports = function (grunt, callback) {
// Deferring load in case we are being run inline with the grunt build
Handlebars = require('../../lib');
console.log('Execution Throughput');
runner(grunt, makeSuite, function (times, scaled) {
callback(scaled);
});
};
-222
View File
@@ -1,222 +0,0 @@
var _ = require('underscore'),
Benchmark = require('benchmark');
function BenchWarmer() {
this.benchmarks = [];
this.currentBenches = [];
this.names = [];
this.times = {};
this.minimum = Infinity;
this.maximum = -Infinity;
this.errors = {};
}
BenchWarmer.prototype = {
winners: function (benches) {
return Benchmark.filter(benches, 'fastest');
},
suite: function (suite, fn) {
this.suiteName = suite;
this.times[suite] = {};
this.first = true;
var self = this;
fn(function (name, benchFn) {
self.push(name, benchFn);
});
},
push: function (name, fn) {
if (this.names.indexOf(name) === -1) {
this.names.push(name);
}
var first = this.first,
suiteName = this.suiteName,
self = this;
this.first = false;
var bench = new Benchmark(fn, {
name: this.suiteName + ': ' + name,
onComplete: function () {
if (first) {
self.startLine(suiteName);
}
self.writeBench(bench);
self.currentBenches.push(bench);
},
onError: function () {
self.errors[this.name] = this;
},
});
bench.suiteName = this.suiteName;
bench.benchName = name;
this.benchmarks.push(bench);
},
bench: function (callback) {
var self = this;
this.printHeader('ops/msec', true);
Benchmark.invoke(this.benchmarks, {
name: 'run',
onComplete: function () {
self.scaleTimes();
self.startLine('');
console.log('\n');
self.printHeader('scaled');
_.each(self.scaled, function (value, name) {
self.startLine(name);
_.each(self.names, function (lang) {
self.writeValue(value[lang] || '');
});
});
console.log('\n');
var errors = false,
prop,
bench;
for (prop in self.errors) {
if (
Object.prototype.hasOwnProperty.call(self, prop) &&
self.errors[prop].error.message !== 'EWOT'
) {
errors = true;
break;
}
}
if (errors) {
console.log('\n\nErrors:\n');
Object.keys(self.errors).forEach(function (prop) {
if (self.errors[prop].error.message !== 'EWOT') {
bench = self.errors[prop];
console.log('\n' + bench.name + ':\n');
console.log(bench.error.message);
if (bench.error.stack) {
console.log(bench.error.stack.join('\n'));
}
console.log('\n');
}
});
}
callback();
},
});
console.log('\n');
},
scaleTimes: function () {
var scaled = (this.scaled = {});
_.each(
this.times,
function (times, name) {
var output = (scaled[name] = {});
_.each(
times,
function (time, lang) {
output[lang] = (
((time - this.minimum) / (this.maximum - this.minimum)) *
100
).toFixed(2);
},
this
);
},
this
);
},
printHeader: function (title, winners) {
var benchSize = 0,
names = this.names,
i,
l;
for (i = 0, l = names.length; i < l; i++) {
var name = names[i];
if (benchSize < name.length) {
benchSize = name.length;
}
}
this.nameSize = benchSize + 2;
this.benchSize = 20;
var horSize = 0;
this.startLine(title);
horSize = horSize + this.benchSize;
for (i = 0, l = names.length; i < l; i++) {
this.writeValue(names[i]);
horSize = horSize + this.benchSize;
}
if (winners) {
console.log('WINNER(S)');
horSize = horSize + 'WINNER(S)'.length;
}
console.log('\n' + '-'.repeat(horSize));
},
startLine: function (name) {
var winners = Benchmark.map(
this.winners(this.currentBenches),
function (bench) {
return bench.name.split(': ')[1];
}
);
this.currentBenches = [];
console.log(winners.join(', '));
console.log('\n');
if (name) {
this.writeValue(name);
}
},
writeBench: function (bench) {
var out;
if (!bench.error) {
var count = bench.hz,
moe = (count * bench.stats.rme) / 100,
minimum,
maximum;
count = Math.round(count / 1000);
moe = Math.round(moe / 1000);
minimum = count - moe;
maximum = count + moe;
out = count + ' ±' + moe + ' (' + bench.cycles + ')';
this.times[bench.suiteName][bench.benchName] = count;
this.minimum = Math.min(this.minimum, minimum);
this.maximum = Math.max(this.maximum, maximum);
} else if (bench.error.message === 'EWOT') {
out = 'NA';
} else {
out = 'E';
}
this.writeValue(out);
},
writeValue: function (out) {
var padding = this.benchSize - out.length + 1;
out = out + ' '.repeat(Math.max(0, padding - 1));
console.log(out);
},
};
module.exports = BenchWarmer;
-29
View File
@@ -1,29 +0,0 @@
var _ = require('underscore'),
BenchWarmer = require('./benchwarmer'),
templates = require('../templates');
module.exports = function (grunt, makeSuite, callback) {
var warmer = new BenchWarmer();
var handlebarsOnly = grunt.option('handlebars-only'),
grep = grunt.option('grep');
if (grep) {
grep = new RegExp(grep);
}
_.each(templates, function (template, name) {
if (!template.handlebars || (grep && !grep.test(name))) {
return;
}
warmer.suite(name, function (bench) {
makeSuite(bench, name, template, handlebarsOnly);
});
});
warmer.bench(function () {
if (callback) {
callback(warmer.times, warmer.scaled);
}
});
};