Add a new LRUMap class, remove uses of Map

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.
This commit is contained in:
Benjamin Woodruff
2018-04-15 18:52:06 -07:00
parent c8b500c182
commit 8b3b7535c6
3 changed files with 131 additions and 27 deletions
+9 -24
View File
@@ -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;
@@ -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;
@@ -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);
@@ -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);
+121
View File
@@ -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 = value;
} else if (this.size >= this.capacity) {
// we're out of space: recycle the head node, move it to the tail
node = this._head;
this._unlinkNode(node);
delete this._map[node.key];
node.key = key;
node.value = value;
this._map[key] = node;
} else {
// make a new element
const nodePool = this._nodePool;
if (nodePool.length > 0) {
// use a preallocated node if we can
node = nodePool.pop();
node.key = key;
node.value = value;
} else {
node = {
prev: null,
next: null,
key,
value,
};
}
this._map[key] = node;
this.size++;
}
this._appendNode(node);
}
}
+1 -3
View File
@@ -6,9 +6,7 @@
"DOM",
"ES5",
"ScriptHost",
"ES2015.Promise",
"ES2015.Collection",
"ES2015.Iterable"
"ES2015.Promise"
],
"rootDir": "src",
"outDir": "lib",