为什么在此示例中使用生成器函数比填充和迭代数组慢?
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 函数依赖于(let
和 const
除外)的功能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 的“意识流”(例如,玩单词联想游戏)。这些是使用标准数组或列表无法表示的有趣场景,但循环结构在代码中感觉更自然。
两个函数的故事
我有一个函数可以将数组填充到指定值:
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 函数依赖于(let
和 const
除外)的功能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 的“意识流”(例如,玩单词联想游戏)。这些是使用标准数组或列表无法表示的有趣场景,但循环结构在代码中感觉更自然。