为什么第一个函数调用的执行速度是所有其他顺序调用的两倍



我有一个自定义的JS迭代器实现和用于衡量后一个实现性能的代码:

const ITERATION_END = Symbol('ITERATION_END');
const arrayIterator = (array) => {
let index = 0;
return {
hasValue: true,
next() {
if (index >= array.length) {
this.hasValue = false;
return ITERATION_END;
}
return array[index++];
},
};
};
const customIterator = (valueGetter) => {
return {
hasValue: true,
next() {
const nextValue = valueGetter();
if (nextValue === ITERATION_END) {
this.hasValue = false;
return ITERATION_END;
}
return nextValue;
},
};
};
const map = (iterator, selector) => customIterator(() => {
const value = iterator.next();
return value === ITERATION_END ? value : selector(value);
});
const filter = (iterator, predicate) => customIterator(() => {
if (!iterator.hasValue) {
return ITERATION_END;
}
let currentValue = iterator.next();
while (iterator.hasValue && currentValue !== ITERATION_END && !predicate(currentValue)) {
currentValue = iterator.next();
}
return currentValue;
});
const toArray = (iterator) => {
const array = [];
while (iterator.hasValue) {
const value = iterator.next();
if (value !== ITERATION_END) {
array.push(value);
}
}
return array;
};
const test = (fn, iterations) => {
const times = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
fn();
times.push(performance.now() - start);
}
console.log(times);
console.log(times.reduce((sum, x) => sum + x, 0) / times.length);
}
const createData = () => Array.from({ length: 9000000 }, (_, i) => i + 1);
const testIterator = (data) => () => toArray(map(filter(arrayIterator(data), x => x % 2 === 0), x => x * 2))
test(testIterator(createData()), 10);

测试函数的输出非常奇怪和出乎意料——第一次测试运行的执行速度始终是其他所有运行的两倍。其中一个结果,数组包含所有执行时间,数字是平均值(我在Node上运行了它):

[
147.9088459983468,
396.3472499996424,
374.82447600364685,
367.74555300176144,
363.6300039961934,
362.44370299577713,
363.8418449983001,
390.86111199855804,
360.23125199973583,
358.4788999930024
]
348.6312940984964

使用Deno运行时可以观察到类似的结果,但我无法在其他JS引擎上重现这种行为。V8的原因是什么?

环境:节点v13.8.0,V8 v7.9.317.25-Node.28,Deno v1.3.3,V8 V8.6.334

(此处为V8开发人员。)简而言之:它是内联的,或者没有内联,这是由引擎启发式决定的。

对于优化编译器来说,内联被调用的函数可能有很大的好处(例如:避免了调用开销,有时可以进行常量折叠,或者消除重复计算,有时甚至为额外的内联创造了新的机会),但这是有代价的:它使编译本身更慢,并且它增加了由于某些结果不成立的假设而不得不稍后丢弃优化代码("去优化")的风险。内联任何内容都不会浪费性能,内联所有内容都会浪费性能,准确地内联正确的函数需要能够预测程序的未来行为,这显然是不可能的。所以编译器使用启发法。

V8的优化编译器目前只有在内联函数总是在特定位置调用的同一个函数的情况下才具有内联函数的启发式。在这种情况下,第一次迭代就是这样。随后的迭代会创建新的闭包作为回调,从V8的角度来看,这些闭包是新函数,因此它们不会内联。(V8实际上知道一些高级技巧,这些技巧允许它在某些情况下消除来自同一源的函数实例的重复,并无论如何内联它们;但在这种情况下,这些技巧不适用[我不知道为什么])。

因此,在第一次迭代中,所有内容(包括x => x % 2 === 0x => x * 2)都被内联到toArray中。从第二次迭代开始,情况不再如此,而是生成的代码执行实际的函数调用。

这可能很好;我想,在大多数实际应用中,这种差异几乎无法测量。(减少的测试用例往往会使这种差异更加突出;但根据小测试的观察结果改变更大应用程序的设计通常不是最有影响力的方式,最坏的情况下会让事情变得更糟。)

此外,手工优化引擎/编译器的代码也是一个难以平衡的问题。我通常建议而不是这样做(因为引擎会随着时间的推移而改进,他们的工作实际上是让代码快速);另一方面,显然有效率更高的代码和效率更低的代码,为了最大限度地提高整体效率,每个参与者都需要尽自己的职责,也就是说,尽可能简化引擎的工作。

如果您确实想微调它的性能,可以通过分离代码和数据来实现,从而确保始终调用相同的函数。例如,您的代码的修改版本:

const ITERATION_END = Symbol('ITERATION_END');
class ArrayIterator {
constructor(array) {
this.array = array;
this.index = 0;
}
next() {
if (this.index >= this.array.length) return ITERATION_END;
return this.array[this.index++];
}
}
function arrayIterator(array) {
return new ArrayIterator(array);
}
class MapIterator {
constructor(source, modifier) {
this.source = source;
this.modifier = modifier;
}
next() {
const value = this.source.next();
return value === ITERATION_END ? value : this.modifier(value);
}
}
function map(iterator, selector) {
return new MapIterator(iterator, selector);
}
class FilterIterator {
constructor(source, predicate) {
this.source = source;
this.predicate = predicate;
}
next() {
let value = this.source.next();
while (value !== ITERATION_END && !this.predicate(value)) {
value = this.source.next();
}
return value;
}
}
function filter(iterator, predicate) {
return new FilterIterator(iterator, predicate);
}
function toArray(iterator) {
const array = [];
let value;
while ((value = iterator.next()) !== ITERATION_END) {
array.push(value);
}
return array;
}
function test(fn, iterations) {
for (let i = 0; i < iterations; i++) {
const start = performance.now();
fn();
console.log(performance.now() - start);
}
}
function createData() {
return Array.from({ length: 9000000 }, (_, i) => i + 1);
};
function even(x) { return x % 2 === 0; }
function double(x) { return x * 2; }
function testIterator(data) {
return function main() {
return toArray(map(filter(arrayIterator(data), even), double));
};
}
test(testIterator(createData()), 10);

观察热路径上如何不再有动态创建的函数;公共接口";(即arrayIteratormapfiltertoArray的组成方式)与以前完全相同,只是幕后细节发生了变化。为所有函数命名的好处是可以获得更有用的分析输出;-)

敏锐的读者会注意到,这种修改只会转移问题:如果你的代码中有几个地方用不同的修饰符/谓词调用mapfilter,那么内联性问题就会再次出现。正如我上面所说:微基准往往具有误导性,因为真正的应用程序通常有不同的行为。。。

(FWIW,这与在中的效果几乎相同。为什么这个函数调用的执行时间发生了变化?)

为了补充这项研究,我将OP的原始代码与jmrk建议的声明为独立函数的谓词和选择器函数与其他两种实现进行了比较。因此,此代码有三个实现:

  1. OP的代码,谓词和选择器函数分别声明为命名函数(非内联)
  2. 使用标准的array.map().filter()(您可能会认为这会更慢,因为需要额外创建中间数组)
  3. 使用在一次迭代中同时进行过滤和映射的自定义迭代

OP在节省时间和加快速度方面的尝试实际上是最慢的(平均而言)。自定义迭代是最快的。

我想这里的教训是,如何使用优化编译器使事情变得更快并不一定是直观的,所以如果你在调整性能,你必须根据";典型的";做事的方式(这可能会从大多数优化中受益)。

此外,请注意,在方法#3中,前两次迭代是最慢的,然后它变得更快——与原始代码的效果相反。想想看。

结果如下:

[
99.90320014953613,
253.79690098762512,
271.3091011047363,
247.94990015029907,
247.457200050354,
261.9487009048462,
252.95090007781982,
250.8520998954773,
270.42809987068176,
249.340900182724
]
240.59370033740998
[
222.14270091056824,
220.48679995536804,
224.24630093574524,
237.07260012626648,
218.47070002555847,
218.1493010520935,
221.50559997558594,
223.3587999343872,
231.1618001461029,
243.55419993400574
]
226.01488029956818
[
147.81360006332397,
144.57479882240295,
73.13350009918213,
79.41700005531311,
77.38950109481812,
78.40880012512207,
112.31539988517761,
80.87990117073059,
76.7899010181427,
79.79679894447327
]
95.05192012786866

代码在这里:

const { performance } = require('perf_hooks');
const ITERATION_END = Symbol('ITERATION_END');
const arrayIterator = (array) => {
let index = 0;
return {
hasValue: true,
next() {
if (index >= array.length) {
this.hasValue = false;
return ITERATION_END;
}
return array[index++];
},
};
};
const customIterator = (valueGetter) => {
return {
hasValue: true,
next() {
const nextValue = valueGetter();
if (nextValue === ITERATION_END) {
this.hasValue = false;
return ITERATION_END;
}
return nextValue;
},
};
};
const map = (iterator, selector) => customIterator(() => {
const value = iterator.next();
return value === ITERATION_END ? value : selector(value);
});
const filter = (iterator, predicate) => customIterator(() => {
if (!iterator.hasValue) {
return ITERATION_END;
}
let currentValue = iterator.next();
while (iterator.hasValue && currentValue !== ITERATION_END && !predicate(currentValue)) {
currentValue = iterator.next();
}
return currentValue;
});
const toArray = (iterator) => {
const array = [];
while (iterator.hasValue) {
const value = iterator.next();
if (value !== ITERATION_END) {
array.push(value);
}
}
return array;
};
const test = (fn, iterations) => {
const times = [];
let result;
for (let i = 0; i < iterations; i++) {
const start = performance.now();
result = fn();
times.push(performance.now() - start);
}
console.log(times);
console.log(times.reduce((sum, x) => sum + x, 0) / times.length);
return result;
}
const createData = () => Array.from({ length: 9000000 }, (_, i) => i + 1);
const cache = createData();
const comp1 = x => x % 2 === 0;
const comp2 = x => x * 2;
const testIterator = (data) => () => toArray(map(filter(arrayIterator(data), comp1), comp2))
// regular array filter and map
const testIterator2 = (data) => () => data.filter(comp1).map(comp2);
// combine filter and map in same operation
const testIterator3 = (data) => () => {
let result = [];
for (let value of data) {
if (comp1(value)) {
result.push(comp2(value));
}
}
return result;
}
const a = test(testIterator(cache), 10);
const b = test(testIterator2(cache), 10);
const c = test(testIterator3(cache), 10);
function compareArrays(a1, a2) {
if (a1.length !== a2.length) return false;
for (let [i, val] of a1.entries()) {
if (a2[i] !== val) return false;
}
return true;
}
console.log(a.length);
console.log(compareArrays(a, b));
console.log(compareArrays(a, c));

最新更新