Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

some buffer handling optimizations #4115

Merged
merged 14 commits into from
Dec 7, 2022
30 changes: 20 additions & 10 deletions src/common/TaskQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import { isNode } from 'common/Platform';
interface ITaskQueue {
/**
* Adds a task to the queue which will run in a future idle callback.
* To avoid perceivable stalls on the mainthread, tasks with heavy workload
* should split their work into smaller pieces and return `true` to get
* called again until the work is done (on falsy return value).
*/
enqueue(task: () => void): void;
enqueue(task: () => boolean | void): void;

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

abstract class TaskQueue implements ITaskQueue {
private _tasks: (() => void)[] = [];
private _tasks: (() => boolean | void)[] = [];
private _idleCallback?: number;
private _i = 0;

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

public enqueue(task: () => void): void {
public enqueue(task: () => boolean | void): void {
this._tasks.push(task);
this._start();
}

public flush(): void {
while (this._i < this._tasks.length) {
this._tasks[this._i++]();
if (!this._tasks[this._i]()) {
this._i++;
}
}
this.clear();
}
Expand All @@ -67,9 +72,14 @@ abstract class TaskQueue implements ITaskQueue {
let taskDuration = 0;
let longestTask = 0;
while (this._i < this._tasks.length) {
taskDuration = performance.now();
this._tasks[this._i++]();
taskDuration = performance.now() - taskDuration;
taskDuration = Date.now();
if (!this._tasks[this._i]()) {
this._i++;
}
// other than performance.now, Date.now might not be stable (changes on wall clock changes),
// this is not an issue here as a clock change during a short running task is very unlikely
// in case it still happened and leads to negative duration, simply assume 1 msec
taskDuration = Math.max(1, Date.now() - taskDuration);
longestTask = Math.max(taskDuration, longestTask);
// Guess the following task will take a similar time to the longest task in this batch, allow
// additional room to try avoid exceeding the deadline
Expand Down Expand Up @@ -97,9 +107,9 @@ export class PriorityTaskQueue extends TaskQueue {
}

private _createDeadline(duration: number): ITaskDeadline {
const end = performance.now() + duration;
const end = Date.now() + duration;
return {
timeRemaining: () => Math.max(0, end - performance.now())
timeRemaining: () => Math.max(0, end - Date.now())
};
}
}
Expand Down Expand Up @@ -136,7 +146,7 @@ export class DebouncedIdleTask {
this._queue = new IdleTaskQueue();
}

public set(task: () => void): void {
public set(task: () => boolean | void): void {
this._queue.clear();
this._queue.enqueue(task);
}
Expand Down
3 changes: 2 additions & 1 deletion src/common/Types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ export interface IBufferLine {
insertCells(pos: number, n: number, ch: ICellData, eraseAttr?: IAttributeData): void;
deleteCells(pos: number, n: number, fill: ICellData, eraseAttr?: IAttributeData): void;
replaceCells(start: number, end: number, fill: ICellData, eraseAttr?: IAttributeData, respectProtect?: boolean): void;
resize(cols: number, fill: ICellData): void;
resize(cols: number, fill: ICellData): boolean;
cleanupMemory(): number;
fill(fillCellData: ICellData, respectProtect?: boolean): void;
copyFrom(line: IBufferLine): void;
clone(): IBufferLine;
Expand Down
30 changes: 30 additions & 0 deletions src/common/buffer/Buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CircularList } from 'common/CircularList';
import { MockOptionsService, MockBufferService } from 'common/TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { ExtendedAttrs } from 'common/buffer/AttributeData';

const INIT_COLS = 80;
const INIT_ROWS = 24;
Expand Down Expand Up @@ -1177,4 +1178,33 @@ describe('Buffer', () => {
assert.equal(str3, '😁a');
});
});

describe('memory cleanup after shrinking', () => {
it('should realign memory from idle task execution', async () => {
buffer.fillViewportRows();

// shrink more than 2 times to trigger lazy memory cleanup
buffer.resize(INIT_COLS / 2 - 1, INIT_ROWS);

// sync
for (let i = 0; i < INIT_ROWS; i++) {
const line = buffer.lines.get(i)!;
// line memory is still at old size from initialization
assert.equal((line as any)._data.buffer.byteLength, INIT_COLS * 3 * 4);
// array.length and .length get immediately adjusted
assert.equal((line as any)._data.length, (INIT_COLS / 2 - 1) * 3);
assert.equal(line.length, INIT_COLS / 2 - 1);
}

// wait for a bit to give IdleTaskQueue a chance to kick in
// and finish memory cleaning
await new Promise(r => setTimeout(r, 100));

// cleanup should have realigned memory with exact bytelength
for (let i = 0; i < INIT_ROWS; i++) {
const line = buffer.lines.get(i)!;
assert.equal((line as any)._data.buffer.byteLength, (INIT_COLS / 2 - 1) * 3 * 4);
}
});
});
});
31 changes: 29 additions & 2 deletions src/common/buffer/Buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Marker } from 'common/buffer/Marker';
import { IOptionsService, IBufferService } from 'common/services/Services';
import { DEFAULT_CHARSET } from 'common/data/Charsets';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
import { DebouncedIdleTask, IdleTaskQueue } from 'common/TaskQueue';

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

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

// count bufferlines with overly big memory to be cleaned afterwards
let dirtyMemoryLines = 0;

// Increase max length if needed before adjustments to allow space to fill
// as required.
const newMaxLength = this._getCorrectBufferLength(newRows);
Expand All @@ -163,7 +167,8 @@ export class Buffer implements IBuffer {
// Deal with columns increasing (reducing needs to happen after reflow)
if (this._cols < newCols) {
for (let i = 0; i < this.lines.length; i++) {
this.lines.get(i)!.resize(newCols, nullCell);
// +boolean for fast 0 or 1 conversion
dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell);
}
}

Expand Down Expand Up @@ -242,13 +247,35 @@ export class Buffer implements IBuffer {
// Trim the end of the line off if cols shrunk
if (this._cols > newCols) {
for (let i = 0; i < this.lines.length; i++) {
this.lines.get(i)!.resize(newCols, nullCell);
// +boolean for fast 0 or 1 conversion
dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell);
}
}
}

this._cols = newCols;
this._rows = newRows;

this._memoryCleanupQueue.clear();
// schedule memory cleanup only, if more than 10% of the lines are affected
if (dirtyMemoryLines > 0.1 * this.lines.length) {
this._memoryCleanupQueue.enqueue(() => this._batchedMemoryCleanup());
}
}

private _memoryCleanupQueue = new IdleTaskQueue();

private _batchedMemoryCleanup(): boolean {
let counted = 0;
for (let i = 0; i < this.lines.length; i++) {
counted += this.lines.get(i)!.cleanupMemory();
// throttle to 500 lines at once and
// return true to indicate, that the task is not finished yet
if (counted > 500) {
return true;
}
}
return false;
}

private get _isReflowEnabled(): boolean {
Expand Down
78 changes: 54 additions & 24 deletions src/common/buffer/BufferLine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
// Work variables to avoid garbage collection
let $startIndex = 0;

/** Factor when to cleanup underlying array buffer after shrinking. */
const CLEANUP_THRESHOLD = 2;

/**
* Typed array based bufferline implementation.
*
Expand Down Expand Up @@ -333,42 +336,69 @@ export class BufferLine implements IBufferLine {
}
}

public resize(cols: number, fillCellData: ICellData): void {
/**
* Resize BufferLine to `cols` filling excess cells with `fillCellData`.
* The underlying array buffer will not change if there is still enough space
* to hold the new buffer line data.
* Returns a boolean indicating, whether a `cleanupMemory` call would free
* excess memory (true after shrinking > CLEANUP_THRESHOLD).
*/
public resize(cols: number, fillCellData: ICellData): boolean {
if (cols === this.length) {
return;
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
const uint32Cells = cols * CELL_SIZE;
if (cols > this.length) {
const data = new Uint32Array(cols * CELL_SIZE);
if (this.length) {
if (cols * CELL_SIZE < this._data.length) {
data.set(this._data.subarray(0, cols * CELL_SIZE));
} else {
data.set(this._data);
}
if (this._data.buffer.byteLength >= uint32Cells * 4) {
// optimization: avoid alloc and data copy if buffer has enough room
this._data = new Uint32Array(this._data.buffer, 0, uint32Cells);
} else {
// slow path: new alloc and full data copy
const data = new Uint32Array(uint32Cells);
data.set(this._data);
this._data = data;
}
this._data = data;
for (let i = this.length; i < cols; ++i) {
this.setCell(i, fillCellData);
}
} else {
if (cols) {
const data = new Uint32Array(cols * CELL_SIZE);
data.set(this._data.subarray(0, cols * CELL_SIZE));
this._data = data;
// Remove any cut off combined data, FIXME: repeat this for extended attrs
const keys = Object.keys(this._combined);
for (let i = 0; i < keys.length; i++) {
const key = parseInt(keys[i], 10);
if (key >= cols) {
delete this._combined[key];
}
// optimization: just shrink the view on existing buffer
this._data = this._data.subarray(0, uint32Cells);
// Remove any cut off combined data
const keys = Object.keys(this._combined);
for (let i = 0; i < keys.length; i++) {
const key = parseInt(keys[i], 10);
if (key >= cols) {
delete this._combined[key];
}
}
// remove any cut off extended attributes
const extKeys = Object.keys(this._extendedAttrs);
for (let i = 0; i < extKeys.length; i++) {
const key = parseInt(extKeys[i], 10);
if (key >= cols) {
delete this._extendedAttrs[key];
}
} else {
this._data = new Uint32Array(0);
this._combined = {};
}
}
this.length = cols;
return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}

/**
* Cleanup underlying array buffer.
* A cleanup will be triggered if the array buffer exceeds the actual used
* memory by a factor of CLEANUP_THRESHOLD.
* Returns 0 or 1 indicating whether a cleanup happened.
*/
public cleanupMemory(): number {
if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
const data = new Uint32Array(this._data.length);
data.set(this._data);
this._data = data;
return 1;
}
return 0;
}

/** fill a line with fillCharData */
Expand Down