【问题标题】:Why is nested loop/recur slow in Clojure?为什么 Clojure 中的嵌套循环/递归很慢?
【发布时间】:2019-10-22 17:55:54
【问题描述】:

Clojure 中的单个循环/递归执行速度与 Java for 循环等效

Clojure 版本:

(defn singel-loop [i-count]
  (loop [i 0]
    (if (= i i-count)
      i
      (recur (inc i)))))
(time (loop-test 100101))
"Elapsed time: 0.8857 msecs"

Java 版本:

long s = System.currentTimeMillis();
for (i = 0; i < 100000; i++) {
}
System.out.println("Time: " + (System.currentTimeMillis() - s));

时间:~1ms

但是,如果您添加一个内部loop/recur,性能绝对会一落千丈!

Clojure:

(defn double-loop [i-count j-count]
  (loop [i 0]
    (loop [j 0]
      (if (= j j-count)
        j
        (recur (inc j))))
      (if (= i i-count)
        i
        (recur (inc i)))))
(time (double-loop 100000 100000))
"Elapsed time: 70673.9189 msecs"

Java 版本:

long s = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
    for (int j = 0; j < 100000; j++) {
    }
}
System.out.println((System.currentTimeMillis() - s));

时间:~3ms

为什么 Clojure 版本的性能下降到可笑的程度,而 Java 版本保持不变?

【问题讨论】:

  • 顺便说一句。这是一种可怕的微基准测试方法。使用标准之类的东西至少可以获得一些信心。你永远不应该使用System.currentTimeMillis 来衡量一段代码的运行时间。甚至 clojure 的 timez uses System.nanoTime. currentTimeInMillis` 在虚拟化环境中也可能特别错误。另见stackoverflow.com/questions/8853698/…shipilev.net/blog/2014/nanotrusting-nanotime

标签: java clojure


【解决方案1】:

你让它完成了 100,000 倍的工作,而现在它需要 100,000 倍的时间。这并不奇怪,我也不会称其为“从悬崖上掉下来”。您可能会问为什么 Java 版本只需要 3 倍的时间就可以完成 100,000 倍的工作,但在这一点上,这并不是关于循环/递归一般如何执行的问题。相反,它更多的是 JIT 可以用 Java 代码实现什么奇迹的问题。

【讨论】:

    【解决方案2】:

    我认为这主要是因为 Java 代码更易于优化。

    根据here

    具有空主体的无限循环会消耗 CPU 周期,但什么也不做。优化编译器和即时系统 (JIT) 允许(可能出乎意料地)移除这样的循环。因此,程序不能包含空体的无限循环。

    虽然,我无法验证这样的说法。这里的代码也没有涉及无限循环,但是不管退出条件如何的空循环同样没用。如果有的话,有限循环似乎是一个更合理的优化目标,因为至少无限循环具有潜在目的(无限期阻塞)。

    那么更好的比较是尝试消除任何此类优化。我选择使用System.out.flush,因为println 可能非常昂贵且不一致,而且我不认为任何直接影响System.out. 的东西会被优化掉。

    结果如下:

    (defn double-loop [i-count j-count]
      (loop [i 0]
        (loop [j 0]
          (if (= j j-count)
            j
            (do
              (.flush System/out)
              (recur (inc j)))))
    
        (if (= i i-count)
          i
    
          (recur (inc i)))))
    
    (time (double-loop 1000 10000))  ; "Elapsed time: 1194.718969 msecs"
    

    public class HelloWorld {
    
         public static void main(String []args){
            long s = System.currentTimeMillis();
            for (int i = 0; i < 1000; i++) {
                for (int j = 0; j < 10000; j++) {
                    System.out.flush();
                }
            }
    
            System.out.println((System.currentTimeMillis() - s));  // 1097
         }
    }
    

    1194.718969 毫秒与 1097 毫秒

    因此,Clojure 可能无法轻松编译为优化代码。

    注意事项:

    • 我在Tutorials Point 上进行了这些测试,而不是真实环境。自从上次更新以来,IntelliJ 对我来说完全无法使用,老实说,我不想为 Clojure 建立一个项目并为 Java 摆弄javac

    • 为什么是这些确切的数字?因为我在恶劣的环境中运行,我不希望网站限制我或做任何类似的事情。由于 Clojure 测试的任何原因,10000x10000 无限期地挂起(或者至少超出了我的耐心)。我不得不将它降低到 10000x1000 才能完成。

    • 正如我在有关问题的 cmets 中指出的那样,这仍然是对在 JVM 上运行的语言进行基准测试的一种糟糕方法,因为本例很好地显示了这一点。请参阅here 了解原因。我对 Clojure 使用 Criterium。太棒了。它会在测试之前为您运行代码以预​​热所有内容,并尝试处理垃圾收集等事情。

    【讨论】:

      【解决方案3】:

      如果像前面的回答提到的那样,如果从源代码中出现的 Java 嵌套循环版本需要比非嵌套循环多 10,000 倍的时间,而只需要多 3 倍的时间,那么它应该为您带来危险信号(Java 嵌套循环约为 3 毫秒,而非嵌套循环约为 1 毫秒)。我不知道为什么会这样,但有几种可能性:

      (a) JVM JIT 编译尚未针对您的较短版本启动,因此与嵌套循环版本

      (b) JVM JIT 以某种方式确定您的循环不需要运行,因为没有返回值,因此无论循环是否运行都会产生相同的效果。一般而言,我建议在每个内部循环中至少进行一点计算(例如,将两个数字相加,例如添加到运行总数中),并具有取决于此计算发生的返回值。

      我在这里创建了运行时间相似的 Clojure 和 Java 版本,您可以查看,并记录了我使用 Criterium 库获得的测量结果,该库多次运行相同的代码以“预热”首先是 JIT,然后再对其进行多次测量,仅根据预热后的执行报告结果。

      Java 代码:https://github.com/jafingerhut/leeuwenhoek/blob/master/src/leeuwenhoek/java/JavaLoops.java

      Clojure 代码:https://github.com/jafingerhut/leeuwenhoek/blob/master/src/leeuwenhoek/clojure_loops.clj

      两者的测量代码,结果以 cmets 为单位:https://github.com/jafingerhut/leeuwenhoek/blob/master/src/leeuwenhoek/measure_loops.clj

      【讨论】:

        猜你喜欢
        • 2017-08-19
        • 1970-01-01
        • 1970-01-01
        • 2011-10-31
        • 2014-09-14
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多