Skip to content

Commit 6d46b7b

Browse files
authored
Merge pull request #4115 from jerch/buffer_optimizations
some buffer handling optimizations
2 parents fe30cd6 + 9bc65c3 commit 6d46b7b

File tree

5 files changed

+146
-37
lines changed

5 files changed

+146
-37
lines changed

src/common/TaskQueue.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import { isNode } from 'common/Platform';
88
interface ITaskQueue {
99
/**
1010
* Adds a task to the queue which will run in a future idle callback.
11+
* To avoid perceivable stalls on the mainthread, tasks with heavy workload
12+
* should split their work into smaller pieces and return `true` to get
13+
* called again until the work is done (on falsy return value).
1114
*/
12-
enqueue(task: () => void): void;
15+
enqueue(task: () => boolean | void): void;
1316

1417
/**
1518
* Flushes the queue, running all remaining tasks synchronously.
@@ -28,21 +31,23 @@ interface ITaskDeadline {
2831
type CallbackWithDeadline = (deadline: ITaskDeadline) => void;
2932

3033
abstract class TaskQueue implements ITaskQueue {
31-
private _tasks: (() => void)[] = [];
34+
private _tasks: (() => boolean | void)[] = [];
3235
private _idleCallback?: number;
3336
private _i = 0;
3437

3538
protected abstract _requestCallback(callback: CallbackWithDeadline): number;
3639
protected abstract _cancelCallback(identifier: number): void;
3740

38-
public enqueue(task: () => void): void {
41+
public enqueue(task: () => boolean | void): void {
3942
this._tasks.push(task);
4043
this._start();
4144
}
4245

4346
public flush(): void {
4447
while (this._i < this._tasks.length) {
45-
this._tasks[this._i++]();
48+
if (!this._tasks[this._i]()) {
49+
this._i++;
50+
}
4651
}
4752
this.clear();
4853
}
@@ -69,9 +74,14 @@ abstract class TaskQueue implements ITaskQueue {
6974
let lastDeadlineRemaining = deadline.timeRemaining();
7075
let deadlineRemaining = 0;
7176
while (this._i < this._tasks.length) {
72-
taskDuration = performance.now();
73-
this._tasks[this._i++]();
74-
taskDuration = performance.now() - taskDuration;
77+
taskDuration = Date.now();
78+
if (!this._tasks[this._i]()) {
79+
this._i++;
80+
}
81+
// other than performance.now, Date.now might not be stable (changes on wall clock changes),
82+
// this is not an issue here as a clock change during a short running task is very unlikely
83+
// in case it still happened and leads to negative duration, simply assume 1 msec
84+
taskDuration = Math.max(1, Date.now() - taskDuration);
7585
longestTask = Math.max(taskDuration, longestTask);
7686
// Guess the following task will take a similar time to the longest task in this batch, allow
7787
// additional room to try avoid exceeding the deadline
@@ -106,9 +116,9 @@ export class PriorityTaskQueue extends TaskQueue {
106116
}
107117

108118
private _createDeadline(duration: number): ITaskDeadline {
109-
const end = performance.now() + duration;
119+
const end = Date.now() + duration;
110120
return {
111-
timeRemaining: () => Math.max(0, end - performance.now())
121+
timeRemaining: () => Math.max(0, end - Date.now())
112122
};
113123
}
114124
}
@@ -145,7 +155,7 @@ export class DebouncedIdleTask {
145155
this._queue = new IdleTaskQueue();
146156
}
147157

148-
public set(task: () => void): void {
158+
public set(task: () => boolean | void): void {
149159
this._queue.clear();
150160
this._queue.enqueue(task);
151161
}

src/common/Types.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ export interface IBufferLine {
205205
insertCells(pos: number, n: number, ch: ICellData, eraseAttr?: IAttributeData): void;
206206
deleteCells(pos: number, n: number, fill: ICellData, eraseAttr?: IAttributeData): void;
207207
replaceCells(start: number, end: number, fill: ICellData, eraseAttr?: IAttributeData, respectProtect?: boolean): void;
208-
resize(cols: number, fill: ICellData): void;
208+
resize(cols: number, fill: ICellData): boolean;
209+
cleanupMemory(): number;
209210
fill(fillCellData: ICellData, respectProtect?: boolean): void;
210211
copyFrom(line: IBufferLine): void;
211212
clone(): IBufferLine;

src/common/buffer/Buffer.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CircularList } from 'common/CircularList';
99
import { MockOptionsService, MockBufferService } from 'common/TestUtils.test';
1010
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
1111
import { CellData } from 'common/buffer/CellData';
12+
import { ExtendedAttrs } from 'common/buffer/AttributeData';
1213

1314
const INIT_COLS = 80;
1415
const INIT_ROWS = 24;
@@ -1177,4 +1178,33 @@ describe('Buffer', () => {
11771178
assert.equal(str3, '😁a');
11781179
});
11791180
});
1181+
1182+
describe('memory cleanup after shrinking', () => {
1183+
it('should realign memory from idle task execution', async () => {
1184+
buffer.fillViewportRows();
1185+
1186+
// shrink more than 2 times to trigger lazy memory cleanup
1187+
buffer.resize(INIT_COLS / 2 - 1, INIT_ROWS);
1188+
1189+
// sync
1190+
for (let i = 0; i < INIT_ROWS; i++) {
1191+
const line = buffer.lines.get(i)!;
1192+
// line memory is still at old size from initialization
1193+
assert.equal((line as any)._data.buffer.byteLength, INIT_COLS * 3 * 4);
1194+
// array.length and .length get immediately adjusted
1195+
assert.equal((line as any)._data.length, (INIT_COLS / 2 - 1) * 3);
1196+
assert.equal(line.length, INIT_COLS / 2 - 1);
1197+
}
1198+
1199+
// wait for a bit to give IdleTaskQueue a chance to kick in
1200+
// and finish memory cleaning
1201+
await new Promise(r => setTimeout(r, 30));
1202+
1203+
// cleanup should have realigned memory with exact bytelength
1204+
for (let i = 0; i < INIT_ROWS; i++) {
1205+
const line = buffer.lines.get(i)!;
1206+
assert.equal((line as any)._data.buffer.byteLength, (INIT_COLS / 2 - 1) * 3 * 4);
1207+
}
1208+
});
1209+
});
11801210
});

src/common/buffer/Buffer.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Marker } from 'common/buffer/Marker';
1414
import { IOptionsService, IBufferService } from 'common/services/Services';
1515
import { DEFAULT_CHARSET } from 'common/data/Charsets';
1616
import { ExtendedAttrs } from 'common/buffer/AttributeData';
17+
import { DebouncedIdleTask, IdleTaskQueue } from 'common/TaskQueue';
1718

1819
export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
1920

@@ -150,6 +151,9 @@ export class Buffer implements IBuffer {
150151
// store reference to null cell with default attrs
151152
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
152153

154+
// count bufferlines with overly big memory to be cleaned afterwards
155+
let dirtyMemoryLines = 0;
156+
153157
// Increase max length if needed before adjustments to allow space to fill
154158
// as required.
155159
const newMaxLength = this._getCorrectBufferLength(newRows);
@@ -163,7 +167,8 @@ export class Buffer implements IBuffer {
163167
// Deal with columns increasing (reducing needs to happen after reflow)
164168
if (this._cols < newCols) {
165169
for (let i = 0; i < this.lines.length; i++) {
166-
this.lines.get(i)!.resize(newCols, nullCell);
170+
// +boolean for fast 0 or 1 conversion
171+
dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell);
167172
}
168173
}
169174

@@ -242,13 +247,46 @@ export class Buffer implements IBuffer {
242247
// Trim the end of the line off if cols shrunk
243248
if (this._cols > newCols) {
244249
for (let i = 0; i < this.lines.length; i++) {
245-
this.lines.get(i)!.resize(newCols, nullCell);
250+
// +boolean for fast 0 or 1 conversion
251+
dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell);
246252
}
247253
}
248254
}
249255

250256
this._cols = newCols;
251257
this._rows = newRows;
258+
259+
this._memoryCleanupQueue.clear();
260+
// schedule memory cleanup only, if more than 10% of the lines are affected
261+
if (dirtyMemoryLines > 0.1 * this.lines.length) {
262+
this._memoryCleanupPosition = 0;
263+
this._memoryCleanupQueue.enqueue(() => this._batchedMemoryCleanup());
264+
}
265+
}
266+
267+
private _memoryCleanupQueue = new IdleTaskQueue();
268+
private _memoryCleanupPosition = 0;
269+
270+
private _batchedMemoryCleanup(): boolean {
271+
let normalRun = true;
272+
if (this._memoryCleanupPosition >= this.lines.length) {
273+
// cleanup made it once through all lines, thus rescan in loop below to also catch shifted lines,
274+
// which should finish rather quick if there are no more cleanups pending
275+
this._memoryCleanupPosition = 0;
276+
normalRun = false;
277+
}
278+
let counted = 0;
279+
while (this._memoryCleanupPosition < this.lines.length) {
280+
counted += this.lines.get(this._memoryCleanupPosition++)!.cleanupMemory();
281+
// cleanup max 100 lines per batch
282+
if (counted > 100) {
283+
return true;
284+
}
285+
}
286+
// normal runs always need another rescan afterwards
287+
// if we made it here with normalRun=false, we are in a final run
288+
// and can end the cleanup task for sure
289+
return normalRun;
252290
}
253291

254292
private get _isReflowEnabled(): boolean {

src/common/buffer/BufferLine.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
4040
// Work variables to avoid garbage collection
4141
let $startIndex = 0;
4242

43+
/** Factor when to cleanup underlying array buffer after shrinking. */
44+
const CLEANUP_THRESHOLD = 2;
45+
4346
/**
4447
* Typed array based bufferline implementation.
4548
*
@@ -333,42 +336,69 @@ export class BufferLine implements IBufferLine {
333336
}
334337
}
335338

336-
public resize(cols: number, fillCellData: ICellData): void {
339+
/**
340+
* Resize BufferLine to `cols` filling excess cells with `fillCellData`.
341+
* The underlying array buffer will not change if there is still enough space
342+
* to hold the new buffer line data.
343+
* Returns a boolean indicating, whether a `cleanupMemory` call would free
344+
* excess memory (true after shrinking > CLEANUP_THRESHOLD).
345+
*/
346+
public resize(cols: number, fillCellData: ICellData): boolean {
337347
if (cols === this.length) {
338-
return;
348+
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
339349
}
350+
const uint32Cells = cols * CELL_SIZE;
340351
if (cols > this.length) {
341-
const data = new Uint32Array(cols * CELL_SIZE);
342-
if (this.length) {
343-
if (cols * CELL_SIZE < this._data.length) {
344-
data.set(this._data.subarray(0, cols * CELL_SIZE));
345-
} else {
346-
data.set(this._data);
347-
}
352+
if (this._data.buffer.byteLength >= uint32Cells * 4) {
353+
// optimization: avoid alloc and data copy if buffer has enough room
354+
this._data = new Uint32Array(this._data.buffer, 0, uint32Cells);
355+
} else {
356+
// slow path: new alloc and full data copy
357+
const data = new Uint32Array(uint32Cells);
358+
data.set(this._data);
359+
this._data = data;
348360
}
349-
this._data = data;
350361
for (let i = this.length; i < cols; ++i) {
351362
this.setCell(i, fillCellData);
352363
}
353364
} else {
354-
if (cols) {
355-
const data = new Uint32Array(cols * CELL_SIZE);
356-
data.set(this._data.subarray(0, cols * CELL_SIZE));
357-
this._data = data;
358-
// Remove any cut off combined data, FIXME: repeat this for extended attrs
359-
const keys = Object.keys(this._combined);
360-
for (let i = 0; i < keys.length; i++) {
361-
const key = parseInt(keys[i], 10);
362-
if (key >= cols) {
363-
delete this._combined[key];
364-
}
365+
// optimization: just shrink the view on existing buffer
366+
this._data = this._data.subarray(0, uint32Cells);
367+
// Remove any cut off combined data
368+
const keys = Object.keys(this._combined);
369+
for (let i = 0; i < keys.length; i++) {
370+
const key = parseInt(keys[i], 10);
371+
if (key >= cols) {
372+
delete this._combined[key];
373+
}
374+
}
375+
// remove any cut off extended attributes
376+
const extKeys = Object.keys(this._extendedAttrs);
377+
for (let i = 0; i < extKeys.length; i++) {
378+
const key = parseInt(extKeys[i], 10);
379+
if (key >= cols) {
380+
delete this._extendedAttrs[key];
365381
}
366-
} else {
367-
this._data = new Uint32Array(0);
368-
this._combined = {};
369382
}
370383
}
371384
this.length = cols;
385+
return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
386+
}
387+
388+
/**
389+
* Cleanup underlying array buffer.
390+
* A cleanup will be triggered if the array buffer exceeds the actual used
391+
* memory by a factor of CLEANUP_THRESHOLD.
392+
* Returns 0 or 1 indicating whether a cleanup happened.
393+
*/
394+
public cleanupMemory(): number {
395+
if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
396+
const data = new Uint32Array(this._data.length);
397+
data.set(this._data);
398+
this._data = data;
399+
return 1;
400+
}
401+
return 0;
372402
}
373403

374404
/** fill a line with fillCharData */

0 commit comments

Comments
 (0)