From a54d0397b087b2d17c581d296d4551249c5510d2 Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Fri, 22 May 2026 02:16:50 -0400 Subject: [PATCH] perf(bench): Expand hot-path benchmark coverage Expand the benchmark suite to answer the performance questions raised by the latest undent work instead of relying on a few broad comparisons. The new cases measure short single-line string fast paths, already-clean inputs, mixed-newline inputs, Unicode-aware alignment, and scaling behavior across more realistic line counts. Add a small curiosity section for string-building strategies so repeat-heavy helpers can be compared against native primitives with the same harness. Keeping these measurements in their own commit makes it easier to review performance intent without mixing it into the user-facing feature diff. --- mod_bench.ts | 651 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 631 insertions(+), 20 deletions(-) diff --git a/mod_bench.ts b/mod_bench.ts index 9059a4d..34089d9 100644 --- a/mod_bench.ts +++ b/mod_bench.ts @@ -23,6 +23,7 @@ * 8. Primitives — exported utilities in isolation * 9. Pathological — worst-case inputs * 10. Real-world scenarios — common usage patterns + * 11. Curiosity — string-building microbenchmarks * * Memory regression tests live in mod_memory_test.ts and run as part * of `deno task test`. @@ -49,6 +50,11 @@ import undent, { rejoinLines, splitLines, } from "./mod.ts"; +import { + createUnicodeColumnOffset, + unicodeColumnOffset, + visualColumnWidth, +} from "./unicode.ts"; // Competitors import npmDedent from "npm:dedent"; @@ -69,6 +75,19 @@ function makeLines(count: number, indent = " "): string { return out.join("\n"); } +function makeLineScaleData( + min: number, + max: number, + indent = ' ', +): Record { + const out = Object.create(null) as Record; + for (let size = min; size <= max; size *= 2) { + out[size] = makeLines(size, indent); + } + + return out; +} + function makeTSA(segmentCount: number, indent = " "): TemplateStringsArray { const strings: string[] = []; for (let i = 0; i < segmentCount; i++) { @@ -80,6 +99,30 @@ function makeTSA(segmentCount: number, indent = " "): TemplateStringsArray { }) as unknown as TemplateStringsArray; } +/** + * Repeat a string with manual concatenation. + * + * This exists only for a curiosity benchmark comparing loop-based string + * building with the built-in `.repeat()` primitive. + */ +function repeatWithLoop(text: string, count: number): string { + let out = ""; + for (let i = 0; i < count; i++) { + out += text; + } + return out; +} + +/** + * Repeat a string with `Array(...).fill(...).join("")`. + * + * This is another common hand-written alternative to `.repeat()`, so the + * curiosity benchmark measures it alongside the manual loop. + */ +function repeatWithFillJoin(text: string, count: number): string { + return new Array(count).fill(text).join(''); +} + /** * Competitor-equivalent helper for multiline interpolation alignment. * @@ -110,17 +153,70 @@ function embedLike( // Pre-built data — allocated once, reused across iterations. const SMALL_10 = makeLines(10); +const CLEAN_10 = makeLines(10, ''); +const CLEAN_100 = makeLines(100, ''); +const CLEAN_1K = makeLines(1_000, ''); +const CLEAN_50K = makeLines(50_000, ''); const MED_100 = makeLines(100); const LARGE_1K = makeLines(1_000); const LARGE_5K = makeLines(5_000); const LARGE_10K = makeLines(10_000); +const LARGE_50K = makeLines(50_000); + +const STRING_SCALE_MIN = 8; +const STRING_SCALE_MAX = 16_384; +const STRING_SCALE_INDENTED = makeLineScaleData(STRING_SCALE_MIN, STRING_SCALE_MAX); +const STRING_SCALE_CLEAN = makeLineScaleData(STRING_SCALE_MIN, STRING_SCALE_MAX, ''); const INDENTED_100 = makeLines(100, " "); const INDENTED_1K = makeLines(1_000, " "); +const INDENTED_50K = makeLines(50_000, " "); + +const SHORT_PLAIN = 'hello world'; +const SHORT_INDENTED = ' hello world'; +const PLAIN_LONG_LINE = 'x'.repeat(100_000); const ML_50 = Array.from({ length: 50 }, (_, i) => `item ${i}`).join("\n"); const ML_500 = Array.from({ length: 500 }, (_, i) => `item ${i}`).join("\n"); const ML_5K = Array.from({ length: 5_000 }, (_, i) => `item ${i}`).join("\n"); +const ML_500_MOSTLY_BLANK = Array.from( + { length: 500 }, + (_, i) => i % 8 === 0 ? `item ${i}` : "", +).join("\n"); + +const UNICODE_CJK_500 = Array.from( + { length: 500 }, + (_, i) => `項目${i} 界界`, +).join("\n"); +const UNICODE_EMOJI_500 = Array.from( + { length: 500 }, + (_, i) => `😀 item ${i}`, +).join("\n"); +const UNICODE_COMBINING_500 = Array.from( + { length: 500 }, + (_, i) => `e\u0301 item ${i}`, +).join("\n"); +const UNICODE_TAB_HEAVY_500 = Array.from( + { length: 500 }, + (_, i) => `\t列\t${i}`, +).join("\n"); +const UNICODE_MIXED_COLUMN_LINE = 'prefix 界😀e\u0301\tΩ'; + +// Preconfigure the Unicode-aware measurers once so the benchmark loop only +// measures the alignment work, not repeated option normalization. +const unicodeColumnOffsetDefault = createUnicodeColumnOffset(); +const unicodeColumnOffsetTabs = createUnicodeColumnOffset({ + tabWidth: 4, + ambiguous: 'wide', +}); +const undentAlignUnicode = undent.with({ + alignValues: true, + columnOffset: unicodeColumnOffsetDefault, +}); +const undentAlignUnicodeTabs = undent.with({ + alignValues: true, + columnOffset: unicodeColumnOffsetTabs, +}); const DEEP_INDENT_100 = makeLines(100, " ".repeat(200)); const ALL_BLANK_1K = Array.from({ length: 1000 }, () => " ").join("\n"); @@ -129,6 +225,16 @@ const MIXED_INDENT_500 = Array.from( (_, i) => " ".repeat(i % 20) + `line ${i}`, ).join("\n"); const LONG_LINE = " " + "x".repeat(100_000); +const MIXED_NEWLINES_1K = makeLines(500, " ").replace(/\n/g, (_, i: number) => + i % 3 === 0 ? "\r\n" : i % 3 === 1 ? "\r" : "\n") + + "\r\n" + makeLines(500, " "); +const TRIM_SAMPLE_STRING = "\n\n alpha\n beta\n\n"; + +function makeInlineTSA(prefix: string): TemplateStringsArray { + return Object.assign([prefix, ""], { + raw: [prefix, ""], + }) as unknown as TemplateStringsArray; +} // ========================================================================= // 1. Competitor comparison — undent vs dedent vs outdent @@ -250,7 +356,7 @@ barplot(() => { // Parameterized with computed parameters to prevent LICM. // deno-lint-ignore no-explicit-any -bench(function* tag_N_interpolations(state: any) { +bench('tag: $n interpolations (computed)', function* (state: any) { const n = state.get("n"); const tsa = makeTSA(n); const vals = Array.from({ length: n }, (_, i) => String(i)); @@ -262,33 +368,199 @@ bench(function* tag_N_interpolations(state: any) { do_not_optimize(undent(tsa, ...freshVals)); }, }; -}).args("n", [10, 50, 100]); + }).range('n', 8, 128); // ========================================================================= // 3. String algorithm — .string() scaling // ========================================================================= +// Hot-path string usage often looks much closer to an identity operation than +// to a large formatting transform: a short single-line value arrives, callers +// run `.string()` defensively, and the result should be as close as possible to +// the cost of handing the original string through untouched. +summary(() => { + bench('plain string baseline: short single-line', () => { + do_not_optimize(SHORT_PLAIN); + }); + + bench('undent.string hot path: short single-line', () => { + do_not_optimize(undent.string(SHORT_PLAIN)); + }); + + bench('undent.string hot path: short indented single-line', () => { + do_not_optimize(undent.string(SHORT_INDENTED)); + }); + + bench('dedent(string) hot path: short single-line', () => { + do_not_optimize(npmDedent(SHORT_PLAIN)); + }); + + bench('outdent.string hot path: short single-line', () => { + do_not_optimize(npmOutdent.string(SHORT_PLAIN)); + }); +}); + +summary(() => { + bench('plain string baseline: short single-line ×1000', () => { + let last = ''; + for (let i = 0; i < 1000; i++) { + last = SHORT_PLAIN; + } + do_not_optimize(last); + }); + + bench('undent.string hot path: short single-line ×1000', () => { + let last = ''; + for (let i = 0; i < 1000; i++) { + last = undent.string(SHORT_PLAIN); + } + do_not_optimize(last); + }); + + bench('dedent(string) hot path: short single-line ×1000', () => { + let last = ''; + for (let i = 0; i < 1000; i++) { + last = npmDedent(SHORT_PLAIN); + } + do_not_optimize(last); + }); + + bench('outdent.string hot path: short single-line ×1000', () => { + let last = ''; + for (let i = 0; i < 1000; i++) { + last = npmOutdent.string(SHORT_PLAIN); + } + do_not_optimize(last); + }); +}); + lineplot(() => { // deno-lint-ignore no-explicit-any - bench(function* string_N_lines(state: any) { - const n = state.get("lines"); - const data: Record = { - 10: SMALL_10, - 100: MED_100, - 1000: LARGE_1K, - 5000: LARGE_5K, - 10000: LARGE_10K, + bench('string: indented $lines lines', function* (state: any) { + const lines = state.get('lines'); + const input = STRING_SCALE_INDENTED[lines]!; + + yield { + [0]() { + return input; + }, + + bench(input: string) { + do_not_optimize(undent.string(input)); + }, + }; + }).range('lines', STRING_SCALE_MIN, STRING_SCALE_MAX); + + // Measure the multiline identity path separately so we can see whether the + // early-return fast path stays flat as inputs scale. + bench('string: clean pass-through $lines lines', function* (state: any) { + const lines = state.get('lines'); + const input = STRING_SCALE_CLEAN[lines]!; + + yield { + [0]() { + return input; + }, + + bench(input: string) { + do_not_optimize(undent.string(input)); + }, }; - const input = data[n]!; - yield () => do_not_optimize(undent.string(input)); - }).args("lines", [10, 100, 1000, 5000, 10000]); + }).range('lines', STRING_SCALE_MIN, STRING_SCALE_MAX); +}); + +summary(() => { + bench('plain string baseline: multiline 1K already clean', () => { + do_not_optimize(CLEAN_1K); + }); + + bench('undent.string pass-through: multiline 1K already clean', () => { + do_not_optimize(undent.string(CLEAN_1K)); + }); + + bench('dedent(string) pass-through: multiline 1K already clean', () => { + do_not_optimize(npmDedent(CLEAN_1K)); + }); + + bench('outdent.string pass-through: multiline 1K already clean', () => { + do_not_optimize(npmOutdent.string(CLEAN_1K)); + }); +}); + +summary(() => { + bench('plain string baseline: multiline 1K already clean ×100', () => { + let last = ''; + for (let i = 0; i < 100; i++) { + last = CLEAN_1K; + } + do_not_optimize(last); + }); + + bench('undent.string pass-through: multiline 1K already clean ×100', () => { + let last = ''; + for (let i = 0; i < 100; i++) { + last = undent.string(CLEAN_1K); + } + do_not_optimize(last); + }); }); bench("string: mixed newlines 1K", () => { - const mixed = makeLines(500, " ").replace(/\n/g, (_, i: number) => - i % 3 === 0 ? "\r\n" : i % 3 === 1 ? "\r" : "\n") + - "\r\n" + makeLines(500, " "); - do_not_optimize(undent.string(mixed)); + do_not_optimize(undent.string(MIXED_NEWLINES_1K)); +}); + +// Large single-line strings are a practical hot path in code generation, +// logging, serialization, and HTTP response assembly. These benchmarks show +// how much overhead `.string()` adds above simply holding the original string. +summary(() => { + bench('plain string baseline: 100K-char line', () => { + do_not_optimize(PLAIN_LONG_LINE); + }).gc('inner'); + + bench('undent.string large: 100K-char line plain', () => { + do_not_optimize(undent.string(PLAIN_LONG_LINE)); + }).gc('inner'); + + bench('undent.string large: 100K-char line indented', () => { + do_not_optimize(undent.string(LONG_LINE)); + }).gc('inner'); + + bench('dedent(string) large: 100K-char line plain', () => { + do_not_optimize(npmDedent(PLAIN_LONG_LINE)); + }).gc('inner'); + + bench('outdent.string large: 100K-char line plain', () => { + do_not_optimize(npmOutdent.string(PLAIN_LONG_LINE)); + }).gc('inner'); +}); + +// Very large multi-line inputs matter for generated source files, embedded SQL, +// and templated config blobs. These keep the side-by-side library comparison so +// large-input regressions stay visible. +summary(() => { + bench('undent.string huge: 50K lines already clean', () => { + do_not_optimize(undent.string(CLEAN_50K)); + }).gc('inner'); + + bench('dedent(string) huge: 50K lines already clean', () => { + do_not_optimize(npmDedent(CLEAN_50K)); + }).gc('inner'); + + bench('outdent.string huge: 50K lines already clean', () => { + do_not_optimize(npmOutdent.string(CLEAN_50K)); + }).gc('inner'); + + bench('undent.string huge: 50K lines indented', () => { + do_not_optimize(undent.string(INDENTED_50K)); + }).gc('inner'); + + bench('dedent(string) huge: 50K lines indented', () => { + do_not_optimize(npmDedent(INDENTED_50K)); + }).gc('inner'); + + bench('outdent.string huge: 50K lines indented', () => { + do_not_optimize(npmOutdent.string(INDENTED_50K)); + }).gc('inner'); }); // ========================================================================= @@ -399,6 +671,109 @@ bench("alignValues: 3 multi-line values", () => { `); }); +// Unicode-specific alignment benchmarks compare the default code-unit policy to +// the opt-in visual-width path. Competitor baselines do not exist here because +// dedent/outdent expose no equivalent Unicode column measurement hook. +summary(() => { + const codeUnit = undent.with({ alignValues: true }); + + bench("alignValues columns: ASCII 500 code-unit", () => { + do_not_optimize(codeUnit` + header: + ${ML_500} + `); + }).gc("inner"); + + bench("alignValues columns: ASCII 500 unicode", () => { + do_not_optimize(undentAlignUnicode` + header: + ${ML_500} + `); + }).gc("inner"); + + bench("alignValues columns: CJK 500 unicode", () => { + do_not_optimize(undentAlignUnicode` + header: + ${UNICODE_CJK_500} + `); + }).gc("inner"); + + bench("alignValues columns: emoji 500 unicode", () => { + do_not_optimize(undentAlignUnicode` + header: + ${UNICODE_EMOJI_500} + `); + }).gc("inner"); + + bench("alignValues columns: combining 500 unicode", () => { + do_not_optimize(undentAlignUnicode` + header: + ${UNICODE_COMBINING_500} + `); + }).gc("inner"); + + bench("alignValues columns: tabs 500 unicode", () => { + do_not_optimize(undentAlignUnicodeTabs` + header:\t${UNICODE_TAB_HEAVY_500} + `); + }).gc("inner"); +}); + +summary(() => { + bench("columnOffset: mixed unicode line", () => { + do_not_optimize(columnOffset(UNICODE_MIXED_COLUMN_LINE)); + }); + + bench("unicodeColumnOffset: mixed unicode line", () => { + do_not_optimize(unicodeColumnOffsetDefault(UNICODE_MIXED_COLUMN_LINE)); + }); + + bench("unicodeColumnOffset: tab-aware mixed line", () => { + do_not_optimize(unicodeColumnOffsetTabs(UNICODE_MIXED_COLUMN_LINE)); + }); + + bench("visualColumnWidth: mixed unicode line", () => { + do_not_optimize(visualColumnWidth(UNICODE_MIXED_COLUMN_LINE)); + }); +}); + +summary(() => { + const multi = "x\ny"; + const wrapped = align(multi); + + bench("align branch: no wrapped values", () => { + do_not_optimize(undent` + first: ${multi} + second: ${multi} + third: ${multi} + `); + }); + + bench("align branch: wrapped first interpolation", () => { + do_not_optimize(undent` + first: ${wrapped} + second: ${multi} + third: ${multi} + `); + }); + + bench("align branch: wrapped middle interpolation", () => { + do_not_optimize(undent` + first: ${multi} + second: ${wrapped} + third: ${multi} + `); + }); + + bench("align branch: wrapped last interpolation", () => { + do_not_optimize(undent` + first: ${multi} + second: ${multi} + third: ${wrapped} + `); + }); +}); + // ========================================================================= // 5. Configuration // ========================================================================= @@ -424,6 +799,103 @@ summary(() => { }); }); +summary(() => { + const trimAll = undent; + const trimOne = undent.with({ trim: "one" }); + const trimNone = undent.with({ trim: "none" }); + + bench("trim template: all", () => { + do_not_optimize(trimAll` + + alpha + beta + + `); + }); + + bench("trim template: one", () => { + do_not_optimize(trimOne` + + alpha + beta + + `); + }); + + bench("trim template: none", () => { + do_not_optimize(trimNone` + + alpha + beta + + `); + }); +}); + +summary(() => { + const trimAll = undent; + const trimOne = undent.with({ trim: "one" }); + const trimNone = undent.with({ trim: "none" }); + + bench("trim string: all", () => { + do_not_optimize(trimAll.string(TRIM_SAMPLE_STRING)); + }); + + bench("trim string: one", () => { + do_not_optimize(trimOne.string(TRIM_SAMPLE_STRING)); + }); + + bench("trim string: none", () => { + do_not_optimize(trimNone.string(TRIM_SAMPLE_STRING)); + }); +}); + +summary(() => { + const keep = undent; + const lf = undent.with({ newline: "\n" }); + const crlf = undent.with({ newline: "\r\n" }); + + bench("newline string: preserve original", () => { + do_not_optimize(keep.string(MIXED_NEWLINES_1K)); + }).gc("inner"); + + bench("newline string: normalize to LF", () => { + do_not_optimize(lf.string(MIXED_NEWLINES_1K)); + }).gc("inner"); + + bench("newline string: normalize to CRLF", () => { + do_not_optimize(crlf.string(MIXED_NEWLINES_1K)); + }).gc("inner"); +}); + +summary(() => { + const common = undent; + const first = undent.with({ strategy: "first" }); + const tsas = Array.from({ length: 100 }, () => { + const strings = [ + "\n first\n second\n third\n fourth\n ", + ]; + return Object.assign([...strings], { raw: [...strings] }) as unknown as + TemplateStringsArray; + }); + + bench("strategy template: common cold path ×100", () => { + let last = ""; + for (let i = 0; i < tsas.length; i++) { + last = common(tsas[i]!); + } + do_not_optimize(last); + }); + + bench("strategy template: first cold path ×100", () => { + let last = ""; + for (let i = 0; i < tsas.length; i++) { + last = first(tsas[i]!); + } + do_not_optimize(last); + }); +}); + // ========================================================================= // 6. Cache effectiveness // ========================================================================= @@ -450,6 +922,56 @@ summary(() => { }); }); +summary(() => { + const wrapped = align("alpha\nbeta\ngamma"); + const sameColumnTsa = makeInlineTSA("padding: "); + const varyingColumnTsas = Array.from( + { length: 32 }, + (_, i) => makeInlineTSA(`${" ".repeat(i)}pad: `), + ); + + bench("aligned cache: same wrapper same column ×100", () => { + let last = ""; + for (let i = 0; i < 100; i++) { + last = undent(sameColumnTsa, wrapped); + } + do_not_optimize(last); + }); + + bench("aligned cache: same wrapper varying columns ×100", () => { + let last = ""; + for (let i = 0; i < 100; i++) { + last = undent(varyingColumnTsas[i % varyingColumnTsas.length]!, wrapped); + } + do_not_optimize(last); + }); +}); + +summary(() => { + bench("anchor cache: anchored hot path ×100", () => { + let last = ""; + for (let i = 0; i < 100; i++) { + last = undent` + ${undent.indent} + list: + ${i} + `; + } + do_not_optimize(last); + }); + + bench("anchor cache: auto-detect hot path ×100", () => { + let last = ""; + for (let i = 0; i < 100; i++) { + last = undent` + list: + ${i} + `; + } + do_not_optimize(last); + }); +}); + // Separate cache-behavior benchmarks for embed/embed-like patterns. // These isolate repeated-input (hot) vs unique-input (cold) performance. // @@ -676,17 +1198,106 @@ bench("alignText: 500 lines, 8-char pad", () => { do_not_optimize(alignText(ML_500, " ")); }).gc("inner"); +summary(() => { + bench("alignText: 500 lines mostly content", () => { + do_not_optimize(alignText(ML_500, " ")); + }).gc("inner"); + + bench("alignText: 500 lines mostly blank", () => { + do_not_optimize(alignText(ML_500_MOSTLY_BLANK, " ")); + }).gc("inner"); +}); + // deno-lint-ignore no-explicit-any -bench(function* columnOffset_len(state: any) { +bench('columnOffset: $len chars', function* (state: any) { const n = state.get("len"); const s = "a".repeat(n / 2) + "\n" + "b".repeat(n / 2); - yield () => do_not_optimize(columnOffset(s)); -}).args("len", [100, 1000, 10000]); + yield { + [0]() { + return s; + }, + + bench(input: string) { + do_not_optimize(columnOffset(input)); + }, + }; +}).range('len', 128, 16_384); bench("dedentString: 1K lines", () => { do_not_optimize(dedentString(LARGE_1K)); }).gc("inner"); +// Curiosity microbenchmark. +// +// This does not model an undent hot path directly. It exists to answer a +// practical implementation question: when we need repeated padding or marker +// strings, is a manual loop faster than the built-in `.repeat()` helper? +summary(() => { + const chunk = " "; + const count = 32; + + bench("string repeat: .repeat() 8-char chunk ×32", () => { + do_not_optimize(chunk.repeat(count)); + }); + + bench("string repeat: for loop 8-char chunk ×32", () => { + do_not_optimize(repeatWithLoop(chunk, count)); + }); + + bench("string repeat: fill+join 8-char chunk ×32", () => { + do_not_optimize(repeatWithFillJoin(chunk, count)); + }); +}); + +summary(() => { + const chunk = "0123456789abcdef0123456789abcdef"; + const count = 512; + + bench("string repeat: .repeat() 32-char chunk ×512", () => { + do_not_optimize(chunk.repeat(count)); + }).gc("inner"); + + bench("string repeat: for loop 32-char chunk ×512", () => { + do_not_optimize(repeatWithLoop(chunk, count)); + }).gc("inner"); + + bench("string repeat: fill+join 32-char chunk ×512", () => { + do_not_optimize(repeatWithFillJoin(chunk, count)); + }).gc("inner"); +}); + +summary(() => { + const count = 32; + + bench("space repeat: .repeat() ×32", () => { + do_not_optimize(' '.repeat(count)); + }); + + bench("space repeat: for loop ×32", () => { + do_not_optimize(repeatWithLoop(' ', count)); + }); + + bench("space repeat: fill+join ×32", () => { + do_not_optimize(repeatWithFillJoin(' ', count)); + }); +}); + +summary(() => { + const count = 512; + + bench("space repeat: .repeat() ×512", () => { + do_not_optimize(' '.repeat(count)); + }).gc("inner"); + + bench("space repeat: for loop ×512", () => { + do_not_optimize(repeatWithLoop(' ', count)); + }).gc("inner"); + + bench("space repeat: fill+join ×512", () => { + do_not_optimize(repeatWithFillJoin(' ', count)); + }).gc("inner"); +}); + // ========================================================================= // 9. Pathological inputs // ========================================================================= -- 2.51.2