chenglou / chenglou/pretext

For fun: "Auto-researched" performance optimizations (analysis + bidi + layout)

Open
#6 7 comments 6 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
50.3k
Forks
2.7k
Avg merge
2h 40m
Merged PRs (30d)
69

Description

For fun, I've led Opus have at it with a custom "auto performance optimization loop" I've been building these past weeks that focuses on V8-specific improvements (just to see how it would tackle this kind of project).

The following benchmarks have been run via Node v24 on a Ryzen 7950X inside of WSL2 on Windows 10. I've let Opus create tests and benchmarks before tackling any of the code - they kept passing, and I personally have checked the demos in the browser as well (they looked all fine to me). The accuracy test results are also the exact same it seems.

Maybe some of the ideas are may be of use to speed up this library! (I didn't dare to open a PR with completely "vibe-coded performance improvements")

You can see the diff here: https://github.com/chenglou/pretext/compare/main...spaceemotion:pretext:perf/v8-optimizations

The full report by Opus is as follows:

---

### `src/line-break.ts`

The four closure-based line-breaking functions (`countPreparedLinesSimple`, `walkPreparedLinesSimple`, `walkPreparedLines`, `layoutNextLineRange`) were refactored into **three classes** with typed private fields:

- **`SimpleLineCounter`** — the `layout()` resize hot path (count-only, no line materialization)
- **`SimpleLineEngine`** — the walk/streaming path for normal text (line ranges + widths)
- **`FullLineEngine`** — the walk/streaming path for soft hyphens, tabs, and chunks

Each class replaces deeply nested closures (7–12 per function) with typed private fields and monomorphic methods. A fresh instance is created per call (`new SimpleLineCounter(...).run()`), so there is no cross-call state reuse — the performance gain comes from V8 optimizing class shapes with fixed fields far better than closure scopes with captured mutable variables. The public entry points (`countPreparedLines`, `walkPreparedLines`, etc.) now delegate to these classes. Net line count is slightly lower — the classes share structural patterns but are intentionally kept separate (merging them into a base class caused a 24–35% V8 regression from bimorphic dispatch).

**Impact:** 3–7x faster for `layout()`, 13–26x faster for the streaming `layoutNextLine()` API.

### `src/analysis.ts`

The post-segmentation merge passes (`mergeUrlLikeRuns`, `mergeNumericRuns`, `mergeGlueConnectedTextRuns`, etc.) were all converted from **copy-and-return to in-place mutation** (`mergeUrlLikeRunsInPlace`, etc.). Instead of building new arrays each pass, they now splice the shared `MergedSegmentation` arrays directly.

Other changes:
- **Content-presence flags** — the initial segmentation loop now tracks what the text contains (CJK, Arabic, URLs, glue, soft hyphens, etc.) so downstream passes can be skipped entirely when irrelevant
- **Char-code classification** — `classifySegmentBreakChar(ch)` became `classifySegmentBreakCharCode(code)` to avoid repeated single-char string allocation
- **WhiteSpace profile objects** are now pre-allocated constants instead of created per call
- **`segmentNeedsSplitting()` fast check** added before the heavier `forEachBreakKindPiece()` iteration
- Arabic script detection uses a charCode range check instead of a regex test for the hot path

Net line count grew because the in-place splice logic is more verbose than filter/map, and the flag-tracking adds branching in the initial loop.

**Impact:** 1.5–3x faster for `analyzeText()`. The floor is set by `Intl.Segmenter` (~55–60% of total time), which can't be optimized from JS.

### `src/bidi.ts`

The bidi type system was changed from **string unions to numeric constants** (`'L'` → `0`, `'R'` → `1`, `'AL'` → `2`, etc.) backed by `Uint8Array` lookup tables. The per-call classification buffer (`typeBuf`) is a **module-scope `Uint8Array`** that grows as needed but is never freed, avoiding typed-array allocation on every call.

Other changes:
- **`computeBidiTypes()` extracted** as a separate classification pass that returns `null` early for pure-LTR text (no allocation at all for Latin/CJK)
- **Content-presence flags** (`hasWeak`, `hasALorNSM`) skip W-rule passes that don't apply
- **Pure-R fast path** — Hebrew-only text (all R, no AL/AN/EN) skips the entire W+N rule pipeline and writes level 1 directly
- The `arabicTypes` remap table is a pre-built `Uint8Array` instead of a string-keyed object
- All type comparisons are integer `===` instead of string equality

**Impact:** 1.8–7.2x faster. Hebrew benefits most (up to 7.2x) from the pure-R fast path; mixed bidi text sees 1.8–2.2x.

---

All numbers measured on the same machine in the same session. Baseline = `main` branch (unoptimized), Optimized = `perf/v8-optimizations` branch (all 3 phases applied). Times are median ns/op from Node.js (`npx tsx`) benchmarks.

### layout() — resize hot path (Phase 1)

| Case | main (ns) | optimized (ns) | Speedup |
|---|---|---|---|
| Latin short (6w) | 277 | 87 | **3.2×** |
| Latin medium (25w) | 400 | 115 | **3.5×** |
| Latin long (100w) | 982 | 173 | **5.7×** |
| Resize sweep (25w) | 402 | 59 | **6.8×** |
| CJK medium (50ch) | 586 | 85 | **6.9×** |
| Long word overflow | 448 | 88 | **5.1×** |
| Corpus 500 segs | 2,110 | 517 | **4.1×** |
| Magazine 2k segs | 8,131 | 2,085 | **3.9×** |
| CJK editorial 5k | 19,223 | 5,987 | **3.2×** |
| Thai-like 10k | 40,797 | 13,029 | **3.1×** |
| Arabic-like 37k | 146,712 | 47,717 | **3.1×** |
| Mixed long 10k | 40,337 | 12,402 | **3.3×** |

**Summary:** 3.1–6.9× faster across all text sizes. Small/medium texts see the largest relative gains (up to 6.9×) because the per-call overhead of closures + object allocation dominated. Large texts converge toward ~3× as the per-segment loop work dominates.

### walkLineRanges() — rich batch geometry path (Phase 1)

| Case | main (ns) | optimized (ns) | Speedup |
|---|---|---|---|
| Latin short (6w) | 1,814 | 59 | **30.7×** |
| Latin medium (25w) | 2,039 | 152 | **13.4×** |
| Latin long (100w) | 2,920 | 592 | **4.9×** |
| CJK medium (50ch) | 2,250 | 274 | **8.2×** |
| Long word overflow | 2,164 | 253 | **8.6×** |
| Corpus 500 segs | 4,983 | 1,549 | **3.2×** |
| Magazine 2k segs | 14,562 | 6,415 | **2.3×** |
| CJK editorial 5k | 31,964 | 14,554 | **2.2×** |
| Thai-like 10k | 64,927 | 32,466 | **2.0×** |
| Arabic-like 37k | 219,801 | 114,334 | **1.9×** |
| Mixed long 10k | 64,873 | 31,006 | **2.1×** |

**Summary:** 1.9–30.7× faster. Short texts see extreme gains (30×) because the old walk path had heavy per-call setup. Large texts converge toward ~2× as the richer per-line materialization work dominates.

### walkPreparedLines() — full walk with soft hyphens/chunks (Phase 1)

| Case | main (ns) | optimized (ns) | Speedup |
|---|---|---|---|
| Latin 25w (SHY+HB) | 2,659 | 215 | **12.4×** |
| Latin 100w (SHY+HB) | 3,947 | 798 | **4.9×** |
| 500 segs (SHY+HB) | 6,467 | 2,018 | **3.2×** |
| 2k segs (SHY+HB) | 19,801 | 8,609 | **2.3×** |
| 5k segs (mixed) | 45,453 | 22,255 | **2.0×** |
| 10k segs (mixed) | 86,898 | 45,418 | **1.9×** |

**Summary:** 1.9–12.4× faster. Same pattern: enormous short-text gains from eliminated allocation overhead, converging toward ~2× for long texts.

### layoutNextLine() — streaming API (Phase 1)

| Case | main (ns) | optimized (ns) | Speedup |
|---|---|---|---|
| Latin short (6w) | 1,408 | 57 | **24.7×** |
| Latin medium (25w) | 5,533 | 216 | **25.6×** |
| Latin long (100w) | 18,106 | 785 | **23.1×** |
| Corpus 500 segs | 46,694 | 2,005 | **23.3×** |
| Magazine 2k segs | 190,366 | 9,707 | **19.6×** |
| Mixed 10k segs | 766,926 | 45,039 | **17.0×** |
| Full: 2k (SHY+HB) | 266,452 | 15,236 | **17.5×** |
| Full: 10k (mixed) | 1,131,125 | 86,434 | **13.1×** |

**Summary:** 13–26× faster across all sizes. The streaming path had the worst overhead per call in the old code (repeated closure creation for every `layoutNextLine()` invocation). The class-based refactoring eliminated this entirely, making the streaming API competitive with the batch paths.

### analyzeText() — text analysis/segmentation (Phase 2)

| Case | main (ns) | optimized (ns) | Speedup |
|---|---|---|---|
| Latin short (45ch) | 5,521 | 3,524 | **1.57×** (36%) |
| Latin medium (240ch) | 19,479 | 10,948 | **1.78×** (44%) |
| Latin long (1000ch) | 98,782 | 45,922 | **2.15×** (54%) |
| CJK mixed (230ch) | 44,807 | 25,784 | **1.74×** (42%) |
| Arabic (250ch) | 23,447 | 9,930 | **2.36×** (58%) |
| Mixed app (360ch) | 54,005 | 35,698 | **1.51×** (34%) |
| CJK long (~5000ch) | 1,291,329 | 693,906 | **1.86×** (46%) |
| Arabic long (~5000ch) | 489,620 | 165,064 | **2.97×** (66%) |
| Mixed long (~5000ch) | 620,850 | 316,673 | **1.96×** (49%) |
| Pre-wrap short | 10,619 | 5,341 | **1.99×** (50%) |
| Pre-wrap long | 303,978 | 217,634 | **1.40×** (28%) |

**Summary:** 1.4–3.0× faster (28–66% reduction). Arabic text benefits the most because content-presence flags and in-place mutation eliminate entire passes that only apply to RTL/bidi content. The floor (~28–34%) is set by `Intl.Segmenter` which accounts for ~55–60% of total analysis time and cannot be optimized from JS.

### computeSegmentLevels() — bidi level computation (Phase 3)

| Case | main (ns) | optimized (ns) | Speedup |
|---|---|---|---|
| Latin short (45ch) | 110 | 36 | **3.1×** |
| Latin long (700ch) | 2,125 | 657 | **3.2×** |
| Hebrew short (24ch) | 315 | 66 | **4.8×** |
| Hebrew medium (100ch) | 1,376 | 192 | **7.2×** |
| Arabic short (35ch) | 454 | 142 | **3.2×** |
| Arabic medium (160ch) | 2,062 | 844 | **2.4×** |
| Mixed short (26ch) | 355 | 102 | **3.5×** |
| Mixed medium (160ch) | 2,139 | 968 | **2.2×** |
| Mixed app (220ch) | 2,676 | 1,286 | **2.1×** |
| Hebrew long (~5000ch) | 61,087 | 9,633 | **6.3×** |
| Arabic long (~5000ch) | 55,854 | 30,739 | **1.8×** |
| Mixed long (~5500ch) | 62,359 | 33,594 | **1.9×** |

**Summary:** 1.8–7.2× faster. Hebrew text benefits the most (up to 7.2×) because the pure-R fast path skips the entire W+N rule pipeline. Arabic and mixed bidi text see 1.8–3.2× gains from numeric type representation, Uint8Array buffers, and content-presence flag skipping.

### Key Techniques Applied

| Technique | Source | Impact |
|---|---|---|
| Class-based refactoring (closures → methods) | Phase 1 | 3–30× on layout hot paths |
| Reusable object instances (eliminate per-call allocation) | Phase 1, 3 | 5–25× on streaming/short paths |
| In-place mutation (avoid array copies in post-merge passes) | Phase 2 | ~15% analysis improvement |
| Content-presence flags (skip irrelevant passes entirely) | Phase 2, 3 | 10–45% per phase |
| Numeric type representation (string → number comparisons) | Phase 3 | 19–47% foundation for bidi |
| Uint8Array typed buffers (module-scope reuse) | Phase 3 | 13–38% short/medium bidi |
| Fast-path early exits (pure-LTR, pure-R scripts) | Phase 3 | 19–37% Hebrew |
| Pre-merged segment passes (fewer array iterations) | Phase 2 | 5–10% analysis |

### What Did NOT Work

| Attempt | Expected | Actual | Root Cause |
|---|---|---|---|
| Shared base class for line engines | Cleaner code, no perf change | 24–35% regression | V8 bimorphic dispatch on prototype chains |
| 64KB Uint8Array lookup table | Faster char classification | ~5% regression | L1 cache misses from large table |
| Pre-sized arrays (`new Array(n)`) | Faster array filling | ~3% regression | V8 treats as holey arrays |
| `TypedArray.subarray().indexOf()` | Faster searching | Severe regression | Function call overhead for small arrays |
| Loop merging (bidi W-rules) | Fewer iterations | 27–36% regression | Inner loop too large for V8 optimizer |

---

### Bun vs Node

| Area | Bun speedup | Node speedup | Why the difference |
|---|---|---|---|
| layout() | 1.0–3.5x | 3.1–6.9x | Bun already optimizes closures well; Node suffered more from them |
| walk paths | 1.0–2.5x | 1.9–30.7x | Same reason — Bun's closure baseline was much better |
| streaming API | 1.2–1.8x | 13–26x | Most extreme: Node's per-call closure creation was catastrophic |
| analyzeText() | 1.9–4.3x | 1.4–3.0x | Bun's baseline was slower; in-place mutation helps it more |
| bidi | 1.5–6.4x | 1.8–7.2x | Roughly similar — both runtimes benefit from numeric types + Uint8Array |

The closure-to-class refactoring in `line-break.ts` was primarily a V8/Node win. Bun (JavaScriptCore) already handled the old closure pattern much better, so it sees modest gains there. But the `analysis.ts` optimizations (in-place mutation, content flags) actually help Bun *more* than Node, because Bun was paying a higher cost for the old copy-heavy pipeline. The bidi optimizations help both runtimes about equally.

With the optimizations applied, the **optimized Bun and optimized Node numbers are now very close to each other** across most cases (e.g. layout Latin long: 255 vs 173ns, bidi Arabic medium: 985 vs 844ns, analysis Latin long: 39,122 vs 45,922ns).

The optimization brought the two runtimes much closer together by eliminating the patterns where they diverged most.

Contributor guide

No contributing guide indexed for this repository

Research direction

Review the proposed comparison branch alongside src/line-break.ts, src/analysis.ts, and src/bidi.ts, then run the reported Node v24 benchmarks and existing accuracy checks. Done would require agreeing on a focused optimization scope and confirming benchmark improvements without changing layout, analysis, bidi behavior, or browser demos.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
performance
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.