Chrome 的高迭代计数意外减速

Unexpected slowdown in Chrome on high-iteration count

我在 Chrome 的控制台中比较两个函数的性能,这时我偶然发现了一些我无法解释的东西。

const A = 3,
    B = 2,
    C = 4,
    D = 2;

const mathCompare = (a1, a2, b1, b2) => {
    return Math.abs(Math.log(a1/a2)) < Math.abs(Math.log(b1/b2));
};

const logicCompare = (a1, a2, b1, b2) => {
    return (a1 > a2 ? a1/a2 : a2/a1) < (b1 > b2 ? b1/b2 : b2/b1);
};

const runLooped = (j) => {
    for(let i = 0; i < 4; i++) {
        jsCompare(mathCompare, logicCompare, j);
    }
}

const jsCompare = (f1, f2, iterations) => {
    let a = jsPerf(f1, iterations);
    let b = jsPerf(f2, iterations);
    console.warn( iterations + " iterations:\n" + f1.name + ": " + a + " ms\n" + f2.name + ": " + b + " ms\n" + "delta: " + (a-b) + " ms");
}

const jsPerf = (f, iterations) => {
    let start = performance.now();

    for(let i = 0; i < iterations; i++) {
        f(A, B, C, D);
    }
    return performance.now() - start;
}

runLooped(10000000);
运行 组 10M 次迭代 n 次:

由于性能仅在一组完整的迭代后发生变化 - 一次 jsCompare() 调用 - 我决定再次尝试使用较少分解的结构:

const A = 3,
    B = 2,
    C = 4,
    D = 2;

const mathCompare = (a1, a2, b1, b2) => {
    return Math.abs(Math.log(a1/a2)) < Math.abs(Math.log(b1/b2));
};

const logicCompare = (a1, a2, b1, b2) => {
    return (a1 > a2 ? a1/a2 : a2/a1) < (b1 > b2 ? b1/b2 : b2/b1);
};

const compareRaw = (f1, f2, maxI, maxJ) => {
    for(let i = 0; i < maxI; i++) {
        let j,
            a = performance.now();
        for(j = 0; j < maxJ; j++) {
            f1(A, B, C, D);
        }
        let b = performance.now();
        for(j = 0; j < maxJ; j++) {
            f2(A, B, C, D);
        }
        let c = performance.now();
        console.warn( j + " iterations:\n" + f1.name + ": " + (b-a) + " ms\n" + f2.name + ": " + (c-b) + " ms\n" + "delta: " + ((b-a) - (c-b)) + " ms");
    }
};

const runRaw = (i) => {
    compareRaw(mathCompare, logicCompare, 4, i);
};

runRaw(10000000);

完全不同的结果。经过一些波动后,结果在第 3 组左右稳定。

  1. Chrome 79.0.3945.130:

    • logicCompare():所有设置需要~12ms
    • mathCompare():所有集合需要 ~10ms
  2. 维瓦尔第 2.6.1566.49 (V8 7.5.288.30):

    • logicCompare():所有集合需要~60ms
    • mathCompare():所有集合需要 ~10ms

我很好奇并再次尝试了所有方法,但这次使用的是随机数。我正在测试的项目显然永远不会使用相同的参数调用这些函数 n * 10M 次。

const mathCompare = (a1, a2, b1, b2) => {
    return Math.abs(Math.log(a1/a2)) < Math.abs(Math.log(b1/b2));
};

const logicCompare = (a1, a2, b1, b2) => {
    return (a1 > a2 ? a1/a2 : a2/a1) < (b1 > b2 ? b1/b2 : b2/b1);
};

const compareRawRandom = (f1, f2, maxI, maxJ) => {
    let randoms = [...Array(maxJ + 3)].map(()=>Math.floor(Math.random()*10));
    for(let i = 0; i < maxI; i++) {
        let j,
            a = performance.now();
        for(j = 0; j < maxJ; j++) {
            f1(randoms[j], randoms[j + 1], randoms[j + 2], randoms[j + 3]);
        }
        let b = performance.now();
        for(j = 0; j < maxJ; j++) {
            f2(randoms[j], randoms[j + 1], randoms[j + 2], randoms[j + 3]);
        }
        let c = performance.now();
        console.warn( j + " iterations:\n" + f1.name + ": " + (b-a) + " ms\n" + f2.name + ": " + (c-b) + " ms\n" + "delta: " + ((b-a) - (c-b)) + " ms");
    }
}

const runRawRandom = (i) => {
    compareRawRandom(mathCompare, logicCompare, 4, i);
};

const jsCompareRandom = (f1, f2, iterations) => {
    let randoms = [...Array(iterations + 3)].map(()=>Math.floor(Math.random()*10));
    let a = jsPerfRandom(f1, iterations, randoms);
    let b = jsPerfRandom(f2, iterations, randoms);
    console.warn( iterations + " iterations:\n" + f1.name + ": " + a + " ms\n" + f2.name + ": " + b + " ms\n" + "delta: " + (a-b) + " ms");
}

const jsPerfRandom = (f, iterations, randoms) => { 
    let start = performance.now();

    for(let i = 0; i < iterations; i++) {
        f(randoms[i], randoms[i + 1], randoms[i + 2], randoms[i + 3]);
    }
    return performance.now() - start;
}

const runRandomLooped = (j) => {
    for(let i = 0; i < 4; i++) {
        jsCompareRandom(mathCompare, logicCompare, j);
    }
}

runRandomLooped(10000000);
runRawRandom(10000000);

runRandomLooped() 显示了 mathCompare() 的前 10M 组同样奇怪的低值。

runRawRandom() 分解较少的版本执行完全相同的计算,但是在 2 组 10M 后再次稳定。但是这一次,对于 10M 次调用,两个函数都显示出相同的 ~23ms 性能。

这只显示在 Chrome/Chromium 浏览器上。 测试于:

我还在 Firefox 72.0.2 上进行了测试,它在集合和两种循环方式上表现出稳定的性能。

我是 运行 当前 Win10 上的 AMD FX-8350。

我想这与 V8 在运行时的优化方式有关,但我预计在这种情况下性能不会下降。

这里是 V8 开发人员。正如 wOxxOm 指出的那样,这主要是对微基准测试陷阱的说明。

首发:

Unexpected de-optimisation...

不,去优化不是这里的问题(这是一个非常具体的术语,具有非常具体的含义)。你是说 "slowdown".

...on high-iteration count

不,高迭代次数也不是这里的问题。虽然您可以说测试中缺少预热对您看到的结果有所贡献,但该贡献是您发现 less 令人惊讶的部分。


需要注意的一种机制是"on-stack replacement":具有(非常)长-运行 循环的函数将在循环执行时得到优化。在后台线程上执行此操作没有意义,因此在主线程上进行优化时执行会中断。如果循环后的代码还没有执行,因此没有类型反馈,那么优化后的代码将在执行到循环结束时被丢弃("deoptimized"),以收集类型反馈 while执行未优化的字节码。如果是此处示例中的另一个 long-运行 循环,将重复相同的 OSR-then-deopt 舞蹈。这意味着您正在测量的一些重要部分是优化时间。这解释了您在时间稳定之前在 runRawRandom 中看到的大部分差异。

另一个需要注意的影响是内联。所讨论的函数越小越快,调用开销就越大,这在编写基准测试时可以避免,这样函数就可以内联。此外,内联通常会释放额外的优化机会:在这种情况下,编译器在内联后可以看到从未使用过比较结果,因此它会通过死代码消除所有比较。这解释了为什么 runRandomLoopedrunRawRandom 慢得多:后者对空循环进行基准测试。前者的第一次迭代是 "fast"(=空),因为 V8 在那一点内联 mathCompare 用于 jsPerfRandom 中的 f(...) 调用(因为这是它在那里见过的唯一函数),但在它意识到 "whoops, various different functions are getting called here" 后不久,它就会取消选择并且不会在后续优化尝试中再次尝试内联。

如果您关心细节,可以使用标志的某种组合 --trace-opt --trace-deopt --trace-osr --trace-turbo-inlining --print-opt-code --code-comments 来深入调查行为。请注意,虽然此练习可能会花费您大量时间,但您可以从微基准测试的行为中学到的东西很可能与实际用例无关。

举例说明:

  • 你这里有一个微基准测试毫无疑问mathComparelogicCompare
  • 慢得多
  • 您有另一个微基准测试毫无疑问地证明两者具有相同的性能
  • 您的综合观察毫无疑问地证明当 V8 决定优化事物时性能会下降

但在实践中,所有三个观察结果都是错误的(这并不奇怪,因为其中两个是彼此直接矛盾的):

  • "fast results" 不能反映真实世界的行为,因为它们可以通过死代码消除您试图衡量的工作量
  • "slow results" 不能反映真实世界的行为,因为编写基准的特定方式阻止了微小函数的内联(实际上总是在实际代码中内联)
  • 假设的 "performance going down" 只是一个微基准测试工件,根本不是由于 failed/useless 优化,在现实世界中也不会发生。