NodeJs比Clojure更快吗?

31

我刚开始学习Clojure。我注意到的第一件事是没有循环。没关系,我可以使用recur。那么让我们看看这个函数(来自《实用Clojure》):

(defn add-up
  "Adds up numbers from 1 to n"
  ([n] (add-up n 0 0))
  ([n i sum] 
    (if (< n i)
      sum
      (recur n (+ 1 i) (+ i sum)))))

为了在Javascript中实现相同的功能,我们使用循环,如下所示:

function addup (n) {
  var sum = 0;
  for(var i = n; i > 0; i--) {
    sum += i;
  }
  return sum;
}

计时后,结果看起来像:

input size: 10,000,000
clojure: 818 ms
nodejs: 160 ms

input size: 55,000,000
clojure: 4051 ms
nodejs: 754 ms

input size: 100,000,000
clojure: 7390 ms
nodejs: 1351 ms

在阅读了这篇文章之后,我开始尝试经典的斐波那契数列实现:

在Clojure中:

(defn fib
  "Fib"
  [n]
  (if (<= n 1) 1
      (+ (fib (- n 1)) (fib (- n 2)))))

在 JavaScript 中:

function fib (n) {
  if (n <= 1) return 1;
  return fib(n-1) + fib(n-2);
}

再次强调,性能有相当大的差异。

fib of 39
clojure: 9092 ms
nodejs: 3484 ms

fib of 40
clojure: 14728 ms
nodejs: 5615 ms

fib of 41
clojure: 23611 ms
nodejs: 9079 ms

注意:我正在使用Clojure中的(time (fib 40)),因此它忽略了JVM的启动时间。这些代码在MacBook Air(1.86 GHz Intel Core 2 Duo)上运行。

那么是什么导致Clojure在这里变慢了?为什么人们说“Clojure很快”?

非常感谢,在此先行致谢,请勿引发无意义的争论。


11
这是一种在任何语言中计算斐波那契数列的可怕方法 :-) - Pointy
3
嘿嘿。没错。只是为了对比而已。 - foobar
1
我认为我已经给出了一个相当明确的答案,有什么理由不将其标记为这样呢? - dnolen
没错!你说得对,dnolen。非常感谢 :) - foobar
9个回答

49
(set! *unchecked-math* true)

(defn add-up ^long [^long n]
  (loop [n n i 0 sum 0]
    (if (< n i)
      sum
      (recur n (inc i) (+ i sum)))))

(defn fib ^long [^long n]
  (if (<= n 1) 1
      (+ (fib (dec n)) (fib (- n 2)))))

(comment
  ;; ~130ms
  (dotimes [_ 10]
    (time
     (add-up 1e8)))

  ;; ~1180ms
  (dotimes [_ 10]
    (time
     (fib 41)))
  )

所有数字均来自于2.66GHz i7 Macbook Pro OS X 10.7 JDK 7 64位版本。

从结果可以看出Node.js性能较差。虽然这是在1.3.0 alpha版本下得出的结论,但如果你知道该如何做,也可以在1.2.0版本下获得相同的结果。

在我的机器上,使用Node.js 0.4.8进行1e8次加法所需时间约为990ms,对于斐波那契数列中的第41项,则需要约7600ms。

            Node.js  | Clojure
                     |
 add-up       990ms  |   130ms
                     |
 fib(41)     7600ms  |  1180ms

8
可以像原帖一样列出时间吗? - DTrejo
@DTrejo:为什么?对于addup和fib,您会发现在Clojure和Node.js中,输入大小的变化几乎完全相同。 - dnolen
另一个需要考虑的因素是JS不进行尾递归优化,但我认为Clojure会在这里进行优化。这也是一个非常愚蠢的问题。 - Ted Johnson
这个答案是有结论性的,但不太易读(正如@DTrejo所指出的)。 - SleepyCal

39

如果你为性能进行优化,我实际上预计Clojure会比Javascript快得多。

Clojure将在给定足够的静态类型信息(例如类型提示或原始类型的强制转换)时静态编译为相当优化的Java字节码。因此至少在理论上,你应该能够接近纯Java速度,而Java本身就非常接近本地代码性能。

那么让我们证明它!

在这种情况下,有几个问题导致Clojure代码运行缓慢:

  • Clojure默认支持任意精度算术,因此任何算术操作都会自动检查溢出,并根据需要提升数字为BigIntegers等。这种额外的检查会增加一小部分开销,通常是可以忽略不计的,但如果你在紧密循环中运行算术操作,则可能会显现出来。在Clojure 1.2中的解决方案是使用unchecked-*函数(这有点不太优雅,但在Clojure 1.3中会得到改进)
  • 除非你告诉它不这样做,否则Clojure会以动态方式执行并装箱函数参数。因此,我怀疑你的代码正在创建和装箱大量的Integers / Longs。要消除循环变量的装箱,可以使用原始类型提示并使用类似loop / recur的构造。
  • 同样,n被装箱,这意味着<=函数调用无法优化为使用原始算术。你可以通过在本地让n转换为long原语来避免这种情况。
  • 在Clojure中,(time (some-function))也不是一种可靠的基准测试方式,因为它不一定允许JIT编译优化生效。通常需要先运行(some-function)几次,以便JIT有机会完成其工作。

因此,我建议优化后的Clojure版本的add-up应该是:

(defn add-up
  "Adds up numbers from 1 to n"
  [n]
  (let [n2 (long n)]                                    ; unbox loop limit
    (loop [i (long 1)                                   ; use "loop" for primitives
          acc (long 0)]                                 ; cast to primitive
      (if (<= i n2)                                     ; use unboxed loop limit
        (recur (unchecked-inc i) (unchecked-add acc i)) ; use unchecked maths
        acc))))

更好的计时方式是按照以下方法进行(以允许JIT编译发生):

(defn f [] (add-up 10000000))
(do 
  (dotimes [i 10] (f)) 
  (time (f)))
如果我按照上述方法操作,Clojure 1.2 中的 Clojure 解决方案需要 6 毫秒。这大约比 Node.js 代码快15-20倍,比原始 Clojure 版本快80-100倍。
顺便说一下,在纯 Java 中,这个循环也是我能做到的最快速度,所以我怀疑在任何 JVM 语言中都不可能有太大的改进。每次迭代大约需要2个机器周期...所以它可能接近本地机器代码的速度!
(抱歉,无法在我的机器上对 Node.js 进行基准测试,但对于任何感兴趣的人来说,这是一个3.3 GHz 的 Core i7 980X)

27

一段高层次的评论。Node.js 和 Clojure 在实现可扩展性和使软件运行快速方面拥有完全不同的模型。

Clojure 通过多核并行实现可扩展性。如果你正确构建 Clojure 程序,可以通过 pmap 等方式将计算工作分配到多个核心上并最终在这些核心上并行运行。

Node.js 并不是并行的。相反,它的关键洞察力在于可扩展性(通常在 Web 应用程序环境中)是 I/O 绑定的。因此,Node.js 和 Google V8 技术通过许多异步 I/O 回调来实现可扩展性。

理论上,我预计Clojure 在易于并行处理的领域会击败 Node.js。菲波那切数列就属于这一类,如果提供足够的核心,Clojure 将会胜过 Node.js。而 Node.js 则更适合于需要向文件系统或网络发出许多请求的服务器端应用程序。

总之,我认为这可能不是比较 Clojure 和 Node.js 的很好的基准测试。


22
根据我的经验,使用Clojure和事件驱动的I/O(例如Netty)与Node.js的速度一样快或更快。我认为,Node.js没有纯粹的技术优势——这只是个人口味问题——更多的人知道JS,所以他们宁愿用JS编写服务器。 - dnolen

6

以下是一些提示,假设您正在使用Clojure 1.2:

  • 在Clojure中,重复执行(time ...)测试通常会获得更高的速度,因为JIT优化会发挥作用。
  • (inc i)比(+ i 1)略快一点。
  • unchecked-*函数也比它们的检查变体快得多(有时快得多)。 假设您不需要超过longs或doubles的限制,则使用unchecked-add,unchecked-int等可能会快得多。
  • 阅读类型声明; 在某些情况下,它们也可以大大提高速度。

Clojure 1.3在数字方面通常比1.2更快,但仍在开发中。

以下版本比您的版本快约20倍,并且通过修改算法(像js版本一样倒数计数,而不是顺序计数)仍然可以改进它。

(defn add-up-faster
  "Adds up numbers from 1 to n"
  ([n] (add-up-faster n 0 0))
  ([^long n ^long i ^long sum] 
    (if (< n i)
      sum
      (recur n (unchecked-inc i) (unchecked-add i sum)))))

我认为调用未经检查的变量不如在关键循环周围设置unchecked-math标志优雅。 - dnolen
@dnolen:我同意,但它确实有助于指示关键部分,在这种情况下,我认为它更清楚地展示了“内部运作”。顺便说一下,最新的1.3 alpha版本中,unchecked-math不是默认设置吗? - Joost Diepenmaat
1
没有未经检查的数学运算,就不会在溢出时出错。在1.3.0中,默认情况下,原语通过算术运算,但如果它们溢出,您将遇到一个硬错误——这是一件好事。使用unchecked-math可以为失去安全性带来8-10%的速度提升。 - dnolen
@dnolen - 除了"安全性的丧失"在某种程度上取决于您的观点之外,我同意您说的所有内容-如果您认识到您正在处理64位有符号整数,则未经检查的长整型数学运算是完全"安全" 的,它只是在不同于整数的数学定义的集合上定义的数学。 实际上,您只是在模算术中进行所有数学运算..... - mikera

2

虽然与手头的优化问题不直接相关,但您的Fibonacci数列可以轻松加速:

(defn fib
  "Fib"
  [n]
  (if (<= n 1) 1
      (+ (fib (- n 1)) (fib (- n 2)))))

change to:

(def fib (memoize (fn
  [n]
  (if (<= n 1) 1
      (+ (fib (- n 1)) (fib (- n 2)))))))

它的运行速度比较快(在核心i5上,fib 38花费13000毫秒,为什么我的电脑比双核慢?但现在只需要0.2毫秒)。本质上,它与迭代解决方案并没有太大的区别,尽管它允许你以一种递归方式表达问题,但代价是一些内存。


1

玩一下,你可以使用类似以下的方法来获得相当不错的fib性能:

(defn fib [^long n]
  (if (< n 2) 
   n
   (loop [i 2 l '(1 1)]
   (if (= i n)
    (first l)
     (recur 
      (inc i) 
      (cons 
       (+' (first l) (second l)) 
        l))))))


(dotimes [_ 10]
 (time
  (fib 51)))
; on old MB air, late 2010
; "Elapsed time: 0.010661 msecs"

0

这个问题需要在2021年更新。

Node.js v14.17 Clojure v1.10 (运行于Java 1.8)
2.403秒 963.443556 毫秒
function fib (n) {
  if (n <= 1) return 1;
  return fib(n-1) + fib(n-2);
}

console.time('foo')
fib(40)
console.timeEnd('foo')

在Clojure中
(ns schema
  (:require
    [clojure.core :refer [time]]
  )
  (:gen-class))


(defn fib ^long [^long n]
  (if (<= n 1) 1
               (+ (fib (dec n)) (fib (- n 2)))))

(defn -main
  [& args]
  (time (fib 40))
  )

在“linux x64 | 8 vCPUs | 46.8GB内存”上运行


0

https://bun.sh/

12:51:00 mb.local ~ cat t.js 
function fib (n) {
  if (n <= 1) return 1;
  return fib(n-1) + fib(n-2);
}

console.time('foo')
fib(40)
console.timeEnd('foo')

12:51:10 mb.local ~ bun run t.js 
[534.31ms] foo

-1

这是一种更适合使用node.js处理的方式:

Number.prototype.triangle = function() {
    return this * (this + 1) /2;
}

var start = new Date();
var result = 100000000 .triangle();
var elapsed = new Date() - start;
console.log('Answer is', result, ' in ', elapsed, 'ms');

产出:

$ node triangle.js
Answer is 5000000050000000  in  0 ms

6
零?该死!Node.js会使我的CPU速度无限快吗? - gtrak

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