Public: anyone can read this; the crew can change it.

A highlighter perf bound of 10ms fails on a slower machine, so the whole file sits outside the gate #502

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/294-a-highlighter-perf-bound-of-10ms-fails-o → main 7 commits fork

  • Merged. torque/294-a-highlighter-perf-bound-of-10ms-fails-o was merged into main as b8f8396.

Review

Sign in to review this change.
3 files changed +84 −30
scripts/gate.ts +1 −5
@@ -77,6 +77,7 @@ export const GATE: readonly string[] = [
7777 "test/fleet-ops.test.ts",
7878 "test/meter.test.ts",
7979 "test/told-by.test.ts",
80 "test/highlight.test.ts",
8081 "test/check-list.test.ts",
8182 ];
8283
@@ -145,11 +146,6 @@ export const EXCLUDED: readonly Exclusion[] = [
145146 { file: "test/git-ssh.test.ts", reason: "not yet admitted", issue: 261 },
146147 { file: "test/git.test.ts", reason: "not yet admitted", issue: 261 },
147148 { file: "test/hardening.test.ts", reason: "not yet admitted", issue: 261 },
148 {
149 file: "test/highlight.test.ts",
150 reason: "red: a 10ms highlighter bound fails on a slower machine",
151 issue: 294,
152 },
153149 { file: "test/image-gif.test.ts", reason: "not yet admitted", issue: 261 },
154150 {
155151 file: "test/image-graphics.test.ts",
src/web/highlight.ts +3 −2
@@ -14,8 +14,9 @@
1414 * step, so no input can wedge it.
1515 *
1616 * **Linear.** No backtracking regex ever touches a whole line; the hot loops
17 * compare character codes. A 10,000-character line scans in under a
18 * millisecond, which is what makes the editor's per-keystroke re-scan free.
17 * compare character codes. A line sixteen times longer costs about sixteen
18 * times as much, never two hundred and fifty-six, which is what keeps the
19 * editor's per-keystroke re-scan cheap however long the line.
1920 *
2021 * ## Two levels of token
2122 *
test/highlight.test.ts +80 −23
@@ -7,8 +7,9 @@
77 *
88 * **lossless** — the token texts of a line concatenate back to the line, so
99 * nothing a file contains can be dropped or duplicated on the way to a page;
10 * **linear** — a 10,000-character line scans in well under a frame, which is
11 * what lets the editor re-highlight on every keystroke;
10 * **linear** — scanning a line eight times longer takes about eight times
11 * as long, never sixty-four, which is what lets the editor re-highlight a
12 * long line on every keystroke;
1213 * **total** — random bytes in any language never throw.
1314 *
1415 * On top of that, one fixture per language, snapshotted, so a change to a rule
@@ -31,6 +32,46 @@ import { renderTokens, tokenClasses } from "../src/web/highlight-html.ts";
3132 import { THEMES, THEME_NAMES } from "../src/design/themes.ts";
3233 import { css } from "../src/web/style.ts";
3334
35 /**
36 * How `work`'s cost grows with its input, as the exponent k in time ∝ size^k:
37 * timed at `small` and at `GROWTH` times that, in this run on this machine,
38 * small and large interleaved so load lands on both, best of three each.
39 *
40 * Linearity is asserted as this exponent, never as milliseconds. A wall-clock
41 * bound is a claim about the machine (10ms passed on one box and took 25ms on
42 * a crew machine, #294); a ratio cancels the machine out. A linear scanner
43 * measures k ≈ 1 (a little above under GC and allocation as the token array
44 * grows), a quadratic one k ≈ 2 — the regression these tests exist for — so
45 * `LINEAR_BOUND` sits halfway. Growth is 16× rather than 2× so the two are
46 * 64× against 256× in time, far apart enough for a loaded machine's noise
47 * not to bridge them. A spike can still land on one measurement, so an
48 * exponent over the bound is measured again, up to three times, and the best
49 * is returned: noise does not repeat, quadratic does.
50 */
51 const GROWTH = 16;
52 const LINEAR_BOUND = 1.5;
53
54 function growth(work: (size: number) => void, small: number): number {
55 const time = (size: number) => {
56 const t0 = process.cpuUsage();
57 work(size);
58 const { user, system } = process.cpuUsage(t0);
59 return user + system;
60 };
61 work(small); // warm the JIT so no sample pays for compiling
62 let k = Infinity;
63 for (let attempt = 0; attempt < 3 && k >= LINEAR_BOUND; attempt++) {
64 let lo = Infinity;
65 let hi = Infinity;
66 for (let i = 0; i < 3; i++) {
67 lo = Math.min(lo, time(small));
68 hi = Math.min(hi, time(small * GROWTH));
69 }
70 k = Math.min(k, Math.log(Math.max(hi, 1) / Math.max(lo, 1)) / Math.log(GROWTH));
71 }
72 return k;
73 }
74
3475 function join(code: string, lang: Language): string {
3576 return highlightLines(code, lang)
3677 .map((line) => line.map((t) => t.text).join(""))
@@ -467,34 +508,50 @@ describe("every language snapshots", () => {
467508 });
468509
469510 describe("resilience", () => {
470 test("a 10,000-character line scans in under 10ms in every language", () => {
511 test("the growth measure tells linear from quadratic", () => {
512 // The yardstick, checked against itself: a scanner that re-reads the
513 // line for every character must land above the bound, or the two tests
514 // below could never fail.
515 const quadratic = (size: number) => {
516 const line = "x".repeat(size);
517 let sum = 0;
518 for (let i = 0; i < size; i++)
519 for (let j = i; j < size; j++) sum += line.charCodeAt(j);
520 return sum;
521 };
522 expect(growth(quadratic, 250)).toBeGreaterThan(LINEAR_BOUND);
523 }, 30_000);
524
525 test("a long line scans in linear time in every language", () => {
471526 const unit =
472527 "const x = { a: 1, b: `t${y}`, c: /re/g, d: 'str', e: [1,2,3] }; // n ";
473 const line = unit.repeat(200).slice(0, 10_000);
528 const line = (size: number) => unit.repeat(Math.ceil(size / unit.length)).slice(0, size);
474529 for (const lang of LANGUAGES) {
475 const t0 = Bun.nanoseconds();
476 const rows = highlightLines(line, lang);
477 const ms = (Bun.nanoseconds() - t0) / 1e6;
478 expect(rows.length).toBe(1);
479 expect(ms).toBeLessThan(10);
530 expect(highlightLines(line(10_000), lang).length).toBe(1);
531 const k = growth((size) => highlightLines(line(size), lang), 500);
532 expect({ lang, linear: k < LINEAR_BOUND }).toEqual({ lang, linear: true });
480533 }
481 });
534 }, 30_000);
482535
483536 test("pathological delimiter runs stay linear", () => {
484 for (const junk of [
485 "`".repeat(4000),
486 "/".repeat(4000),
487 "${".repeat(2000),
488 "<".repeat(4000),
489 '"'.repeat(4000),
490 "#[".repeat(2000),
491 "[[".repeat(2000),
492 ]) {
493 const t0 = Bun.nanoseconds();
494 for (const lang of LANGUAGES) expect(join(junk, lang)).toBe(junk);
495 expect((Bun.nanoseconds() - t0) / 1e6).toBeLessThan(120);
537 for (const [unit, label] of [
538 ["`", "backticks"],
539 ["/", "slashes"],
540 ["${", "interpolations"],
541 ["<", "angles"],
542 ['"', "quotes"],
543 ["#[", "attributes"],
544 ["[[", "long brackets"],
545 ] as const) {
546 const junk = (size: number) => unit.repeat(size / unit.length);
547 for (const lang of LANGUAGES) expect(join(junk(4_000), lang)).toBe(junk(4_000));
548 const k = growth((size) => {
549 const text = junk(size);
550 for (const lang of LANGUAGES) highlightLines(text, lang);
551 }, 250);
552 expect({ label, linear: k < LINEAR_BOUND }).toEqual({ label, linear: true });
496553 }
497 });
554 }, 30_000);
498555
499556 test("random bytes never throw, in any language", () => {
500557 // Deterministic: a seeded generator, so a failure is reproducible.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs