为什么JavaScript看起来比C++快4倍?

59

很长一段时间以来,我一直认为 C++ 比 JavaScript 更快。然而,今天我写了一个基准测试脚本来比较这两种语言中的浮点数计算速度,结果非常惊人!

JavaScript 的速度似乎比 C++ 快近 4 倍!

我让这两种语言在我的 i5-430M 笔记本电脑上执行相同的任务,即对 a 和 b 执行 a = a + b 操作 100000000 次。C++ 需要大约 410 毫秒,而 JavaScript 只需要大约 120 毫秒。

我真的不知道为什么 JavaScript 在这种情况下运行得如此之快。有人能解释一下吗?

我用于 JavaScript 的代码是(在 Node.js 中运行):

(function() {
    var a = 3.1415926, b = 2.718;
    var i, j, d1, d2;
    for(j=0; j<10; j++) {
        d1 = new Date();
        for(i=0; i<100000000; i++) {
            a = a + b;
        }
        d2 = new Date();
        console.log("Time Cost:" + (d2.getTime() - d1.getTime()) + "ms");
    }
    console.log("a = " + a);
})();

C++的代码(使用g++编译)如下:

#include <stdio.h>
#include <ctime>

int main() {
    double a = 3.1415926, b = 2.718;
    int i, j;
    clock_t start, end;
    for(j=0; j<10; j++) {
        start = clock();
        for(i=0; i<100000000; i++) {
            a = a + b;
        }
        end = clock();
        printf("Time Cost: %dms\n", (end - start) * 1000 / CLOCKS_PER_SEC);
    }
    printf("a = %lf\n", a);
    return 0;
}

27
请添加 -O3 -ffast-math,并观察 C++ 时间的变化。 - Jesse Good
25
我理解您的意思是:“长期以来,我一直认为C ++应该比JavaScript更快。” 您是否了解JavaScript引擎通常是用C ++实现的? - jamylak
8
启用优化后,C++ 版本显示大约 90 毫秒(尽管这显然会因处理器而异)。 - Jerry Coffin
23
谁关心呢?关闭优化的情况下测试优化是毫无意义的。 - Jerry Coffin
4
请看下面的扩展答案。我已经在目前的英特尔处理器和一台旧的AMD处理器上进行了测试,后者按照当前的标准来说速度非常慢。两者都比你看到的结果显著地好。 - Jerry Coffin
显示剩余5条评论
6个回答

304

如果您正在使用符合POSIX标准(至少在这种情况下)的Linux系统,则我可能有一些坏消息要告诉您。调用clock()会返回程序消耗的时钟滴答数,并按CLOCKS_PER_SEC进行缩放,该值为1,000,000

这意味着,如果您正处于这样的系统上,则C语言以微秒为单位,JavaScript以毫秒为单位(根据JS在线文档)。因此,与其说JS快四倍,不如说C ++实际上快250倍。

现在可能您是在一个其他CLOCKS_PER_SECOND值不是一百万的系统上,您可以在系统上运行以下程序,以查看它是否按相同的值进行缩放:

#include <stdio.h>
#include <time.h>
#include <stdlib.h>

#define MILLION * 1000000

static void commaOut (int n, char c) {
    if (n < 1000) {
        printf ("%d%c", n, c);
        return;
    }

    commaOut (n / 1000, ',');
    printf ("%03d%c", n % 1000, c);
}

int main (int argc, char *argv[]) {
    int i;

    system("date");
    clock_t start = clock();
    clock_t end = start;

    while (end - start < 30 MILLION) {
        for (i = 10 MILLION; i > 0; i--) {};
        end = clock();
    }

    system("date");
    commaOut (end - start, '\n');

    return 0;
}

我的电脑上的输出为:

Tuesday 17 November  11:53:01 AWST 2015
Tuesday 17 November  11:53:31 AWST 2015
30,001,946

显示缩放因子为一百万。如果您运行该程序,或者调查 CLOCKS_PER_SEC 并且它的值 不是 一百万作为缩放因子,那么您需要查看其他一些内容。


第一步是确保编译器实际上正在优化您的代码。这意味着例如对于gcc设置-O2-O3等参数。

在我的系统上,未经过优化的代码会显示:

Time Cost: 320ms
Time Cost: 300ms
Time Cost: 300ms
Time Cost: 300ms
Time Cost: 300ms
Time Cost: 300ms
Time Cost: 300ms
Time Cost: 300ms
Time Cost: 300ms
Time Cost: 300ms
a = 2717999973.760710

使用-O2编译可以使速度提高三倍,尽管答案略有不同,但只有百万分之一左右的差异:

Time Cost: 140ms
Time Cost: 110ms
Time Cost: 100ms
Time Cost: 100ms
Time Cost: 100ms
Time Cost: 100ms
Time Cost: 100ms
Time Cost: 100ms
Time Cost: 100ms
Time Cost: 100ms
a = 2718000003.159864

这将使得两种情况重新处于同等水平,这是我所期望的,因为JavaScript不像以前那样只是一个解释性的野兽,每个令牌都会在看到它时进行解释。

现代JavaScript引擎(V8、Rhino等)可以将代码编译成中间形式(甚至是机器语言),这可能会使性能与像C这样的编译语言大致相当。

但说实话,你选择JavaScript或C++并不是因为它们的速度,而是因为它们的强项。浏览器内部并没有太多的C编译器,我也没注意到有多少操作系统或嵌入式应用程序是用JavaScript编写的。


3
我认为不是这样,400毫秒是容易感知的。输出看起来比JavaScript慢得多。 - streaver91
21
@user2189264,不要凭感觉,要进行测量!感觉可能有助于“提出”假设,但在评估假设时没有用 :-) 无论如何,在调用程序之外打印时间会包括你所测量的内容以外的东西(例如前面提到的进程启动/关闭)。 - paxdiablo
2
@paxdiablo,我不是怀疑你。但这一次,我打印出常量值CLOCKS_PER_SEC,它是1000。也许我们使用了不同的平台。 - streaver91
7
叹气...如果你有使用C++11的权限,只需使用 <chrono> 库--http://solarianprogrammer.com/2012/10/14/cpp-11-timing-code-performance/--无需使用依赖于 CLOCKS_PER_SEC 的测量方法(尤其是在比较时没有考虑这种依赖性的情况下)。 - Matt
9
任何人怎么会被"REKT"?这个问题是关于为什么JS似乎比C++更快(明显意味着不应该如此)的,这个回答解释了最可能的原因。社区已经因嘲笑提问者而声名狼藉,对于一个社区驱动的问答网站来说这相当令人尴尬。 - Carl Smith
显示剩余6条评论

9

进行快速测试并开启优化后,我得到了大约150毫秒的结果,这是在一台古老的AMD 64 X2处理器上获得的;而在一台相对较新的英特尔i7处理器上,我得到了约90毫秒的结果。

然后我做了更多的工作,以便让您了解为什么要使用C++。我展开了循环的四次迭代,得到了以下结果:

#include <stdio.h>
#include <ctime>

int main() {
    double a = 3.1415926, b = 2.718;
    double c = 0.0, d=0.0, e=0.0;
    int i, j;
    clock_t start, end;
    for(j=0; j<10; j++) {
        start = clock();
        for(i=0; i<100000000; i+=4) {
            a += b;
            c += b;
            d += b;
            e += b;
        }
        a += c + d + e;
        end = clock();
        printf("Time Cost: %fms\n", (1000.0 * (end - start))/CLOCKS_PER_SEC);
    }
    printf("a = %lf\n", a);
    return 0;
}

这段C++代码在AMD上运行了约44ms(忘记在Intel上运行此版本)。然后我打开了编译器的自动向量化功能(-Qpar with VC++),这进一步降低了时间,在AMD上为约40ms,在Intel上为30ms。
总之:如果您想使用C ++,您真的需要学习如何使用编译器。如果您想获得非常好的结果,您可能还想学习如何编写更好的代码。
我应该补充说:我并没有尝试在JavaScript中测试已展开循环的版本。这样做可能会在JS中提供类似(或至少有些)速度提升。个人认为,使代码快速运行比比较JavaScript和C ++更有趣。
如果您希望像这样的代码运行速度快,请展开循环(至少在C ++中)。
由于并行计算的主题出现了,我想再添加一个使用OpenMP的版本。在进行此操作时,我稍微清理了一下代码,以便可以跟踪发生的情况。我还将计时代码稍微修改了一下,以显示整个时间,而不是内部循环每次执行的时间。生成的代码如下:
#include <stdio.h>
#include <ctime>

int main() {
    double total = 0.0;
    double inc = 2.718;
    int i, j;
    clock_t start, end;
    start = clock();

    #pragma omp parallel for reduction(+:total) firstprivate(inc)
    for(j=0; j<10; j++) {
        double a=0.0, b=0.0, c=0.0, d=0.0;
        for(i=0; i<100000000; i+=4) {
            a += inc;
            b += inc;
            c += inc;
            d += inc;
        }
        total += a + b + c + d;
    }
    end = clock();
    printf("Time Cost: %fms\n", (1000.0 * (end - start))/CLOCKS_PER_SEC);

    printf("a = %lf\n", total);
    return 0;
}

这里的主要新增内容是以下(可能有些晦涩)一行代码:
#pragma omp parallel for reduction(+:total) firstprivate(inc)

这告诉编译器在多个线程中执行外层循环,每个线程都有一个单独的 inc 副本,并在并行部分汇总 total 的各个值。

结果与预期相符。如果我们不使用编译器的 -openmp 标志启用 OpenMP,则报告的时间大约是之前单独执行所见的时间的10倍(AMD为409毫秒,Intel为323毫秒)。打开 OpenMP 后,AMD 的时间降至217毫秒,Intel 降至100毫秒。

因此,在 Intel 上,原始版本对于外层循环的一个迭代需要90毫秒。使用此版本,我们对于所有10次迭代的外层循环只需要略微更长的时间(100毫秒)-- 速度提高了约9:1。在具有更多内核的机器上,我们可以期望获得更多提高(OpenMP 通常会自动利用所有可用内核,但如果需要,您可以手动调整线程数)。


2
@user2189264:是的和不是——它仍在单个核心上执行。通过一些OpenMP指令等更多工作,我们可以让它在多个核心上执行,有效地再次提高速度。但到目前为止,我所做的只是让它更好地利用单个核心上的资源(暴露指令级并行性,而不是线程级并行性)。 - Jerry Coffin
顺便说一下,Athlon X2是K10核心,我想。或者可能是K8,无论哪种方式addsd延迟= 4,吞吐量=每个时钟1,因此4个累加器刚好足够隐藏FP添加延迟。 - Peter Cordes
1
@PeterCordes:是的,我相信如果你想在现代英特尔上获得更好的性能,你需要展开内部循环的迭代次数(大约8次左右,如果我没记错的话)。这可能会有点痛苦,但应该可以将速度大致提高一倍。 - Jerry Coffin
@JerryCoffin:是的,这个特定情况不需要更多的测试;一般的想法就足够了:展开归约直到你在任何实际热点循环中遇到FP吞吐量而不是延迟瓶颈。 (如果您使用的CPU具有比其他CPU更少的FP-add吞吐量(例如Intel pre-Skylake),请参考http://agner.org/optimize/以查找延迟:吞吐量比率。) - Peter Cordes
使用Intel Q6600,此测试显示C++为120毫秒,JavaScript为1300毫秒。原始版本的测试显示C++和JavaScript均约为380毫秒。 - teg_brightly
显示剩余5条评论

6
即使这篇文章已经过时,我认为添加一些信息可能会有趣。总的来说,你的测试过于模糊,可能存在偏见。
关于速度测试方法的一点说明
当比较两种语言的速度时,首先必须明确定义要在哪个上下文中比较它们的性能。
  • "naive" vs "optimized" code:代码是由初学者还是专家程序员编写的,这个参数取决于谁参与你的项目。例如,在与科学家(非极客)合作时,你更关注“天真”的代码性能,因为科学家不一定是优秀的程序员。

  • 授权编译时间:是否考虑允许代码长时间构建。这个参数可能会影响你的项目管理方法论。如果你需要进行自动化测试,也许可以在降低编译时间的同时稍微牺牲一些速度。另一方面,你可以认为分发版本允许高度的构建时间。

  • 平台可移植性:如果你的速度应该在一个或多个平台上进行比较(Windows、Linux、PS4...)

  • 编译器/解释器可移植性:如果你的代码速度应该与编译器/解释器无关。对于多平台和/或开源项目可能会有用。

  • 其他专业参数,例如,如果你允许在你的代码中进行动态分配,如果你想启用插件(在运行时动态加载的库)等。

然后,您需要确保您的代码代表了您想要测试的内容。在这里,我假设您没有使用优化标志编译C ++,因为您正在测试“天真”的(实际上并不是那么天真)代码的快速编译速度。因为您的循环具有固定的大小和固定的数据,所以您不会测试动态分配,并且您-可能-允许代码转换(下一节将更详细地介绍)。在这种情况下,JavaScript通常比C ++表现更好,因为JavaScript默认情况下在编译时进行优化,而C ++编译器需要被告知进行优化。
C ++参数快速增加的简要概述
由于我对JavaScript的了解不够深入,我只展示了如何通过代码优化和编译类型来改变固定for循环的C ++速度,希望这能回答“JavaScript为什么看起来比C ++快?”的问题。

为此,让我们使用Matt Godbolt的C++ 编译器浏览器,查看由gcc9.2生成的汇编代码。

非优化代码

float func(){
    float a(0.0);
    float b(2.71);
    for (int i = 0;  i < 100000; ++i){
        a = a + b;
    }
    return a;
}

使用gcc 9.2编译,标志为-O0。生成以下汇编代码:

func():
        pushq   %rbp
        movq    %rsp, %rbp
        pxor    %xmm0, %xmm0
        movss   %xmm0, -4(%rbp)
        movss   .LC1(%rip), %xmm0
        movss   %xmm0, -12(%rbp)
        movl    $0, -8(%rbp)
.L3:
        cmpl    $99999, -8(%rbp)
        jg      .L2
        movss   -4(%rbp), %xmm0
        addss   -12(%rbp), %xmm0
        movss   %xmm0, -4(%rbp)
        addl    $1, -8(%rbp)
        jmp     .L3
.L2:
        movss   -4(%rbp), %xmm0
        popq    %rbp
        ret
.LC1:
        .long   1076719780


循环的代码位于“.L3”和“.L2”之间。可以看出,这里创建的代码并没有进行任何优化:存在大量的内存访问(没有正确使用寄存器),因此有很多浪费操作来存储和重新加载结果。
这会在现代x86 CPU中将FP加法进入“a”的关键路径依赖链中引入额外的存储转发延迟5或6个周期。这还要加上addss的4或5个周期延迟,使函数的速度超过两倍。 编译器优化 同样的C++代码使用gcc 9.2编译,标志为-O3。产生以下汇编代码:
func():
        movss   .LC1(%rip), %xmm1
        movl    $100000, %eax
        pxor    %xmm0, %xmm0
.L2:
        addss   %xmm1, %xmm0
        subl    $1, %eax
        jne     .L2
        ret
.LC1:
        .long   1076719780

代码更为简明,并尽可能使用寄存器。
代码优化
编译器通常会很好地优化代码,特别是C++,前提是代码清晰地表达了程序员想要实现的内容。在这里,我们希望一个固定的数学表达式尽可能快地执行,因此让我们稍微修改一下代码。
constexpr float func(){
    float a(0.0);
    float b(2.71);
    for (int i = 0;  i < 100000; ++i){
        a = a + b;
    }
    return a;
}

float call() {
    return func();
}

我们在函数中添加了constexpr,以告诉编译器尝试在编译时计算其结果。并添加了一个调用函数来确保它将生成一些代码。
使用gcc 9.2,-O3编译,导致以下汇编代码:
call():
        movss   .LC0(%rip), %xmm0
        ret
.LC0:
        .long   1216623031

由于func返回值已在编译时计算,因此asm代码很短,call指令只需返回该值。


当然,a = b * 100000 总是编译成高效的汇编代码,所以只有在需要探索所有这些临时变量的FP舍入误差时才编写重复加法循环。

CPU有缓存和存储转发。在循环内部进行存储/重新加载只会增加约5或6个周期的延迟,而不是慢1000倍。 - Peter Cordes
@PeterCordes 感谢您的编辑,我将 RAM 进行了比较以强调其速度性能(因为只有真正的随机访问缓存才有很少的不良表现机会),但这导致了很大的误解。在我看来,最好像您编辑的那样留下它。编辑:我没有看到我犯了多少拼写错误(发布时没时间重新阅读),所以更感谢您的纠正。 - Felix Bertoni
是的,缓存未命中会影响性能,但 -O0 引入的额外重载或溢出/重载总是针对您已经刚刚访问过的对象或堆栈。通常可以安全地假设由于调用/返回而使堆栈在缓存中处于热状态。对象的初始访问已经发生在优化代码中,只是避免了进一步的访问。关于 -O0 的成本(为什么clang使用-O0(对于这个简单的浮点求和)生成效率低下的汇编代码?),除了在缓存冲突未命中的罕见情况下,不应该声称会导致访问DRAM。 - Peter Cordes
在交易编译时间与优化方面,使用自动化测试时,您通常会希望使用-O1-Og进行快速编译,不会比低效的-O0构建慢太多,但可以执行基本的寄存器分配(但不包括内联)。集成/单元测试不一定需要使用与完整发布构建相同的构建选项。 - Peter Cordes
1
没错,大多数情况下,“编译一次,运行一次”的程序使用-Og-O3 -flto总的CPU时间更少。可能的例外包括进行大量数字计算的小型程序。在真正的发布版本中花费大量编译时间是有意义的,因为它只需要“编译一次,运行多次”。对于视频编码也是如此:如果可以分摊到许多下载或长期存储文件上,则花费更多的CPU时间以在相同质量下节省位数。 - Peter Cordes
显示剩余3条评论

4

这是一个有争议的话题,因此可以查看以下内容:

https://benchmarksgame-team.pages.debian.net/benchmarksgame/

对各种语言进行基准测试。

Javascript V8等在简单循环方面表现出色,可能会生成非常相似的机器代码。对于大多数“接近用户”的应用程序,Javascript肯定是更好的选择,但请记住,在处理更复杂的算法/应用程序时,可能会浪费内存并且无法避免多次性能下降(以及缺乏控制)。


为什么你会说——对于大多数“贴近用户”的应用程序,JavaScript 显然是更好的选择? - igouy
这个网站不可靠。例如,没有一个Java基准测试包括JMH,因此它们本质上是对JVM进行基准测试,而不是针对测试场景。 - carbolymer

1
有很多关于优化标志影响的好观点被提出来了,但是我想指出,无论语言有多接近底层,“写得不好”的代码都会表现不佳。
你的代码产生了一个长的依赖链,没有任何优化编译器能够消除它,除非你明确告诉它忽略严格的算术合规性。
请注意,过去10年中每个中高端台式机CPU的时钟频率都在3-4 GHz左右,每个核心可以计算2-8个双精度FP指令,从而产生6-30 GFLOPS的结果。这意味着你的JS实现只有达到1 GFLOPS的效率才能达到3-15%。一个经过适当优化的代码应该很容易达到>90%的峰值FP,这甚至还没有考虑多核并行性。
简而言之,我们可能也可以比较冒泡排序或其他极其低效的算法的效率。无论在哪种语言中,引起太多缓存未命中或由于依赖链或复杂的不可预测逻辑而导致太多执行停顿的低效代码都会表现得差不多。
而且,在任何情况下,使用--fast-math编译可能会优化掉部分依赖链。

要达到峰值FLOPS的90%确实需要一些注意,这并不容易实现。首先,您必须使用256位SIMD FMAs才能实现现代CPU理论最大吞吐量的一半以上。(尽管在支持它的CPU上,FMA通常被计算为两个FP操作,但它的成本与FP乘法几乎相同。)这意味着避免其他瓶颈,例如每个FMA最多只有一个负载,并且最好在L1d缓存中命中以维持该带宽。请参见Mysticial在如何实现每个周期的4个FLOP的理论最大值?上的回答。 - Peter Cordes
公平地说,顺序求和(使用1个标量累加器)被广泛使用,尽管它不好。与冒泡排序不同。我们有编译器选项,如-ffast-math,让编译器使用多个累加器进行矢量化,提高速度并经常减少舍入误差(不同但不更差;更接近成对求和),但是如果没有启用快速数学运算,我们就只能使用源代码中的内容。正如您所说,依赖链很重要。 - Peter Cordes
Jerry Coffin的答案展示了在这个问题中使用多个累加器。 - Peter Cordes
@Peter Cordes 你好。是的,我很清楚实现高性能并不容易,但也不一定很难。大多数现代优化编译器都支持pragma、attribute、flag等内存对齐、循环展开、向量化和其他类似的“优化提示”,这些提示可以让你在大多数情况下不必过于费心地动手。 - paperclip optimizer
当然可以,但这个问题比像冒泡排序这样的抽象算法复杂度或者甚至是冒泡排序与插入排序之间的常数因子这样的问题要少为人知。并不是说它不应该更为人所知,但你的措辞有点像OP本应该知道得更好一样。例如,“人们可能会将冒泡排序与……根本没人用的东西进行比较”在语气上有点过了。你可以提供相同的信息,而不让人觉得他们因为不知道这个概念就很蠢,特别是对于那些没有理解这个概念的人,可以提供一些进一步阅读的链接。 - Peter Cordes

-3

任何流行运行时的JS都是用C++编译的,所以你可能无法使其比等效的本地代码运行更快...如果你想证明它,可以通过归纳法从1开始逐个计数到Google。


3
一般而言,一个增量循环不能证明关于编程语言的任何事情。可以通过禁用优化编译来证明,像 OP(原帖作者)所示,可能会生成较慢的本地代码。不过无论如何,C++和JavaScript本地数值类型都无法通过加1达到10e100(1谷歌)。double精度浮点数(即JS中的数字)可以表示高达10e308的值,但9,007,199,254,740,992 + 1 回舍入为相同的数字,因此你会被卡住。(即尾数的最后一位是2) - Peter Cordes
1
请注意,包括JS的V8在内的大多数解释器都具有JIT编译器,使它们能够将解释代码的一部分转换为字节码甚至本机代码。 JIT生成的本机代码可以与“传统”编译器生成的本机代码相媲美,特别是在for循环等简单结构的情况下。 - Felix Bertoni

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接