Skip to content

Commit

Permalink
Add a new LRUMap class, remove uses of Map
Browse files Browse the repository at this point in the history
LRUMap implements the parts of an ordered map that we need to
efficiently implement DynamicCharAtlas. It's more code, but there's a
few advantages of this approach:

- Map isn't available on some older browsers, so this removes the need
  for a polyfill.

- Moving an item to the end of the map's iteration order now only
  requires unlinking and linking a linked-list node, whereas before we
  had to delete and re-insert our value.

- Peeking at the oldest entry in the map no longer requires allocating
  and destroying an iterator.

- We can preallocate the linked-list nodes we want to improve cache
  locality. Similarly, we can recycle linked-list nodes to reduce
  allocations and the GC pauses those allocations may cause.

- LRUMap seems to give slightly better results in Chrome's profiler than
  Map did. We now spend about 5% of our time on map operations instead
  of about 10%.

- In my (limited) testing, it doesn't look like LRUMap is slowing down
  over time. Map appeared to get slightly slower the longer I ran the
  terminal for, either due to memory fragmentation or some sort of leak.

I still need to write some tests for LRUMap, but I've been using this
implementation for the last hour without problems.
  • Loading branch information
bgw committed Mar 17, 2018
1 parent be810f8 commit 7c3a30f
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 27 deletions.
33 changes: 9 additions & 24 deletions src/renderer/atlas/DynamicCharAtlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,26 @@ import { DIM_OPACITY, IGlyphIdentifier, INVERTED_DEFAULT_COLOR } from './Types';
import { ICharAtlasConfig } from '../../shared/atlas/Types';
import BaseCharAtlas from './BaseCharAtlas';
import { clearColor } from '../../shared/atlas/CharAtlasGenerator';
import LRUMap from './LRUMap';

// In practice we're probably never going to exhaust a texture this large. For debugging purposes,
// however, it can be useful to set this to a really tiny value, to verify that LRU eviction works.
const TEXTURE_WIDTH = 1024;
const TEXTURE_HEIGHT = 1024;

type GlyphCacheKey = string;

interface IGlyphCacheValue {
index: number;
isEmpty: boolean;
}

/**
* Removes and returns the oldest element in a map.
*/
function mapShift<K, V>(map: Map<K, V>): [K, V] {
// Map guarantees insertion-order iteration.
const entry = map.entries().next().value;
if (entry === undefined) {
return undefined;
}
map.delete(entry[0]);
return entry;
}

function getGlyphCacheKey(glyph: IGlyphIdentifier): GlyphCacheKey {
function getGlyphCacheKey(glyph: IGlyphIdentifier): string {
return `${glyph.bg}_${glyph.fg}_${glyph.bold ? 0 : 1}${glyph.dim ? 0 : 1}${glyph.char}`;
}

export default class DynamicCharAtlas extends BaseCharAtlas {
// An ordered map that we're using to keep track of where each glyph is in the atlas texture.
// It's ordered so that we can determine when to remove the old entries.
private _cacheMap: Map<GlyphCacheKey, IGlyphCacheValue> = new Map();
private _cacheMap: LRUMap<IGlyphCacheValue>;

// The texture that the atlas is drawn to
private _cacheCanvas: HTMLCanvasElement;
Expand All @@ -51,7 +37,6 @@ export default class DynamicCharAtlas extends BaseCharAtlas {
private _tmpCtx: CanvasRenderingContext2D;

// The number of characters stored in the atlas by width/height
private _capacity: number;
private _width: number;
private _height: number;

Expand All @@ -70,7 +55,9 @@ export default class DynamicCharAtlas extends BaseCharAtlas {

this._width = Math.floor(TEXTURE_WIDTH / this._config.scaledCharWidth);
this._height = Math.floor(TEXTURE_HEIGHT / this._config.scaledCharHeight);
this._capacity = this._width * this._height;
const capacity = this._width * this._height;
this._cacheMap = new LRUMap(capacity);
this._cacheMap.prealloc(capacity);

// This is useful for debugging
// document.body.appendChild(this._cacheCanvas);
Expand All @@ -85,17 +72,15 @@ export default class DynamicCharAtlas extends BaseCharAtlas {
const glyphKey = getGlyphCacheKey(glyph);
const cacheValue = this._cacheMap.get(glyphKey);
if (cacheValue != null) {
// move to end of insertion order, so this can behave like an LRU cache
this._cacheMap.delete(glyphKey);
this._cacheMap.set(glyphKey, cacheValue);
this._drawFromCache(ctx, cacheValue, x, y);
return true;
} else if (this._canCache(glyph)) {
let index;
if (this._cacheMap.size < this._capacity) {
if (this._cacheMap.size < this._cacheMap.capacity) {
index = this._cacheMap.size;
} else {
index = mapShift(this._cacheMap)[1].index;
// we're out of space, so our call to set will delete this item
index = this._cacheMap.peek().index;
}
const cacheValue = this._drawToCache(glyph, index);
this._cacheMap.set(glyphKey, cacheValue);
Expand Down
121 changes: 121 additions & 0 deletions src/renderer/atlas/LRUMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/

interface ILinkedListNode<T> {
prev: ILinkedListNode<T>,
next: ILinkedListNode<T>,
key: string,
value: T,
}

export default class LRUMap<T> {
private _map = {};
private _head: ILinkedListNode<T> = null;
private _tail: ILinkedListNode<T> = null;
private _nodePool: ILinkedListNode<T>[] = [];
public size: number = 0;

constructor(public capacity: number) { }

private _unlinkNode(node: ILinkedListNode<T>): void {
const prev = node.prev;
const next = node.next;
if (node === this._head) {
this._head = next;
}
if (node === this._tail) {
this._tail = prev;
}
if (prev !== null) {
prev.next = next;
}
if (next !== null) {
next.prev = prev;
}
}

private _appendNode(node: ILinkedListNode<T>): void {
node.prev = this._tail;
node.next = null;
this._tail = node;
if (this._head === null) {
this._head = node;
}
}

/**
* Preallocate a bunch of linked-list nodes. Allocating these nodes ahead of time means that
* they're more likely to live next to each other in memory, which seems to improve performance.
*
* Each empty object only consumes about 60 bytes of memory, so this is pretty cheap, even for
* large maps.
*/
public prealloc(count: number) {
const nodePool = this._nodePool;
for (let i = 0; i < count; i++) {
nodePool.push({
prev: null,
next: null,
key: null,
value: null,
});
}
}

public get(key: string): T | null {
// This is unsafe: We're assuming our keyspace doesn't overlap with Object.prototype. However,
// it's faster than calling hasOwnProperty, and in our case, it would never overlap.
const node = this._map[key];
if (node !== undefined) {
this._unlinkNode(node);
this._appendNode(node);
return node.value;
}
return null;
}

public peek(): T | null {
const head = this._head;
return head === null ? null : head.value;
}

public set(key: string, value: T): void {
// This is unsafe: See note above.
let node = this._map[key];
if (node !== undefined) {
// already exists, we just need to mutate it and move it to the end of the list
node = this._map[key];
this._unlinkNode(node);
node.value =