为什么这个看似基本的 clojure 函数这么慢?

Why is this seemingly basic clojure function so slow?

我是 clojure 的新手,作为快速练习,我编写了一个函数,该函数应该遍历斐波那契数列,直到它超过 999999999 10 亿次(也做了一些额外的数学运算,但不是很重要)。我在 Java 中写了一些相同的东西,虽然我知道 Clojure 本质上比 Java 慢,但 java 程序用了 35 秒完成,而 Clojure 程序用了27 分钟,这让我感到非常惊讶(考虑到 nodejs 能够在大约 8 分钟内完成)。我用 repl 编译了 class,用 Java 命令 java -cp `clj -Spath` fib 编译了 运行。真的不确定为什么这么慢。

(defn fib
  []
  (def iter (atom (long 0)))
  (def tester (atom (long 0)))
  (dotimes [n 1000000000]
    (loop [prev (long 0)
           curr (long 1)]
      (when (<= prev 999999999)
        (swap! iter inc)
        (if (even? @iter)
          (swap! tester + prev)
          (swap! tester - prev))
        (recur curr (+ prev curr)))))
  (println (str "Done in: "  @iter " Test: " @tester))
)

这是我的Java方法,供参考

    public static void main(String[] args) {
        long iteration = 0;
        int test = 0;
        for (int n = 0; n < 1000000000; n++) {
            int x = 0, y = 1;
            while (true) {
                iteration += 1;
                if (iteration % 2 == 0) {
                    test += x;
                }
                else {
                    test -=x;
                }
                int i = x + y;
                x = y;
                y = i;
                if (x > 999999999) { break; }
            }
        }
        System.out.println("iter: " + iteration + " " + test);
    }

您的代码可能看起来像一个“基本功能”,但存在两个主要问题:

  • 您使用的 atom. Atom isn't variable as you know it from Java, but it's construct for managing synchronous state, free of race conditions. So reset! and swap! 是原子操作,它们很慢。看这个例子:
(let [counter (atom 0)]
  (dotimes [x 1000]
    (-> (Thread. (fn [] (swap! counter inc)))
        .start))
  (Thread/sleep 2000)
  @counter)
=> 1000

启动了 1000 个线程,计数器的值增加了 1000 倍,结果是 1000,不足为奇。但将其与 volatile! 进行比较,后者不是 thread-safe:

(let [counter (volatile! 0)]
  (dotimes [x 1000]
    (-> (Thread. (fn [] (vswap! counter inc)))
        .start))
  (Thread/sleep 2000)
  @counter)
=> 989

另见 Clojure Reference about Atoms

  • 除非您确实需要atomsvolatiles,否则您不应该使用它们。也不鼓励使用 loop,因为通常有一些更好的函数可以完全满足您的需求。您试图 字面上 将您的 Java 函数重写为 Clojure。 Clojure 需要不同的方法来解决问题,您的代码绝对不是惯用的。我建议你不要将 Java 代码逐行重写到 Clojure 中,而是找到一些简单的问题并学习如何以 Clojure 的方式解决它们,而无需 atomvolatile!loop.

顺便说一下,有一个memoize,它在像你这样的例子中很有用。

如果您是编程初学者,我建议您在假设 language/lib/framework/platform 错误之前始终假设您的代码是错误的。

看看Fibonacci sequence在Java和Clojure中的各种实现,你可能会学到一些东西。

许多 Clo​​jure 新手没有意识到的一件事是 Clojure 默认是一种 higher-level 语言。这意味着它会强制你实现处理算术溢出,将数字视为你可以扩展的对象,将阻止你改变任何变量,将强制你拥有 thread-safe 代码,并将推动你走向功能依赖递归循环的解决方案。

默认情况下它也不会强制您输入所有内容,这也很方便,您不必关心所有内容的类型并确保所有类型都兼容,例如您的向量仅包含整数例如,Clojure 不关心,让你把 Integers 和 Longs 放进去。

所有这些东西都非常适合编写 fast-enough 正确、可演化和可维护的应用程序,但对于 high-performance 算法来说就不是那么好了。

这意味着默认情况下 Clojure 针对实现应用程序而不是针对实现 high-performance 算法进行了优化。

不幸的是,似乎大多数人都在“尝试”一门新语言,因此 Clojure 的新手往往会首先使用该语言来尝试和实现 high-performance 算法。这显然与 Clojure 默认擅长的不匹配,许多新手立即面临 Clojure 在这里造成的额外摩擦。 Clojure 假定您要实现一个应用程序,而不是某种 high-performance 十亿 N 大小的 Fibonacci-like 算法。

但不要失去希望,Clojure 也可用于实现 high-performance 算法,但它不是默认设置,因此您通常需要成为更有经验的 Clojure 开发人员才能知道该怎么做所以,因为它不太明显。

这是您在 Clojure 中的算法,它的执行速度与您的 Java 实现一样快,它是您的确切 Java 代码的递归 re-write:

(ns playground
  (:gen-class)
  (:require [fastmath.core :as fm]))

(defn -main []
  (loop [n (long 0) iteration (long 0) test (long 0)]
    (if (fm/< n 1000000000)
      (let [^longs inner
            (loop [x (long 0) y (long 1) iteration iteration test test]
              (let [iteration (fm/inc iteration)
                    test (if (fm/== (fm/mod iteration 2) 0) (fm/+ test x) (fm/- test x))
                    i (fm/+ x y)
                    x y
                    y i]
                (if (fm/> x 999999999)
                  (doto (long-array 2) (aset 0 iteration) (aset 1 test))
                  (recur x y iteration test))))]
        (recur (fm/inc n) (aget inner 0) (aget inner 1)))
      (str "iter: " iteration " " test))))

(println (time (-main)))

"Elapsed time: 47370.544514 msecs"
;;=> iter: 45000000000 0

使用部门:

:deps {generateme/fastmath {:mvn/version "2.1.8"}}

如您所见,在我的笔记本电脑上,它在大约 47 秒内完成。我也在我的笔记本电脑上 运行 你的 Java 版本来比较我的确切硬件,对于 Java 我得到:46947.343671 ms.

所以在我的笔记本电脑上,您可以看到 Clojure 和 Java 基本上都一样快,都在 47 秒左右计时。

区别在于Java,编程风格总是有利于实现high-performance算法。您可以直接使用原始类型和原始算法,没有装箱,没有溢出检查,没有同步或原子性或易失性保护的可变变量等。

因此,在 Clojure 中获得类似的性能只需要很少的事情:

  1. 使用原始类型
  2. 使用原始数学
  3. 避免使用 higher-level 托管可变容器,如 atom

显然,我们也需要 运行 相同的算法,因此实现方式相似。我并不是要比较是否存在另一种算法可以更快地解决相同的问题,而是要比较如何在 Clojure 中实现相同的算法以使其 运行 一样快。

为了在 Clojure 中执行基本类型,您必须知道您只能在使用 letloop 的本地上下文中这样做,并且所有函数调用都会撤消基本类型类型,除非它们也被类型化为原始 long 或 double(Clojure 中唯一支持的可以跨函数边界的原始类型)。

那是我当时做的第一件事,只是 re-write 使用 Clojure 的 loop/recur 循环并声明与您相同的变量,但使用 let 阴影代替,所以我们不需要托管可变容器。

最后,我使用了 Fastmath,这是一个提供大量算术函数原始版本的库,因此我们可以进行原始数学运算。 Clojure 核心有一些它自己的,但它没有 mod 例如,所以我需要引入 Fastmath。

就是这样。

一般来说,这是您需要知道的,保持基本类型,保持基本数学(使用 fastmath),键入提示以避免反射,利用 let 阴影,保持基本数组,以及你会得到 Clojure high-performance 实现。

这里有一组很好的相关信息:https://clojure.org/reference/java_interop#primitives

最后一件事,Clojure 的理念是它旨在实现 fast-enough 正确、可演化和可扩展的可维护应用程序。这就是为什么语言是这样的。正如我所展示的,虽然您可以实现 high-performance 算法,但 Clojure 的理念也不是 re-invent 语法 Java 已经很擅长的东西。 Clojure 可以使用 Java,因此对于需要非常命令的、可变的、原始逻辑的算法,它会期望您回退到 Java 将其编写为静态方法,然后从Clojure。或者它认为您甚至会委托给比 Java 更高性能的东西,并使用 BLAS 或 GPU 来执行 super-fast 矩阵数学或类似的东西。这就是为什么它不费心提供自己的命令式结构或原始内存ry 访问和所有这些,因为它认为它没有比它 运行 结束的主机做得更好。

正如其他人所指出的,将 Java 代码直接转换为 Clojure 的速度相当慢。但是,如果我们编写一个利用 Clojure 的优势的斐波那契数生成器,我们可以获得一些简短的东西,并且可以更地道地完成它的工作。

首先,假设我们需要一个函数来计算斐波那契数列的第 n 个数 (1, 1, 2, 3, 5, 8, 13, 21, 34, 55, ... ).为此,我们可以使用:

(defn fib [n]
  (loop [a   1
         b   0
         cnt n]
    (if (= cnt 1)
      a
      (recur (+' a b) a (dec cnt)))))

迭代地重新计算“下一个”斐波那契值,直到达到所需值。

鉴于此函数,我们可以开发一个通过将此函数映射到一系列索引值来创建斐波那契数列值集合的函数:

(defn fib-seq [n]
  (map #(fib %) (range 1 (inc n))))

但这当然是一种非常低效的计算斐波那契值序列的方法,因为对于每个值,我们必须计算所有前面的值,然后我们只保存最后一个值。如果我们想要一种更有效的方法来计算整个序列,我们可以遍历各种可能性并将结果收集到一个集合中:

(defn fib-seq [n]
  (loop [curr   1
         prev   0
         c      '(1)]
    (if (= n (count c))
      (reverse c)
      (let [new-curr  (+' curr prev)]
        (recur new-curr curr (cons new-curr c))))))

这为我们提供了一种合理有效的方法来收集斐波那契数列的值。为了通过 (fib 45) 测试十亿个循环(序列的第 45 项是第一个超过 999,999,999 的项),我使用了:

(time (dotimes [n 1000000000](fib-seq 45)))

在我的硬件上用了 17.5 秒完成,OS(Windows 10,dual-processor Intel i5 @ 2.6 GHz)。