diff --git a/.gitignore b/.gitignore index a82151d07..d282f21fe 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store screenshot-*.png +*.cpuprofile diff --git a/packages/core/src/benchmark/measure-cache-benchmark.ts b/packages/core/src/benchmark/measure-cache-benchmark.ts new file mode 100644 index 000000000..2c954d552 --- /dev/null +++ b/packages/core/src/benchmark/measure-cache-benchmark.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun + +/** + * Benchmark for TextBufferRenderable measure cache optimization. + * + * This benchmark measures the performance improvement from caching measure results + * when Yoga calls measureFunc multiple times with different widths during layout. + * + * Run with: bun packages/core/src/benchmark/measure-cache-benchmark.ts + */ + +import { createTestRenderer } from "../testing/test-renderer" +import { TextRenderable } from "../renderables/Text" +import { BoxRenderable } from "../renderables/Box" + +const ITERATIONS = 100 +const CONTENT_CHANGES = 100 +const GRID_ROWS = 8 +const GRID_COLS = 6 + +async function runBenchmark() { + console.log("=== Measure Cache Benchmark ===") + console.log(`Grid: ${GRID_ROWS}x${GRID_COLS} = ${GRID_ROWS * GRID_COLS} text boxes`) + console.log(`Iterations: ${ITERATIONS}, Content changes: ${CONTENT_CHANGES}\n`) + + const { renderer, renderOnce } = await createTestRenderer({ + width: 200, + height: 100, + }) + + // Create a grid container + const container = new BoxRenderable(renderer, { + id: "container", + width: "100%", + height: "100%", + flexDirection: "column", + flexWrap: "wrap", + }) + renderer.root.add(container) + + // Create a grid of boxes, each containing text with wrapping + const texts: TextRenderable[] = [] + for (let row = 0; row < GRID_ROWS; row++) { + const rowBox = new BoxRenderable(renderer, { + id: `row-${row}`, + width: "100%", + flexDirection: "row", + flexWrap: "wrap", + }) + container.add(rowBox) + + for (let col = 0; col < GRID_COLS; col++) { + const cellBox = new BoxRenderable(renderer, { + id: `cell-${row}-${col}`, + width: `${Math.floor(100 / GRID_COLS)}%`, + padding: 1, + flexDirection: "column", + }) + rowBox.add(cellBox) + + // Add multiple text elements per cell + for (let t = 0; t < 3; t++) { + const text = new TextRenderable(renderer, { + id: `text-${row}-${col}-${t}`, + content: generateLongText(row * GRID_COLS * 3 + col * 3 + t), + wrapMode: "word", + width: "100%", + }) + cellBox.add(text) + texts.push(text) + } + } + } + + console.log(`Total text renderables: ${texts.length}\n`) + + // Warm up + console.log("Warming up...") + for (let i = 0; i < 10; i++) { + await renderOnce() + } + + // Benchmark 1: Multiple renders without content change (cache should help) + console.log(`\nBenchmark 1: ${ITERATIONS} renders without content change`) + console.log(" (Cache should return results immediately)") + + const startNoChange = performance.now() + for (let i = 0; i < ITERATIONS; i++) { + await renderOnce() + } + const endNoChange = performance.now() + const timeNoChange = endNoChange - startNoChange + + console.log(` Total time: ${timeNoChange.toFixed(2)}ms`) + console.log(` Average per render: ${(timeNoChange / ITERATIONS).toFixed(2)}ms`) + + // Benchmark 2: Renders with content changes (cache invalidated each time) + console.log(`\nBenchmark 2: ${CONTENT_CHANGES} renders with content changes`) + console.log(" (Cache invalidated on each content change)") + + const startWithChange = performance.now() + for (let i = 0; i < CONTENT_CHANGES; i++) { + // Change content on all texts (invalidates cache) + for (let j = 0; j < texts.length; j++) { + texts[j].content = generateLongText(j + i * 7) + } + await renderOnce() + } + const endWithChange = performance.now() + const timeWithChange = endWithChange - startWithChange + + console.log(` Total time: ${timeWithChange.toFixed(2)}ms`) + console.log(` Average per render: ${(timeWithChange / CONTENT_CHANGES).toFixed(2)}ms`) + + // Summary + console.log("\n=== Summary ===") + const avgCached = timeNoChange / ITERATIONS + const avgUncached = timeWithChange / CONTENT_CHANGES + console.log(`Cached renders (no content change): ${avgCached.toFixed(2)}ms avg`) + console.log(`Uncached renders (content changes): ${avgUncached.toFixed(2)}ms avg`) + + const speedup = avgUncached / avgCached + console.log(`\nCache speedup: ${speedup.toFixed(1)}x faster when content unchanged`) + + renderer.destroy() +} + +function generateLongText(seed: number): string { + const words = [ + "Lorem", + "ipsum", + "dolor", + "sit", + "amet", + "consectetur", + "adipiscing", + "elit", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua", + "Ut", + "enim", + "ad", + "minim", + "veniam", + "quis", + "nostrud", + "exercitation", + "ullamco", + "laboris", + ] + + let text = "" + const length = 30 + (seed % 20) // Variable length text + for (let i = 0; i < length; i++) { + text += words[(i + seed) % words.length] + " " + } + return text.trim() +} + +runBenchmark().catch(console.error) diff --git a/packages/core/src/renderables/TextBufferRenderable.ts b/packages/core/src/renderables/TextBufferRenderable.ts index d5ed49d2a..0a10449c6 100644 --- a/packages/core/src/renderables/TextBufferRenderable.ts +++ b/packages/core/src/renderables/TextBufferRenderable.ts @@ -373,7 +373,6 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf } const effectiveHeight = isNaN(height) ? 1 : height - const measureResult = this.textBufferView.measureForDimensions( Math.floor(effectiveWidth), Math.floor(effectiveHeight), diff --git a/packages/core/src/text-buffer-view.ts b/packages/core/src/text-buffer-view.ts index b5c8d60b9..8c9d1af8c 100644 --- a/packages/core/src/text-buffer-view.ts +++ b/packages/core/src/text-buffer-view.ts @@ -9,6 +9,9 @@ export class TextBufferView { private textBuffer: TextBuffer private _destroyed: boolean = false + private _measureCache: Map = new Map() + private _measureCacheVersion: number = -1 + constructor(lib: RenderLib, ptr: Pointer, textBuffer: TextBuffer) { this.lib = lib this.viewPtr = ptr @@ -103,21 +106,25 @@ export class TextBufferView { public setWrapWidth(width: number | null): void { this.guard() + this._measureCache.clear() this.lib.textBufferViewSetWrapWidth(this.viewPtr, width ?? 0) } public setWrapMode(mode: "none" | "char" | "word"): void { this.guard() + this._measureCache.clear() this.lib.textBufferViewSetWrapMode(this.viewPtr, mode) } public setViewportSize(width: number, height: number): void { this.guard() + this._measureCache.clear() this.lib.textBufferViewSetViewportSize(this.viewPtr, width, height) } public setViewport(x: number, y: number, width: number, height: number): void { this.guard() + this._measureCache.clear() this.lib.textBufferViewSetViewport(this.viewPtr, x, y, width, height) } @@ -168,7 +175,30 @@ export class TextBufferView { public measureForDimensions(width: number, height: number): { lineCount: number; maxWidth: number } | null { this.guard() - return this.lib.textBufferViewMeasureForDimensions(this.viewPtr, width, height) + + const flooredWidth = Math.floor(width) + const version = this.textBuffer.version + if (version !== this._measureCacheVersion) { + this._measureCache.clear() + this._measureCacheVersion = version + } + + const cached = this._measureCache.get(flooredWidth) + if (cached) { + return cached + } + + // Always measure with effectively infinite height to ensure the result is + // cacheable and represents the true content dimensions for this width. + // If we passed the constrained 'height', we might cache a truncated result + // (e.g. lineCount: 1) and wrongly return it for a subsequent call with a larger height. + const UNLIMITED_HEIGHT = 1 << 30 + const result = this.lib.textBufferViewMeasureForDimensions(this.viewPtr, flooredWidth, UNLIMITED_HEIGHT) + if (result) { + this._measureCache.set(flooredWidth, result) + } + + return result } public getVirtualLineCount(): number { diff --git a/packages/core/src/text-buffer.ts b/packages/core/src/text-buffer.ts index 9aba23966..70129e3f4 100644 --- a/packages/core/src/text-buffer.ts +++ b/packages/core/src/text-buffer.ts @@ -25,6 +25,7 @@ export class TextBuffer { private _textBytes?: Uint8Array private _memId?: number private _appendedChunks: Uint8Array[] = [] + private _version: number = 0 constructor(lib: RenderLib, ptr: Pointer) { this.lib = lib @@ -45,6 +46,7 @@ export class TextBuffer { public setText(text: string): void { this.guard() + this._version++ this._textBytes = this.lib.encoder.encode(text) if (this._memId === undefined) { @@ -62,6 +64,7 @@ export class TextBuffer { public append(text: string): void { this.guard() + this._version++ const textBytes = this.lib.encoder.encode(text) // Keep the bytes alive to prevent garbage collection this._appendedChunks.push(textBytes) @@ -73,6 +76,7 @@ export class TextBuffer { public loadFile(path: string): void { this.guard() + this._version++ const success = this.lib.textBufferLoadFile(this.bufferPtr, path) if (!success) { throw new Error(`Failed to load file: ${path}`) @@ -85,6 +89,7 @@ export class TextBuffer { public setStyledText(text: StyledText): void { this.guard() + this._version++ // TODO: This should not be necessary anymore, the struct packing should take care of this const chunks = text.chunks.map((chunk) => ({ @@ -137,6 +142,10 @@ export class TextBuffer { return this._byteSize } + public get version(): number { + return this._version + } + public get ptr(): Pointer { this.guard() return this.bufferPtr @@ -221,6 +230,7 @@ export class TextBuffer { public setTabWidth(width: number): void { this.guard() + this._version++ this.lib.textBufferSetTabWidth(this.bufferPtr, width) } @@ -231,6 +241,7 @@ export class TextBuffer { public clear(): void { this.guard() + this._version++ this.lib.textBufferClear(this.bufferPtr) this._length = 0 this._byteSize = 0 @@ -242,6 +253,7 @@ export class TextBuffer { public reset(): void { this.guard() + this._version++ this.lib.textBufferReset(this.bufferPtr) this._length = 0 this._byteSize = 0