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
+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);
}
});
};