Optimize BufferLine.copyFrom sparse map copying
Skip O(cols) _copyCellMapsFrom loop when the source line has no sparse combined/extended map entries (scroll recycle from cached blank line). Copy sparse entries by map keys only when needed. Add CDP profiling script and BufferLine.copyFrom micro-benchmark. Co-authored-by: Daniel Imms <Tyriar@users.noreply.github.com>
This commit is contained in:
co-authored by
Daniel Imms
parent
f3f4dcaf9f
commit
9872be0c29
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* CPU profile `timeout 1s yes` on the demo terminal via CDP (Chromium).
|
||||
*
|
||||
* Usage:
|
||||
* node bin/profile_yes_cdp.mjs [--out path.cpuprofile] [--label baseline]
|
||||
*
|
||||
* Requires demo server on port 3000 (npm start).
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { writeFileSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let out = join(root, 'out-test', 'yes.cpuprofile');
|
||||
let label = 'profile';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--out' && args[i + 1]) {
|
||||
out = args[++i];
|
||||
label = out.replace(/\.cpuprofile$/, '').split('/').pop() ?? label;
|
||||
} else if (args[i] === '--label' && args[i + 1]) {
|
||||
label = args[++i];
|
||||
}
|
||||
}
|
||||
return { out, label };
|
||||
}
|
||||
|
||||
const INTEREST = [
|
||||
'copyFrom',
|
||||
'_copySparseMapsFrom',
|
||||
'_copyCellMapsFrom',
|
||||
'BufferService.scroll'
|
||||
];
|
||||
|
||||
export function summarizeCpuProfileFromObject(profile, label = 'profile') {
|
||||
const nodes = profile.nodes ?? [];
|
||||
const samples = profile.samples ?? [];
|
||||
const timeDeltas = profile.timeDeltas ?? [];
|
||||
const idToNode = new Map(nodes.map(n => [n.id, n]));
|
||||
const stats = new Map(INTEREST.map(n => [n, { hitCount: 0, selfTimeUs: 0, totalTimeUs: 0 }]));
|
||||
let totalSampleUs = 0;
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const deltaUs = timeDeltas[i] ?? 0;
|
||||
totalSampleUs += deltaUs;
|
||||
const stack = [];
|
||||
let nodeId = samples[i];
|
||||
while (nodeId !== undefined) {
|
||||
const node = idToNode.get(nodeId);
|
||||
if (!node) break;
|
||||
stack.push(node);
|
||||
nodeId = node.parent;
|
||||
}
|
||||
for (const s of INTEREST) {
|
||||
if (stack.some(n => (n.callFrame?.functionName ?? '').includes(s))) {
|
||||
const st = stats.get(s);
|
||||
st.hitCount++;
|
||||
st.totalTimeUs += deltaUs;
|
||||
}
|
||||
}
|
||||
const leaf = stack[0];
|
||||
const fn = leaf?.callFrame?.functionName ?? '';
|
||||
for (const s of INTEREST) {
|
||||
if (fn.includes(s)) {
|
||||
stats.get(s).selfTimeUs += deltaUs;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...stats.entries()]
|
||||
.filter(([, v]) => v.hitCount > 0)
|
||||
.map(([name, v]) => ({
|
||||
name,
|
||||
hitCount: v.hitCount,
|
||||
selfMs: +(v.selfTimeUs / 1000).toFixed(2),
|
||||
totalMs: +(v.totalTimeUs / 1000).toFixed(2),
|
||||
pctTotal: totalSampleUs ? +((100 * v.totalTimeUs) / totalSampleUs).toFixed(2) : 0
|
||||
}));
|
||||
|
||||
return { label, totalSampleMs: +(totalSampleUs / 1000).toFixed(2), rows };
|
||||
}
|
||||
|
||||
export function summarizeCpuProfile(profilePath) {
|
||||
const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
|
||||
return summarizeCpuProfileFromObject(profile, profilePath);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { out, label } = parseArgs();
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto('http://localhost:3000/', { waitUntil: 'networkidle' });
|
||||
await page.waitForFunction(() => window.term && window.term.element, undefined, { timeout: 60000 });
|
||||
|
||||
const termInfo = await page.evaluate(() => ({
|
||||
cols: window.term.cols,
|
||||
rows: window.term.rows,
|
||||
scrollback: window.term.options.scrollback
|
||||
}));
|
||||
console.log('Terminal:', JSON.stringify(termInfo));
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Profiler.enable');
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: 100 });
|
||||
await cdp.send('Profiler.start');
|
||||
|
||||
const textarea = page.locator('.xterm-helper-textarea');
|
||||
await textarea.click();
|
||||
await page.keyboard.type('timeout 1s yes', { delay: 5 });
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const { profile } = await cdp.send('Profiler.stop');
|
||||
writeFileSync(out, JSON.stringify(profile));
|
||||
|
||||
const summary = summarizeCpuProfileFromObject(profile, label);
|
||||
const summaryPath = out.replace(/\.cpuprofile$/, '.summary.json');
|
||||
writeFileSync(summaryPath, JSON.stringify({ ...summary, termInfo }, null, 2));
|
||||
console.log('Wrote', out);
|
||||
console.log('Summary:', JSON.stringify(summary, null, 2));
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -23,6 +23,15 @@ 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.
|
||||
*
|
||||
@@ -617,10 +626,24 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** Rebuild sparse maps from another line, keyed only by `_data` flags. */
|
||||
private _copySparseMapsFrom(line: BufferLine): void {
|
||||
this._combined = {};
|
||||
this._extendedAttrs = {};
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
this._copyCellMapsFrom(line, i, i);
|
||||
const srcCombined = line._combined;
|
||||
const srcExtended = line._extendedAttrs;
|
||||
|
||||
if (!hasSparseMapEntries(srcCombined) && !hasSparseMapEntries(srcExtended)) {
|
||||
this._combined = {};
|
||||
this._extendedAttrs = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const combined: {[index: number]: string} = {};
|
||||
for (const key in srcCombined) {
|
||||
combined[+key] = srcCombined[+key];
|
||||
}
|
||||
const extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
|
||||
for (const key in srcExtended) {
|
||||
extendedAttrs[+key] = srcExtended[+key];
|
||||
}
|
||||
this._combined = combined;
|
||||
this._extendedAttrs = extendedAttrs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { perfContext, before, RuntimeCase } from 'xterm-benchmark';
|
||||
import { BufferLine } 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';
|
||||
|
||||
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 } {
|
||||
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);
|
||||
}
|
||||
return { recycled, blank };
|
||||
}
|
||||
|
||||
perfContext('BufferLine.copyFrom (scroll recycle)', () => {
|
||||
perfContext('cols=80', () => {
|
||||
let recycled: BufferLine;
|
||||
let blank: BufferLine;
|
||||
before(() => {
|
||||
({ recycled, blank } = setupScrollRecyclePair(80));
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
recycled.copyFrom(blank);
|
||||
}
|
||||
return { payloadSize: ITERATIONS };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('cols=279', () => {
|
||||
let recycled: BufferLine;
|
||||
let blank: BufferLine;
|
||||
before(() => {
|
||||
({ recycled, blank } = setupScrollRecyclePair(279));
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
recycled.copyFrom(blank);
|
||||
}
|
||||
return { payloadSize: ITERATIONS };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user