Files
handlebars.js/tests/bench/perf.js
T
2026-04-21 22:02:44 +03:00

289 lines
9.3 KiB
JavaScript

import { execSync } from 'node:child_process';
import { Bench } from 'tinybench';
import Handlebars from '../../lib/index.js';
import { templates as allTemplates } from './templates.js';
import {
printResults,
printSectionHeader,
saveMarkdownReport,
} from './report.js';
// ─── 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);
}
}
const sectionIdx = args.indexOf('--section');
let sectionPattern = null;
if (sectionIdx !== -1 && args[sectionIdx + 1]) {
try {
sectionPattern = new RegExp(args[sectionIdx + 1], 'i');
} catch (e) {
console.error(`Invalid --section 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;
}
function shouldRunSection(title) {
if (!sectionPattern) return true;
return sectionPattern.test(title);
}
async function runSection(title, config, setup) {
if (!shouldRunSection(title)) return null;
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 = [];
if (sectionPattern) {
console.log(`Filtering sections: /${sectionPattern.source}/i`);
console.log();
}
// ─── 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 = {};
const execResult = 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);
});
}
}
);
allSections.push(execResult);
// Verify outputs haven't changed during benchmarking
if (execResult) {
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.filter(Boolean), {
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);
});