Speed
pnpm speed: markz alone, this working tree's src/ against origin/main's, in seconds. It
answers one question while you work: did this change move it? Each cell is parse + HTML over
some text, in MB/s: each document tier read whole, and each construct over its own examples,
repeated to a size (examples/markz/<id>.md), so a slower construct shows by name. What a tree
holds in memory is a cost, so pnpm size prints it.
Both versions run in one process, a pass of each in turn (harness/speed.ts), and that is
repeated in three fresh processes (--versus). A cell shows the median change and the range
across the processes. It is marked only when every process puts it beyond 5% the same way. A
saved baseline from an earlier run was ruled out, since the machine drifts between runs by more
than any change worth finding. --against <ref> times against another commit.
pnpm compare (--compare) times markz beside the parsers in harness/parsers.ts on each tier's common
variant, each parser in a fresh process of its own (--parser <name>), so no parser's heap or
JIT state colours another's numbers. It is for our own insight: nothing here is published.
pnpm hotspots (--profile) shows where the time goes rather than how much there is (harness/profile.ts). It
profiles every tier, or one tier or construct by name (pnpm hotspots heading), and writes the
profile to node_modules/.cache/ for DevTools.
import "./harness/node.ts";
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
const { html, parse } = await import("../src/index.ts");
const { TIERS, common, documents, repeat } = await import("./harness/corpus.ts");
const { readFences } = await import("./harness/fences.ts");
const { OTHERS, load } = await import("./harness/parsers.ts");
const { time, versus, warm } = await import("./harness/speed.ts");
const { profile, summarize } = await import("./harness/profile.ts");
const root = join(import.meta.dirname, "..");
const CACHE = join(root, "node_modules/.cache/markz");
const PROFILE = join(CACHE, "markz.cpuprofile");
const PROFILE_MS = 3_000;
const BUDGET_MS = 25;
/** Both versions' passes together, for each cell. */
const VERSUS_MS = 60;
/** Processes the comparison runs in. A version's luck with the JIT holds for a whole process. */
const RUNS = 3;
const WARM_MS = 1_000;
const CONSTRUCT_BYTES = 20_000;
/** No change under this is marked, however quiet both runs were. */
const FLOOR = 0.05;
const run = (text: string) => html(parse(text));
/** What is timed: each document tier whole, and each construct's examples repeated. */
function timed(): { name: string; texts: string[] }[] {
const out = TIERS.map((tier) => ({
name: `${tier} documents`,
texts: [...documents(tier).values()],
}));
const own = join(root, "test/examples/markz");
for (const file of readdirSync(own).sort()) {
if (!file.endsWith(".md") || file === "README.md" || file === "not-supported.md") continue;
const examples = readFences(readFileSync(join(own, file), "utf8")).examples;
const text = examples.map((e) => e.markdown.replace(/\s*$/, "\n")).join("\n");
out.push({ name: file.slice(0, -3), texts: [repeat(text, CONSTRUCT_BYTES)] });
}
return out;
}
const argv = process.argv.slice(2);
const flag = (name: string) => argv.indexOf(name);
const mb = (n: number | undefined) => (n === undefined ? "—" : n.toFixed(1));
if (flag("--parser") >= 0) {
// One parser's process: warm up on every tier, time each, and report on stdout.
const tiers = JSON.parse(readFileSync(argv[flag("--parser") + 2]!, "utf8")) as Record<
string,
string[]
>;
const run = await load(argv[flag("--parser") + 1]!);
warm(run, Object.values(tiers).flat(), WARM_MS);
const out: Record<string, number> = {};
const entries = Object.entries(tiers);
const timings = time(
run,
entries.map(([, texts]) => texts),
BUDGET_MS,
3,
);
entries.forEach(([tier, texts], i) => {
out[tier] = texts.reduce((sum, t) => sum + t.length, 0) / 1e3 / timings[i]!.ms;
});
process.stdout.write(JSON.stringify(out));
} else if (flag("--compare") >= 0) {
const dir = mkdtempSync(join(tmpdir(), "markz-compare-"));
const file = join(dir, "tiers.json");
const tiers = Object.fromEntries(
TIERS.map((tier) => [tier, [...documents(tier).values()].map((d) => common(parse(d)))]),
);
writeFileSync(file, JSON.stringify(tiers));
const width = 16;
console.log(
`${"MB/s, parse + HTML".padEnd(width + 8)}${TIERS.map((t) => t.padStart(8)).join("")}`,
);
console.log("common variant, warm, each parser in its own process\n");
try {
for (const parser of ["markz", ...OTHERS]) {
const child = spawnSync(
process.execPath,
[join(import.meta.dirname, "speed.ts"), "--parser", parser, file],
{ encoding: "utf8" },
);
if (child.status !== 0) {
console.log(`${parser.padEnd(width + 8)}failed: ${child.stderr.split("\n")[0]}`);
continue;
}
const out = JSON.parse(child.stdout) as Record<string, number>;
console.log(
`${parser.padEnd(width + 8)}${TIERS.map((t) => mb(out[t]).padStart(8)).join("")}`,
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
} else if (flag("--profile") >= 0) {
// A tier or construct to profile, or every tier.
const target = argv[flag("--profile") + 1];
const all = timed();
const chosen = target
? all.filter((c) => c.name === target || c.name === `${target} documents`)
: all.filter((c) => c.name.endsWith(" documents"));
if (!chosen.length) {
console.error(`no tier or construct "${target}": ${all.map((c) => c.name).join(", ")}`);
process.exit(1);
}
const texts = chosen.flatMap((c) => c.texts);
warm(run, texts, WARM_MS);
const cpu = await profile(run, texts, PROFILE_MS);
mkdirSync(join(PROFILE, ".."), { recursive: true });
writeFileSync(PROFILE, JSON.stringify(cpu));
console.log(`parse + HTML: ${chosen.map((c) => c.name).join(", ")}, warm\n`);
console.log(summarize(cpu, root));
console.log(`\n${relative(root, PROFILE)} opens in DevTools for the flame chart`);
} else if (flag("--versus") >= 0) {
// One process of `pnpm speed`: both versions over every cell, as JSON on stdout.
const base = await import(join(argv[flag("--versus") + 1]!, "src/index.ts"));
const before = (text: string) => base.html(base.parse(text));
const cells = timed();
const texts = cells.flatMap((c) => c.texts);
// Warmed in turns: whichever version warmed last measured a few percent faster.
for (let i = 0; i < 10; i++) {
warm(before, texts, WARM_MS / 10);
warm(run, texts, WARM_MS / 10);
}
const out: Record<string, { mbPerSecond: number; change: number }> = {};
for (const cell of cells) {
const bytes = cell.texts.reduce((sum, t) => sum + t.length, 0);
const v = versus(before, run, cell.texts, VERSUS_MS);
out[cell.name] = { mbPerSecond: bytes / 1e3 / v.ms, change: v.change };
}
process.stdout.write(JSON.stringify(out));
} else {
// The ref's `src/`, unpacked once per commit, for the processes to load beside the working tree's.
const ref = flag("--against") >= 0 ? argv[flag("--against") + 1]! : "origin/main";
const git = (...args: string[]) => spawnSync("git", args, { cwd: root, maxBuffer: 1 << 28 });
const sha = git("rev-parse", "--verify", `${ref}^{commit}`).stdout.toString().trim();
if (!sha) {
console.error(`no commit "${ref}"`);
process.exit(1);
}
// Node strips types only outside `node_modules`, so the ref's files can't live in the cache.
const dir = join(tmpdir(), "markz-speed", sha);
if (!existsSync(join(dir, "src/index.ts"))) {
mkdirSync(dir, { recursive: true });
const tar = git("archive", "--format=tar", sha, "src").stdout;
spawnSync("tar", ["-x", "-C", dir], { input: tar });
}
const started = performance.now();
const runs: Record<string, { mbPerSecond: number; change: number }>[] = [];
for (let i = 0; i < RUNS; i++) {
const child = spawnSync(
process.execPath,
[join(import.meta.dirname, "speed.ts"), "--versus", dir],
{ encoding: "utf8" },
);
if (child.status !== 0) {
console.error(child.stderr);
process.exit(1);
}
runs.push(JSON.parse(child.stdout));
}
const cells = Object.keys(runs[0]!);
const width = Math.max(...cells.map((c) => c.length)) + 2;
const median = (xs: number[]) => xs.toSorted((a, b) => a - b)[xs.length >> 1]!;
const pct = (x: number) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(0)}%`;
console.log(`against ${ref} (${sha.slice(0, 7)}), both in each of ${RUNS} processes\n`);
console.log(
`${"MB/s, parse + HTML".padEnd(width)}${"now".padStart(8)}${"change".padStart(9)}${"runs".padStart(16)}`,
);
for (const cell of cells) {
const changes = runs.map((r) => r[cell]!.change);
const mark = changes.every((c) => c > FLOOR)
? " faster"
: changes.every((c) => c < -FLOOR)
? " SLOWER"
: "";
console.log(
`${cell.padEnd(width)}${mb(median(runs.map((r) => r[cell]!.mbPerSecond))).padStart(8)}${pct(median(changes)).padStart(9)}${`${pct(Math.min(...changes))} to ${pct(Math.max(...changes))}`.padStart(16)}${mark}`,
);
}
console.log(`\n${((performance.now() - started) / 1000).toFixed(1)} s`);
}