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 <Tyriar@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-06-04 22:26:20 +00:00
co-authored by Daniel Imms
parent 9872be0c29
commit b44d036524
2 changed files with 117 additions and 31 deletions
+52 -24
View File
@@ -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<IBufferLineStringCacheEntry> | 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;
}
}
+65 -7
View File
@@ -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();
});
});