【问题标题】:How to iterate a Javascript Map or Object without allocating?如何在不分配的情况下迭代 Javascript 映射或对象?
【发布时间】:2021-08-04 06:30:15
【问题描述】:

我正在用 Javascript 开发一款游戏,其中涉及尽快运行渲染循环。我注意到 GC(垃圾收集器)峰值中断了平滑的帧速率,并且在分析后我发现我的实体查询系统正在生成大量垃圾。

在这样做的过程中,我在 Javascript 中发现了 iterators cause allocations。在我的代码的一部分中,我正在做这样的事情:

let minimumSet = this.getSmallestEntitySet(componentTypes);

for (let entity of minimumSet) {
  // handle entity
}

不幸的是,只是执行for of 循环会导致分配(因为它每次运行循环时都会创建一个新的迭代器对象),并且由于它经常运行,因此会产生大量垃圾。我环顾四周,不知道是否有办法在不执行分配的情况下迭代SetMapObject。例如,使用 Javascript 数组,您可以迭代它,同时避免像这样的分配:

for (let i = 0; i < arr.length; i++) {
  let item = arr[i];

  // handle item
}

但我找不到像这样只使用常规 for 循环来迭代 MapSet 的方法。有可能吗?

假设这是不可能的,我假设解决方法是使用排序数组而不是映射,对吗?唯一的问题是插入和删除是O(log n) 而不是O(1),但我想这可能是值得的,只是为了避免分配。

这是我最好的选择,有没有一种方法可以在不执行分配的情况下迭代这些集合?

【问题讨论】:

  • 你怎么知道迭代器对垃圾负责?从您自己的链接来看,优秀的 JS 引擎会优化此类循环中涉及的所有对象,以支持直接迭代底层容器的后备存储。
  • @ShadowRanger 您可以通过运行一个紧密循环来确定这一点,其中一个使用for of,另一个使用标准for,并分析Chrome 任务管理器@987654335 中的Javascript memory 列@。或者,您也可以使用 Chrome 分析器在开发者工具的 Memory 选项卡中分析分配情况。
  • 对我来说听起来像是 V8 回归。
  • "有可能吗?" - 没有。
  • 我想到了一个解决方案。我会发布一个答案。

标签: javascript performance iterator set v8


【解决方案1】:

我想到的临时解决方法,直到我找到更好的方法。基本上,只使用一个稀疏集。

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];
} 

【讨论】:

    【解决方案2】:

    (这里是 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 &lt; unsorted_array.size(); i++) { let entry = unsorted_array.get_by_index(i); ...}),在 O(n) 中删除和 has。我希望has 在超过 30-50 个元素后实际上只会比在有序数组上进行二进制搜索要慢;当然,has 性能是否重要取决于您的用例。

    【讨论】:

    • 感谢您的精彩回答!实际上,我最终遇到了一个对于这个评论框来说太长的问题,所以如果可以的话,我又提出了一个问题:stackoverflow.com/questions/67541538/…
    猜你喜欢
    • 2016-09-21
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多