问题描述
我正在用 Javascript 开发游戏,这涉及尽快运行渲染循环。我注意到 GC(垃圾收集器)峰值中断了平滑的帧速率,在分析之后我发现我的实体查询系统正在生成大量垃圾。
这样做时,我发现了 Javascript 中的 iterators cause allocations。在我的代码的一部分中,我正在做这样的事情:
let minimumSet = this.getSmallestEntitySet(componentTypes);
for (let entity of minimumSet) {
// handle entity
}
不幸的是,仅仅执行 for of
循环会导致分配(因为它每次运行循环时都会创建一个新的迭代器对象),并且由于它经常运行,因此会产生大量垃圾。我环顾四周,但不知道是否有办法在不执行分配的情况下迭代 Set
、Map
或 Object
。例如,使用 Javascript 数组,您可以迭代它,同时避免像这样的分配:
for (let i = 0; i < arr.length; i++) {
let item = arr[i];
// handle item
}
但是我找不到只用常规的 for 循环来像这样迭代 Map
或 Set
的方法。可能吗?
假设这是不可能的,我认为解决这个问题的方法是使用排序数组而不是映射,对吗?唯一的问题是插入和删除是 O(log n)
而不是 O(1)
,但我认为这可能是值得的,只是为了避免分配。
有没有办法在不执行分配的情况下迭代这些集合,这是我最好的选择吗?
解决方法
(此处为 V8 开发人员。)
正如您所链接的问题所指出的,JavaScript 引擎(至少 V8)确实优化了迭代协议的规范文本所讨论的临时对象;尽管完整的答案(通常如此)是“视情况而定”。
一个效果是优化编译器会做的事情是优化,而那些不会立即启动(因为通常情况下,这会浪费精力)。所以如果你只运行一个函数几次,你会看到它的未优化版本分配了短暂的垃圾。但这很快就会改变(具体取决于引擎),一旦该功能被认为是“热门”并得到优化。
您可能遇到的另一个问题是直接迭代 Map
(如:let map = new Map(); ...; for (let e of map) {...}
被指定为每次都将 e
作为数组 [key,value]
返回。这个数组没有被优化掉;它必须在每次迭代时分配。但是如果你只对处理值感兴趣,你可以避免通过只迭代值来创建它:for (let v of map.values()) {...}
不分配任何短期对象。迭代 map.keys()
也是如此。
如果您同时需要键和值,您可以将键上的迭代与地图查找结合起来:for (let key of map.keys()) { let value = map.get(key); ...}
,但是这比对值进行迭代要慢得多。如果您的对象在您的回答中实现了 interface Entry
,即它们有一个携带其密钥的属性,那么您可以使用它:for (let value of map.values()) { let key = value.id; ...}
。
总而言之:如果 SparseSet
解决方案适合您,那当然也很酷。你甚至可以让它更高效一点:如果你将 add
改为 add(item: T) { let key = item.id; ...}
并更新 delete
以包含 this.sparse.delete(key)
,那么集合本身可以保证它的内部数据是始终一致,然后 contains
可以像 return this.sparse.get(key) !== undefined;
一样简单。
我认为解决这个问题的方法是使用排序数组而不是地图,对吗?唯一的问题是插入和删除是 O(log n)
在已排序数组上插入和删除是 O(n),因为您可能需要移动整个内容。 (如果您使用二进制搜索,则通过其排序键查找元素需要 O(log n)。)但是,使用 unsorted 数组可能比直觉上预期的要快,只要它们仍然很小(大约有几十个条目)。类似的东西:
class UnsortedArray<T extends Entry> {
contents: Array<T> = [];
add(item: T) { contents.push(T); } // optional: check for duplicates first
private find(key: number) {
for (let i = 0; i < contents.length; i++) {
if (contents[i].id == key) return i;
}
return -1;
}
size() { return contents.length; }
get_by_index(index: number) { return contents[index]; }
get_by_key(key: number) { return contents[find(key)]; }
has(item: T) { return find(T.id) >= 0; }
delete(item: T) {
let index = find(T.id);
if (index < 0) return;
let last = contents.pop();
if (index !== contents.length) contents[index] = last;
}
}
这使您可以在 O(1) 中插入、无开销的经典迭代 (for (let i = 0; i < unsorted_array.size(); i++) { let entry = unsorted_array.get_by_index(i); ...}
)、删除和 has
中的 O(n)。一旦超过 30-50 个元素,我预计 has
实际上只会比对有序数组进行二分搜索慢;当然,has
性能是否重要取决于您的用例。
我想到的临时解决方法,直到找到更好的方法。基本上,只使用稀疏集。
interface Entry {
id: number;
}
export class SparseSet<T extends Entry> {
dense: Array<T>;
sparse: Map<number,number>;
constructor() {
this.dense = [];
this.sparse = new Map<number,number>();
}
get size() {
return this.dense.length;
}
contains(key: number) {
let denseIndex = this.sparse.get(key);
if (denseIndex === undefined || denseIndex >= this.dense.length) {
return false;
}
let denseItem = this.dense[denseIndex];
return denseItem.id === key;
}
delete(key: number) {
if (!this.contains(key)) { return; }
let denseIndex = this.sparse.get(key);
let lastItem = this.dense.pop();
if (this.dense.length > 0) {
this.dense[denseIndex] = lastItem;
this.sparse.set(lastItem.id,denseIndex);
}
}
add(key: number,item: T) {
if (this.contains(key)) { return; }
this.sparse.set(key,this.dense.length);
this.dense.push(item);
}
}
然后你可以像这样添加和删除:
let entity = new Entity();
let items = new SparseSet<Entity>();
items.add(entity.id,entity);
items.delete(entity.id);
并在不执行分配的情况下进行迭代:
for (let i = 0; i < items.size; i++) {
let item = items.dense[i];
}