From 7c3a30f23969423709f284820986335b277c63ad Mon Sep 17 00:00:00 2001 From: Benjamin Woodruff Date: Fri, 16 Mar 2018 23:24:45 -0700 Subject: [PATCH] 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. --- src/renderer/atlas/DynamicCharAtlas.ts | 33 ++----- src/renderer/atlas/LRUMap.ts | 121 +++++++++++++++++++++++++ tsconfig.json | 4 +- 3 files changed, 131 insertions(+), 27 deletions(-) create mode 100644 src/renderer/atlas/LRUMap.ts diff --git a/src/renderer/atlas/DynamicCharAtlas.ts b/src/renderer/atlas/DynamicCharAtlas.ts index 0e7664347b..ebe70b8511 100644 --- a/src/renderer/atlas/DynamicCharAtlas.ts +++ b/src/renderer/atlas/DynamicCharAtlas.ts @@ -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(map: Map): [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 = new Map(); + private _cacheMap: LRUMap; // 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); diff --git a/src/renderer/atlas/LRUMap.ts b/src/renderer/atlas/LRUMap.ts new file mode 100644 index 0000000000..942641780b --- /dev/null +++ b/src/renderer/atlas/LRUMap.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +interface ILinkedListNode { + prev: ILinkedListNode, + next: ILinkedListNode, + key: string, + value: T, +} + +export default class LRUMap { + private _map = {}; + private _head: ILinkedListNode = null; + private _tail: ILinkedListNode = null; + private _nodePool: ILinkedListNode[] = []; + public size: number = 0; + + constructor(public capacity: number) { } + + private _unlinkNode(node: ILinkedListNode): 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): 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); + } +} diff --git a/tsconfig.json b/tsconfig.json index 7525c3def6..e56930e639 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,9 +6,7 @@ "DOM", "ES5", "ScriptHost", - "ES2015.Promise", - "ES2015.Collection", - "ES2015.Iterable" + "ES2015.Promise" ], "rootDir": "src", "outDir": "lib",