From b44d036524a600b57f7dfaebc3f80430f97127e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 22:25:46 +0000 Subject: [PATCH] Improve copyFrom for empty and sparse sparse-map paths Use shared empty-map sentinels with copy-on-write for scroll recycling, and an inlined _data flag scan for populated sparse maps (faster than per-cell function calls on master). Avoid slow Object.assign map copies. Co-authored-by: Daniel Imms --- src/common/buffer/BufferLine.ts | 76 ++++++++++++++++++-------- test/benchmark/BufferLine.benchmark.ts | 72 +++++++++++++++++++++--- 2 files changed, 117 insertions(+), 31 deletions(-) diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 1a0d79701..daa99259b 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -23,15 +23,6 @@ const enum Constants { CLEANUP_THRESHOLD = 2 } -function hasSparseMapEntries(map: object): boolean { - for (const key in map) { - if (Object.prototype.hasOwnProperty.call(map, key)) { - return true; - } - } - return false; -} - /** * Cell member indices. * @@ -80,12 +71,16 @@ export interface IBufferLineStringCache { * (if only one particular value is needed) or `loadCell`. For `loadCell` in a loop * memory allocs / GC pressure can be greatly reduced by reusing the CellData object. */ +/** Shared empty sparse maps for lines with no combining/extended data. */ +const EMPTY_SPARSE_MAP: {[index: number]: string} = Object.create(null); +const EMPTY_SPARSE_EXTENDED: {[index: number]: IExtendedAttrs | undefined} = Object.create(null); + export class BufferLine implements IBufferLine { protected _data: Uint32Array; /** Sparse cache; only read when `IS_COMBINED_MASK` is set in `_data`. */ - protected _combined: {[index: number]: string} = {}; + protected _combined: {[index: number]: string} = EMPTY_SPARSE_MAP; /** Sparse cache; only read when `HAS_EXTENDED` is set in `_data`. */ - protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; + protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = EMPTY_SPARSE_EXTENDED; protected _stringCacheEntryRef: WeakRef | undefined; public length: number; @@ -130,6 +125,7 @@ export class BufferLine implements IBufferLine { this._invalidateStringCache(); this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; if (value[CHAR_DATA_CHAR_INDEX].length > 1) { + this._ensureCombinedMap(); this._combined[index] = value[1]; this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } else { @@ -235,9 +231,11 @@ export class BufferLine implements IBufferLine { public setCell(index: number, cell: ICellData): void { this._invalidateStringCache(); if (cell.content & Content.IS_COMBINED_MASK) { + this._ensureCombinedMap(); this._combined[index] = cell.combinedData; } if (cell.bg & BgFlags.HAS_EXTENDED) { + this._ensureExtendedAttrsMap(); this._extendedAttrs[index] = cell.extended; } this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = cell.content; @@ -253,6 +251,7 @@ export class BufferLine implements IBufferLine { public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { this._invalidateStringCache(); if (attrs.bg & BgFlags.HAS_EXTENDED) { + this._ensureExtendedAttrsMap(); this._extendedAttrs[index] = attrs.extended; } this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); @@ -271,12 +270,14 @@ export class BufferLine implements IBufferLine { let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add + this._ensureCombinedMap(); this._combined[index] += stringFromCodePoint(codePoint); } else { if (content & Content.CODEPOINT_MASK) { // normal case for combining chars: // - move current leading char + new one into combined string // - set combined flag + this._ensureCombinedMap(); this._combined[index] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint); content &= ~Content.CODEPOINT_MASK; // set codepoint in buffer to 0 content |= Content.IS_COMBINED_MASK; @@ -459,8 +460,8 @@ export class BufferLine implements IBufferLine { } return; } - this._combined = {}; - this._extendedAttrs = {}; + this._combined = EMPTY_SPARSE_MAP; + this._extendedAttrs = EMPTY_SPARSE_EXTENDED; for (let i = 0; i < this.length; ++i) { this.setCell(i, fillCellData); } @@ -617,9 +618,11 @@ export class BufferLine implements IBufferLine { private _copyCellMapsFrom(src: BufferLine, srcCol: number, destCol: number): void { const srcStart = srcCol * Constants.CELL_INDICIES; if (src._data[srcStart + Cell.CONTENT] & Content.IS_COMBINED_MASK) { + this._ensureCombinedMap(); this._combined[destCol] = src._combined[srcCol]; } if (src._data[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) { + this._ensureExtendedAttrsMap(); this._extendedAttrs[destCol] = src._extendedAttrs[srcCol]; } } @@ -629,21 +632,46 @@ export class BufferLine implements IBufferLine { const srcCombined = line._combined; const srcExtended = line._extendedAttrs; - if (!hasSparseMapEntries(srcCombined) && !hasSparseMapEntries(srcExtended)) { - this._combined = {}; - this._extendedAttrs = {}; + // Fast path: blank / scroll-recycle source (shared empty sentinel or plain empty object). + if (srcCombined === EMPTY_SPARSE_MAP && srcExtended === EMPTY_SPARSE_EXTENDED) { + this._combined = EMPTY_SPARSE_MAP; + this._extendedAttrs = EMPTY_SPARSE_EXTENDED; return; } + this._copySparseMapsFromDense(line); + } - const combined: {[index: number]: string} = {}; - for (const key in srcCombined) { - combined[+key] = srcCombined[+key]; + /** Copy sparse maps by scanning `_data` flags (inlined, no per-cell function calls). */ + private _copySparseMapsFromDense(line: BufferLine): void { + this._combined = {}; + this._extendedAttrs = {}; + const srcData = line._data; + const srcCombined = line._combined; + const srcExtended = line._extendedAttrs; + const len = line.length; + const indices = Constants.CELL_INDICIES; + const combined = this._combined; + const extendedAttrs = this._extendedAttrs; + for (let i = 0; i < len; i++) { + const start = i * indices; + if (srcData[start + Cell.CONTENT] & Content.IS_COMBINED_MASK) { + combined[i] = srcCombined[i]; + } + if (srcData[start + Cell.BG] & BgFlags.HAS_EXTENDED) { + extendedAttrs[i] = srcExtended[i]; + } } - const extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; - for (const key in srcExtended) { - extendedAttrs[+key] = srcExtended[+key]; + } + + private _ensureCombinedMap(): void { + if (this._combined === EMPTY_SPARSE_MAP) { + this._combined = {}; + } + } + + private _ensureExtendedAttrsMap(): void { + if (this._extendedAttrs === EMPTY_SPARSE_EXTENDED) { + this._extendedAttrs = {}; } - this._combined = combined; - this._extendedAttrs = extendedAttrs; } } diff --git a/test/benchmark/BufferLine.benchmark.ts b/test/benchmark/BufferLine.benchmark.ts index b2896ae51..e2e6702c9 100644 --- a/test/benchmark/BufferLine.benchmark.ts +++ b/test/benchmark/BufferLine.benchmark.ts @@ -4,26 +4,54 @@ */ import { perfContext, before, RuntimeCase } from 'xterm-benchmark'; -import { BufferLine } from 'common/buffer/BufferLine'; +import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; -import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; -import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from 'common/buffer/Constants'; +import { BgFlags, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, UnderlineStyle } from 'common/buffer/Constants'; const ITERATIONS = 500_000; const stringCache = new BufferLineStringCache(); const nullCell = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); const yCell = CellData.fromCharData([0, 'y', 1, 'y'.charCodeAt(0)]); -function setupScrollRecyclePair(cols: number): { recycled: BufferLine; blank: BufferLine } { +function fillPlainY(line: BufferLine, cols: number): void { + for (let i = 0; i < cols; i++) { + line.setCellFromCodepoint(i, 'y'.charCodeAt(0), 1, DEFAULT_ATTR_DATA); + } +} + +function setupScrollRecyclePair(cols: number): { recycled: BufferLine, blank: BufferLine } { const blank = new BufferLine(stringCache, cols, nullCell, false); const recycled = new BufferLine(stringCache, cols, yCell, false); - for (let i = 0; i < cols; i++) { - recycled.setCellFromCodepoint(i, 'y'.charCodeAt(0), 1, DEFAULT_ATTR_DATA); - } + fillPlainY(recycled, cols); return { recycled, blank }; } +/** Source line with combining chars and extended attrs spread across columns. */ +function buildSparseSourceLine(cols: number): BufferLine { + const line = new BufferLine(stringCache, cols, nullCell, false); + for (let i = 0; i < cols; i++) { + if (i % 10 === 0) { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.extended.underlineStyle = UnderlineStyle.CURLY; + cell.bg |= BgFlags.HAS_EXTENDED; + line.setCell(i, cell); + } else if (i % 17 === 0) { + line.setCell(i, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)])); + } else { + line.setCellFromCodepoint(i, 'y'.charCodeAt(0), 1, DEFAULT_ATTR_DATA); + } + } + return line; +} + +function setupCopyFromSparsePair(cols: number): { dest: BufferLine, src: BufferLine } { + const src = buildSparseSourceLine(cols); + const dest = new BufferLine(stringCache, cols, yCell, false); + fillPlainY(dest, cols); + return { dest, src }; +} + perfContext('BufferLine.copyFrom (scroll recycle)', () => { perfContext('cols=80', () => { let recycled: BufferLine; @@ -53,3 +81,33 @@ perfContext('BufferLine.copyFrom (scroll recycle)', () => { }, { fork: false }).showAverageRuntime(); }); }); + +perfContext('BufferLine.copyFrom (sparse source: combined + extended attrs)', () => { + perfContext('cols=80', () => { + let dest: BufferLine; + let src: BufferLine; + before(() => { + ({ dest, src } = setupCopyFromSparsePair(80)); + }); + new RuntimeCase('', () => { + for (let i = 0; i < ITERATIONS; i++) { + dest.copyFrom(src); + } + return { payloadSize: ITERATIONS }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('cols=279', () => { + let dest: BufferLine; + let src: BufferLine; + before(() => { + ({ dest, src } = setupCopyFromSparsePair(279)); + }); + new RuntimeCase('', () => { + for (let i = 0; i < ITERATIONS; i++) { + dest.copyFrom(src); + } + return { payloadSize: ITERATIONS }; + }, { fork: false }).showAverageRuntime(); + }); +});