Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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)
33 changes: 29 additions & 4 deletions packages/core/src/renderables/TextBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
protected textBuffer: TextBuffer
protected textBufferView: TextBufferView

// Cache measure results to avoid redundant measureForDimensions calls
// Yoga may call measureFunc multiple times per layout pass with different widths
private _measureCache: Map<number, { width: number; height: number }> = new Map()
private _measureCacheVersion: number = -1

protected _defaultOptions = {
fg: RGBA.fromValues(1, 1, 1, 1),
bg: RGBA.fromValues(0, 0, 0, 0),
Expand Down Expand Up @@ -277,6 +282,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
this.textBufferView.setWrapWidth(this.width)
}
// Changing wrap mode can change dimensions, so mark yoga node dirty to trigger re-measurement
this._measureCache.clear()
this.yogaNode.markDirty()
this.requestRender()
}
Expand Down Expand Up @@ -372,16 +378,35 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
effectiveWidth = width
}

const flooredWidth = Math.floor(effectiveWidth)

const version = this.textBuffer.version
if (version !== this._measureCacheVersion) {
this._measureCache.clear()
this._measureCacheVersion = version
}

const cached = this._measureCache.get(flooredWidth)
if (cached) {
if (widthMode === MeasureMode.AtMost && this._positionType !== "absolute") {
return {
width: Math.min(effectiveWidth, cached.width),
height: cached.height,
}
}
return cached
}

const effectiveHeight = isNaN(height) ? 1 : height

const measureResult = this.textBufferView.measureForDimensions(
Math.floor(effectiveWidth),
Math.floor(effectiveHeight),
)
const measureResult = this.textBufferView.measureForDimensions(flooredWidth, Math.floor(effectiveHeight))

const measuredWidth = measureResult ? Math.max(1, measureResult.maxWidth) : 1
const measuredHeight = measureResult ? Math.max(1, measureResult.lineCount) : 1

// Cache the raw measurement
this._measureCache.set(flooredWidth, { width: measuredWidth, height: measuredHeight })

if (widthMode === MeasureMode.AtMost && this._positionType !== "absolute") {
return {
width: Math.min(effectiveWidth, measuredWidth),
Expand Down
11 changes: 11 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 @@ -231,6 +240,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 +252,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