为什么在此示例中使用生成器函数比填充和迭代数组慢?

Why is using a generator function slower than filling and iterating an array in this example?

两个函数的故事

我有一个函数可以将数组填充到指定值:

function getNumberArray(maxValue) {
    const a = [];

    for (let i = 0; i < maxValue; i++) {
        a.push(i);
    }

    return a;
}

还有一个类似的生成器函数,而是生成每个值:

function* getNumberGenerator(maxValue) {
    for (let i = 0; i < maxValue; i++) {
        yield i;
    }
}

测试运行程序

我已经为这两种情况编写了这个测试:

function runTest(testName, numIterations, funcToTest) {
    console.log(`Running ${testName}...`);
    let dummyCalculation;
    const startTime = Date.now();
    const initialMemory = process.memoryUsage();
    const iterator = funcToTest(numIterations);

    for (let val of iterator) {
        dummyCalculation = numIterations - val;
    }

    const finalMemory = process.memoryUsage();

    // note: formatNumbers can be found here: https://jsfiddle.net/onz1ozjq/
    console.log(formatNumbers `Total time: ${Date.now() - startTime}ms`);
    console.log(formatNumbers `Rss:        ${finalMemory.rss - initialMemory.rss}`);
    console.log(formatNumbers `Heap Total: ${finalMemory.heapTotal - initialMemory.heapTotal}`);
    console.log(formatNumbers `Heap Used:  ${finalMemory.heapUsed - initialMemory.heapUsed}`);
}

运行 测试

那么当运行这两个像这样的时候:

const numIterations = 999999; // 999,999
console.log(formatNumbers `Running tests with ${numIterations} iterations...\n`);
runTest("Array test", numIterations, getNumberArray);
console.log("");
runTest("Generator test", numIterations, getNumberGenerator);

我得到与此类似的结果:

Running tests with 999,999 iterations...

Running Array test...
Total time: 105ms
Rss:        31,645,696
Heap Total: 31,386,624
Heap Used:  27,774,632

Running Function generator test...
Total time: 160ms
Rss:        2,818,048
Heap Total: 0
Heap Used:  1,836,616

注意:我 运行 这些测试是在 Windows 8.1 上的节点 v4.1.1 上进行的。我没有使用转译器,我 运行 通过 node --harmony generator-test.js.

问题

数组的内存使用量增加显然是预料之中的...但为什么我总是能更快地获得数组的结果?是什么导致了这里的放缓?做 yield 只是一项昂贵的操作吗?或者也许我检查这个的方法有问题?

非常不满意的答案是可能:您的 ES5 函数依赖于(letconst 除外)的功能V8 自 2008 年发布以来(据我所知,V8 起源于 Google 的网络爬虫的一部分,大概有一段时间了)。另一方面,生成器仅出现在 V8 since 2013 中。因此,不仅 ES5 代码有 7 年的时间进行优化,而 ES6 代码只有 2 年,几乎没有人(与使用 ES5 代码的代码的数百万站点相比)在 V8 中使用生成器,这意味着发现或实施优化的机会很少。

如果您真的想要一个关于为什么生成器在 Node.js 中相对较慢的技术答案,您可能需要自己深入研究 V8 源代码,或者问写它的人。

尝试用作用域为 'var' 的函数替换生成器函数中的 'let'。似乎循环内的 'let' 会产生很多开销。仅在绝对必要时才使用 let

仅供参考,这个问题在互联网术语中很古老,生成器已经赶上了(至少在 Chrome 中测试过)https://jsperf.com/generator-vs-loops1

事实上,运行 现在这个基准测试,生成器的速度快了约 2 倍。

我稍微修改了代码(移动了 let i),这里是完整的要点:https://gist.github.com/antonkatz/6b0911c85ddadae39c434bf8ac32b340

在我的机器上,这些是结果:

运行数组... 总时间:4,022 毫秒 RSS:2,728,939,520 堆总计:2,726,199,296 已用堆:2,704,236,368

运行 发电机... 总时间:2,541 毫秒 RSS:851,968 堆总数:0 已用堆:-5,073,968

我自己很好奇,找不到合适的答案。感谢@David 提供测试代码

在 OP 的例子中,生成器总是比较慢

虽然 JS 引擎作者将继续努力提高性能,但有一些潜在的结构现实实际上保证,对于 OP 的测试用例,构建数组将总是更快比使用迭代器。

考虑一个生成器函数 return 是一个 generator object

根据定义,生成器对象将有一个 next() 函数 ,并调用 [=85] 中的一个函数=] 表示向您的调用堆栈添加一个条目。虽然这很快,但它可能永远不会像直接 属性 访问那样快。 (至少,直到奇点。)

因此,如果您要遍历集合中的每个元素,那么通过直接 属性 访问访问元素的简单数组的 for 循环总是比 for 更快遍历一个迭代器,它通过调用 next() 函数来访问每个元素。

当我在 2022 年 1 月写这篇文章时,运行 Chrome 97,生成器函数 60% slower 比数组函数 使用 OP 的示例.

性能取决于用例

不难想象生成器会更快的场景。数组函数的主要缺点是 它必须构建整个集合,然后代码才能开始遍历元素,无论您是否需要所有元素。

考虑一个基本的搜索操作,它平均只会访问集合中一半的元素。在这种情况下,数组函数暴露了它的“阿喀琉斯之踵”:它必须构建一个包含所有结果的数组,即使永远看不到一半。这就是生成器有可能远远超过数组函数的地方。

为了证明这一点,I slightly modified the OP's use-case。我使数组元素的计算成本稍高(使用一些除法和平方根逻辑)并修改了循环以在大约一半处终止(以模拟基本搜索)。

设置

function getNumberArray(maxValue) {
    const a = [];

    for (let i = 0; i < maxValue; i++) {
        const half = i / 2;
        const double = half * 2;
        const root = Math.sqrt(double);
        const square = Math.round(root * root);
        a.push(square);
    }

    return a;
}

function* getNumberGenerator(maxValue) {
    for (let i = 0; i < maxValue; i++) {
        const half = i / 2;
        const double = half * 2;
        const root = Math.sqrt(double);
        const square = Math.round(root * root);
        yield square;
    }
}
let dummyCalculation;
const numIterations = 99999;
const searchNumber = numIterations / 2;

发电机

const iterator = getNumberGenerator(numIterations);
for (let val of iterator) {
    dummyCalculation = numIterations - val;
    if (val > searchNumber) break;
}

数组

const iterator = getNumberArray(numIterations);
for (let val of iterator) {
    dummyCalculation = numIterations - val;
    if (val > searchNumber) break;
}

使用此代码,这两种方法并驾齐驱。经过反复的试运行,生成器和数组函数名列前茅。不难想象,如果数组元素的计算成本更高(例如,克隆一个复杂对象,进行 REST 标注等),那么生成器将轻松获胜。

性能之外的考虑因素

虽然认识到 OP 的问题具体是关于性能的,但我认为值得指出的是,生成器函数最初并不是作为循环数组的更快替代方法开发的。

内存效率

OP 已经承认这一点,但内存效率是生成器提供的构建数组的主要好处之一。生成器可以动态构建对象,然后在不再需要时丢弃它们。在最理想的实现中,生成器一次只需要在内存中保存一个对象,而数组必须同时保存所有对象。

对于非常占用内存的集合,生成器将允许系统根据需要构建对象,然后在调用代码移至下一个元素时回收该内存。

非静态集合的表示

生成器不必解析整个集合,这会释放它们来表示可能不完全存在于内存中的集合。

例如,生成器可以表示集合,其中获取“下一个”项目的逻辑非常耗时(例如分页数据库查询的结果,其中项目是分批获取的)或依赖于状态(例如迭代一个集合,其中对当前项目的操作会影响哪个项目是“下一个”)或。在这些情况下,构建数组要么不切实际,要么不可能。

例如,生成器可用于 return 无穷无尽的随机数据,或表示每次迭代部分基于前一次迭代的游戏关卡,甚至表示理论 AI 的“意识流”(例如,玩单词联想游戏)。这些是使用标准数组或列表无法表示的有趣场景,但循环结构在代码中感觉更自然。