为什么这个看似基本的clojure函数如此缓慢

  • 本文关键字:clojure 函数 缓慢 clojure
  • 更新时间 :
  • 英文 :


我是clojure的新手,作为快速练习,我编写了一个函数,该函数应该经过斐波那契序列,直到它超过999999999亿次(也做了一些额外的数学运算,但不是很重要)。我在Java中写了一些同样的东西,虽然我知道Clojure本质上比Java慢,但Java程序需要35秒才能完成,而Clojure程序需要27分钟,这让我感到非常惊讶(考虑到nodejs能够在大约8分钟内完成)。我用repl编译了这个类,并用这个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);
}

许多新加入Clojure的人没有意识到的一件事是,默认情况下Clojure是一种更高级的语言。这意味着它将迫使你实现处理算术溢出,将数字视为可以扩展的对象,防止你改变任何变量,迫使你拥有线程安全的代码,并将你推向依赖递归进行循环的函数解决方案。

默认情况下,它也不会强迫你键入所有内容,这也很方便,不必考虑所有内容的类型,并确保所有类型都兼容,比如你的向量只包含整数。例如,Clojure不在乎,让你把整数和Longs放在里面。

所有这些东西都非常适合快速编写正确、可进化和可维护的应用程序,但对于高性能算法来说就不那么好了。

这意味着默认情况下,Clojure是为实现应用程序而优化的,而不是为实现高性能算法而优化的

不幸的是,似乎大多数人认为;尝试";一种新的语言,因此Clojure的新手将倾向于首先使用该语言来尝试和实现高性能算法。这是Clojure默认擅长的一个明显的不匹配,许多新来者立即面临Clojure在这里造成的额外摩擦。Clojure假设你要实现一个应用程序,而不是某种高性能的十亿N大小的Fibonacci算法。

但不要失去希望,Clojure也可以用于实现高性能算法,但它不是默认的,所以你通常需要成为一个更有经验的Clojure开发人员才能知道如何做到这一点,因为这并不明显。

以下是您在Clojure中的算法,它的执行速度与您的Java实现一样快,它是对您的Java代码的递归重写:

(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:

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

正如你所看到的,在我的笔记本电脑上,它在大约47秒内完成。我还在我的笔记本电脑上运行了你的Java版本,以在我的确切硬件上进行比较,对于Java,我得到了:46947.343671毫秒

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

不同之处在于,在Java中,编程风格总是有助于实现高性能算法。您可以直接使用基元类型和基元算术,无装箱,无溢出检查,无同步或原子性或波动性保护的可变变量,等等。

因此,在Clojure:中获得类似性能几乎不需要什么东西

  1. 使用基元类型
  2. 使用基本数学
  3. 避免使用更高级别的托管可变容器,如atom

很明显,我们也需要运行相同的算法,所以实现方式相似。我并没有试图比较是否存在另一种可以更快地解决相同问题的算法,而是如何在Clojure中实现相同的算法,使其运行得同样快。

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

这是我当时做的第一件事,只需使用Clojure的loop/recur重写相同的循环,并声明与您所做的相同的变量,但使用let阴影,因此我们不需要托管可变容器。

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

就是这样。

一般来说,这就是您需要知道的,保持原始类型,保持原始数学(使用fastmath),键入提示以避免反射,利用let shadowing,保持原始数组,您将获得Clojure高性能实现

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

最后一件事,Clojure的哲学是,它旨在实现足够快、正确、可进化和可维护的应用程序,这些应用程序可以扩展。这就是为什么语言是这样的。虽然正如我所展示的,你可以实现高性能算法,但Clojure的哲学也不是为Java已经擅长的东西重新发明语法。Clojure可以使用Java,所以对于需要非常命令性、可变的基元逻辑的算法,你只需要回退到Java,将其作为一种静态方法来编写,然后从Clojure使用它。或者它认为你甚至会委托比Java更具性能的东西,并使用BLAS或GPU来执行超快的矩阵数学,或诸如此类的东西。这就是为什么它不愿意提供自己的命令式结构或原始内存访问等等,因为它不认为自己比运行的主机做得更好。

您的代码可能看起来像一个"基本功能";,但主要有两个问题:

  • 您使用了atomAtom并不像您在Java中所知道的那样是可变的,但它是用于管理同步状态的结构,没有竞争条件。所以CCD_ 8和CCD_ 9是原子操作,它们很慢。看看这个例子:
(let [counter (atom 0)]
(dotimes [x 1000]
(-> (Thread. (fn [] (swap! counter inc)))
.start))
(Thread/sleep 2000)
@counter)
=> 1000

启动了1000个线程,计数器的值增加了1000x,结果是1000,这并不奇怪。但将其与volatile!进行比较,后者不是线程安全的:

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

另请参阅关于原子的Clojure参考。

  • 除非真的需要atomsvolatiles,否则不应该使用它们。也不鼓励使用loop,因为通常有一些更好的函数,它可以满足您的需求。您试图将Java函数改写为Clojure。Clojure需要不同的方法来处理问题,而您的代码显然不是惯用的。我建议您不要一行一行地将Java代码重写为Clojure,而是要找到一些简单的问题,并学习如何用Clojure的方式解决它们,而不需要atomvolatile!loop

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

如果你是编程初学者,我建议你在假设语言/lib/framework/platform是错误的之前,先假设你的代码是错误的。

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

正如其他人所指出的,Java代码到Clojure的直接转换运行相当缓慢。然而,如果我们编写一个利用Clojure优势的Fibonacci数字生成器,我们可以得到一些简短的、更习惯地完成其工作的东西。

首先,假设我们想要一个函数来计算斐波那契序列的第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项是第一个超过999999999的项),我使用了:

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

在我的硬件和操作系统(Windows 10,双处理器Intel i5@2.6 GHz)上,它在17.5秒内完成。

最新更新