Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store

screenshot-*.png
*.cpuprofile
169 changes: 169 additions & 0 deletions packages/core/src/benchmark/measure-cache-benchmark.ts
Original file line number Diff line number Diff line change
@@ -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)
1 change: 0 additions & 1 deletion packages/core/src/renderables/TextBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
32 changes: 31 additions & 1 deletion packages/core/src/text-buffer-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export class TextBufferView {
private textBuffer: TextBuffer
private _destroyed: boolean = false

private _measureCache: Map<number, { lineCount: number; maxWidth: number }> = new Map()
private _measureCacheVersion: number = -1

constructor(lib: RenderLib, ptr: Pointer, textBuffer: TextBuffer) {
this.lib = lib
this.viewPtr = ptr
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/text-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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}`)
Expand All @@ -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) => ({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -221,6 +230,7 @@ export class TextBuffer {

public setTabWidth(width: number): void {
this.guard()
this._version++
this.lib.textBufferSetTabWidth(this.bufferPtr, width)
}

Expand All @@ -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
Expand All @@ -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
Expand Down
Loading