From bbbd3b100fb353bc72cb5c887d84bca9697e8bc1 Mon Sep 17 00:00:00 2001 From: fangliquan Date: Mon, 17 Aug 2026 02:43:15 +0800 Subject: [PATCH] perf(tui): skip rounding unchanged layout subtrees --- .../src/native-ts/yoga-layout/index.ts | 119 ++++++++++++-- .../yoga-layout/round-layout.test.ts | 146 ++++++++++++++++++ 2 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/round-layout.test.ts diff --git a/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/index.ts b/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/index.ts index a62a4bae16..5e954e9903 100644 --- a/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/index.ts +++ b/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/index.ts @@ -436,6 +436,21 @@ export class Node { _cGen = -1 _cN = 0 _cWr = 0 + _rLayoutGen = -1 + _rSubtreeLayoutGen = -1 + _rValid = false + _rLeft = NaN + _rTop = NaN + _rWidth = NaN + _rHeight = NaN + _rRoundedLeft = NaN + _rRoundedTop = NaN + _rRoundedWidth = NaN + _rRoundedHeight = NaN + _rParentAbsLeft = NaN + _rParentAbsTop = NaN + _rScale = NaN + _rIsText = false constructor(config?: Config) { this.style = defaultStyle() this.layout = { @@ -509,6 +524,7 @@ export class Node { this._cN = 0 this._cWr = 0 this._fbBasis = NaN + this._rValid = false } markDirty(): void { this.isDirty_ = true @@ -842,6 +858,8 @@ export class Node { _yogaNodesVisited = 0 _yogaMeasureCalls = 0 _yogaCacheHits = 0 + _yogaRoundedNodes = 0 + _yogaRoundSkips = 0 _generation++ const w = ownerWidth === undefined ? NaN : ownerWidth const h = ownerHeight === undefined ? NaN : ownerHeight @@ -924,18 +942,24 @@ let _yogaNodesVisited = 0 let _yogaMeasureCalls = 0 let _yogaCacheHits = 0 let _yogaLiveNodes = 0 +let _yogaRoundedNodes = 0 +let _yogaRoundSkips = 0 export function getYogaCounters(): { visited: number measured: number cacheHits: number live: number + rounded: number + roundSkips: number } { return { visited: _yogaNodesVisited, measured: _yogaMeasureCalls, cacheHits: _yogaCacheHits, - live: _yogaLiveNodes + live: _yogaLiveNodes, + rounded: _yogaRoundedNodes, + roundSkips: _yogaRoundSkips } } @@ -952,6 +976,18 @@ function layoutNode( forceHeight = false ): void { _yogaNodesVisited++ + + if (performLayout) { + node._rLayoutGen = _generation + + let ancestor: Node | null = node + + while (ancestor && ancestor._rSubtreeLayoutGen !== _generation) { + ancestor._rSubtreeLayoutGen = _generation + ancestor = ancestor.parent + } + } + const style = node.style const layout = node.layout const sameGen = node._cGen === _generation && !performLayout @@ -2191,28 +2227,83 @@ function collectLayoutChildren(node: Node, flow: Node[], abs: Node[]): void { } function roundLayout(node: Node, scale: number, absLeft: number, absTop: number): void { - if (scale === 0) { + const l = node.layout + const isText = node.measureFunc !== null + const wasLaidOut = node._rLayoutGen === _generation + + if ( + node._rValid && + node._rSubtreeLayoutGen !== _generation && + sameFloat(node._rParentAbsLeft, absLeft) && + sameFloat(node._rParentAbsTop, absTop) && + sameFloat(node._rScale, scale) && + node._rIsText === isText && + sameFloat(node._rRoundedLeft, l.left) && + sameFloat(node._rRoundedTop, l.top) && + sameFloat(node._rRoundedWidth, l.width) && + sameFloat(node._rRoundedHeight, l.height) + ) { + _yogaRoundSkips++ + return } - const l = node.layout + if ( + node._rValid && + !wasLaidOut && + sameFloat(l.left, node._rRoundedLeft) && + sameFloat(l.top, node._rRoundedTop) && + sameFloat(l.width, node._rRoundedWidth) && + sameFloat(l.height, node._rRoundedHeight) + ) { + l.left = node._rLeft + l.top = node._rTop + l.width = node._rWidth + l.height = node._rHeight + } + + _yogaRoundedNodes++ + const nodeLeft = l.left const nodeTop = l.top const nodeWidth = l.width const nodeHeight = l.height const absNodeLeft = absLeft + nodeLeft const absNodeTop = absTop + nodeTop - const isText = node.measureFunc !== null - l.left = roundValue(nodeLeft, scale, false, isText) - l.top = roundValue(nodeTop, scale, false, isText) - const absRight = absNodeLeft + nodeWidth - const absBottom = absNodeTop + nodeHeight - const hasFracW = !isWholeNumber(nodeWidth * scale) - const hasFracH = !isWholeNumber(nodeHeight * scale) - l.width = - roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) - roundValue(absNodeLeft, scale, false, isText) - l.height = - roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) - roundValue(absNodeTop, scale, false, isText) + node._rValid = true + node._rLeft = nodeLeft + node._rTop = nodeTop + node._rWidth = nodeWidth + node._rHeight = nodeHeight + node._rParentAbsLeft = absLeft + node._rParentAbsTop = absTop + node._rScale = scale + node._rIsText = isText + + if (scale === 0) { + l.left = nodeLeft + l.top = nodeTop + l.width = nodeWidth + l.height = nodeHeight + } else { + l.left = roundValue(nodeLeft, scale, false, isText) + l.top = roundValue(nodeTop, scale, false, isText) + const absRight = absNodeLeft + nodeWidth + const absBottom = absNodeTop + nodeHeight + const hasFracW = !isWholeNumber(nodeWidth * scale) + const hasFracH = !isWholeNumber(nodeHeight * scale) + l.width = + roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) - + roundValue(absNodeLeft, scale, false, isText) + l.height = + roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) - + roundValue(absNodeTop, scale, false, isText) + } + + node._rRoundedLeft = l.left + node._rRoundedTop = l.top + node._rRoundedWidth = l.width + node._rRoundedHeight = l.height for (const c of node.children) { roundLayout(c, scale, absNodeLeft, absNodeTop) diff --git a/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/round-layout.test.ts b/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/round-layout.test.ts new file mode 100644 index 0000000000..f49d787967 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/round-layout.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' + +import Yoga, { FlexDirection, getYogaCounters, type Node } from './index.js' + +const snapshot = (node: Node): number[] => { + const result = [node.getComputedLeft(), node.getComputedTop(), node.getComputedWidth(), node.getComputedHeight()] + + for (let index = 0; index < node.getChildCount(); index++) { + result.push(...snapshot(node.getChild(index))) + } + + return result +} + +const buildTree = (rootWidth: number, widths: number[], scale: number) => { + const config = Yoga.Config.create() + config.setPointScaleFactor(scale) + const root = Yoga.Node.create(config) + root.setFlexDirection(FlexDirection.Column) + root.setWidth(rootWidth) + root.setHeight(20) + const leaves: Node[] = [] + + for (let groupIndex = 0; groupIndex < 4; groupIndex++) { + const group = Yoga.Node.create(config) + group.setFlexDirection(FlexDirection.Row) + group.setHeight(3.125) + root.insertChild(group, groupIndex) + + for (let leafIndex = 0; leafIndex < 8; leafIndex++) { + const leaf = Yoga.Node.create(config) + leaf.setWidth(widths[groupIndex * 8 + leafIndex]!) + leaf.setHeight(1.125 + (leafIndex % 3) * 0.25) + group.insertChild(leaf, leafIndex) + leaves.push(leaf) + } + } + + return { config, leaves, root } +} + +describe('incremental layout rounding', () => { + it('skips an unchanged transcript subtree when only the clock changes', () => { + const config = Yoga.Config.create() + config.setPointScaleFactor(2) + + const root = Yoga.Node.create(config) + root.setWidth(80) + root.setHeight(40) + + const transcript = Yoga.Node.create(config) + transcript.setHeight(39) + root.insertChild(transcript, 0) + + for (let index = 0; index < 500; index++) { + const row = Yoga.Node.create(config) + row.setWidth(20.25) + row.setHeight(0.25) + transcript.insertChild(row, index) + } + + const clock = Yoga.Node.create(config) + clock.setWidth(5.25) + clock.setHeight(1) + root.insertChild(clock, 1) + + root.calculateLayout(80, 40) + const transcriptWidth = transcript.getComputedWidth() + + clock.setWidth(6.25) + root.calculateLayout(80, 40) + + const counters = getYogaCounters() + expect(clock.getComputedWidth()).toBe(6.5) + expect(transcript.getComputedWidth()).toBe(transcriptWidth) + expect(counters.rounded).toBeLessThanOrEqual(4) + expect(counters.roundSkips).toBeGreaterThanOrEqual(1) + + root.freeRecursive() + Yoga.Config.destroy(config) + }) + + it('re-rounds cached raw geometry when the point scale changes', () => { + const config = Yoga.Config.create() + config.setPointScaleFactor(2) + + const root = Yoga.Node.create(config) + root.setWidth(20) + root.setHeight(10) + + const child = Yoga.Node.create(config) + child.setWidth(10.25) + child.setHeight(1) + root.insertChild(child, 0) + + root.calculateLayout(20, 10) + expect(child.getComputedWidth()).toBe(10.5) + + config.setPointScaleFactor(4) + child.setWidth(10.125) + root.calculateLayout(20, 10) + + expect(child.getComputedWidth()).toBe(10.25) + + config.setPointScaleFactor(0) + root.calculateLayout(20, 10) + + expect(child.getComputedWidth()).toBe(10.125) + + root.freeRecursive() + Yoga.Config.destroy(config) + }) + + it('matches a fresh full layout across leaf, root, and scale changes', () => { + const widths = Array.from({ length: 32 }, (_, index) => 1.125 + (index % 5) * 0.375) + let rootWidth = 40.25 + let scale = 2 + const incremental = buildTree(rootWidth, widths, scale) + + for (let step = 0; step < 24; step++) { + if (step % 6 === 0) { + scale = scale === 2 ? 4 : 2 + incremental.config.setPointScaleFactor(scale) + } else if (step % 5 === 0) { + rootWidth += 0.375 + incremental.root.setWidth(rootWidth) + } else { + const leafIndex = (step * 7) % widths.length + widths[leafIndex]! += 0.125 + incremental.leaves[leafIndex]!.setWidth(widths[leafIndex]!) + } + + incremental.root.calculateLayout(rootWidth, 20) + const fresh = buildTree(rootWidth, widths, scale) + fresh.root.calculateLayout(rootWidth, 20) + + expect(snapshot(incremental.root), `step ${step}`).toEqual(snapshot(fresh.root)) + + fresh.root.freeRecursive() + Yoga.Config.destroy(fresh.config) + } + + incremental.root.freeRecursive() + Yoga.Config.destroy(incremental.config) + }) +})