javascript 为什么在这个例子中使用生成器函数比填充和迭代数组慢?

chhqkbe1  于 2023-05-12  发布在  Java
关注(0)|答案(5)|浏览(86)

两个函数的故事

我有一个函数,它填充一个数组到指定的值:

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来运行它。

问题

数组增加的内存使用显然是意料之中的...但为什么我总是能更快地得到阵列的结果呢?是什么导致了这里的减速?做产量只是一个昂贵的操作吗?或者我检查这个的方法有问题?

svmlkihl

svmlkihl1#

在OP的例子中,生成器总是会慢一些

虽然JS引擎的作者将继续努力提高性能,但有一些潜在的结构现实几乎可以保证,对于OP的测试用例,构建数组将 * 总是 * 比使用迭代器更快。
假设生成器函数返回generator object
根据定义,生成器对象将具有next()函数,在JavaScript中调用函数意味着向调用堆栈添加一个条目。虽然这很快,但它可能永远不会像直接访问财产那样快。(至少在奇点出现之前不会。)
因此,如果你要迭代集合中的每个元素,那么在简单数组上的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 callout等),那么生成器将轻松获胜。

性能之外的考虑

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

内存效率

OP已经承认了这一点,但内存效率是生成器提供的构建数组的主要好处之一。生成器可以动态地构建对象,然后在不再需要它们时丢弃它们。在最理想的实现中,生成器一次只需要在内存中保存一个对象,而数组必须同时保存所有对象。
对于内存非常密集的集合,生成器将允许系统在需要时构建对象,然后在调用代码移动到下一个元素时回收内存。

非静态集合表示

生成器不必解析整个集合,这使它们可以自由地表示可能不完全存在于内存中的集合。
例如,生成器可以表示这样的集合,其中获取“下一个”项的逻辑非常耗时(例如对数据库查询的结果进行分页,其中的项是成批获取的)或依赖于状态的(比如迭代一个集合,其中对当前项的操作会影响哪个项是“下一个”)甚至是无穷级数(诸如分形函数、随机数生成器或返回π的所有数字的生成器)。在这些情况下,构建阵列将是不切实际或不可能的。
我们可以想象一个生成器,它根据种子数返回游戏的程序生成的级别数据,甚至表示理论上的AI的“* 意识流 *”(例如,玩单词联想游戏)。这些都是有趣的场景,使用标准的数组或列表是不可能的,但是在代码中使用循环结构可能会更自然。

js81xvg6

js81xvg62#

最不令人满意的答案是“可能”:你的ES5函数依赖于V8自2008年发布以来一直存在的特性(letconst除外)(大概是在此之前的一段时间,因为我知道V8的起源是Google网络爬虫的一部分)。另一方面,发电机只有V8 since 2013。因此,不仅ES5代码有七年的时间来优化,而ES6代码只有两年,而且几乎没有人(与数百万使用类似ES5代码的网站相比)使用V8中的生成器,这意味着很少有机会发现或激励实现优化。
如果你真的想要一个关于为什么Node.js中的生成器相对较慢的技术答案,你可能需要自己深入研究V8源代码,或者询问编写它的人。

eulz3vhy

eulz3vhy3#

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

vatpfxk5

vatpfxk54#

尝试将generator函数中的'let'替换为作用域为'var'的函数。看起来循环中的'let'会导致大量开销。只有在绝对必要的情况下才使用let
最新消息
上述内容在最初发布时是真实的。当前的浏览器已经为块作用域变量优化了堆分配。

vm0i2vca

vm0i2vca5#

事实上,现在运行这个基准测试,生成器的速度快了约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,541ms Rss:851,968堆总计:0已用堆:-5,073,968
我自己也很好奇,找不到合适的答案。感谢@大卫提供测试代码。

相关问题