【问题标题】:Quicksort in ClojureClojure 中的快速排序
【发布时间】:2012-08-23 23:57:31
【问题描述】:

我试图证明 Clojure 的性能可以与 Java 平起平坐。我发现的一个重要用例是快速排序。我写了一个实现如下:

(set! *unchecked-math* true)

(defn qsort [^longs a]
  (let [qs (fn qs [^long low, ^long high]
             (when (< low high)
               (let [pivot (aget a low)
                     [i j]
                     (loop [i low, j high]
                       (let [i (loop [i i] (if (< (aget a i) pivot)
                                             (recur (inc i)) i))
                             j (loop [j j] (if (> (aget a j) pivot)
                                             (recur (dec j)) j))
                             [i j] (if (<= i j)
                                     (let [tmp (aget a i)]
                                       (aset a i (aget a j)) (aset a j tmp)
                                       [(inc i) (dec j)])
                                     [i j])]
                         (if (< i j) (recur i j) [i j])))]
                 (when (< low j) (qs low j))
                 (when (< i high) (qs i high)))))]
    (qs 0 (dec (alength a))))
  a)

另外,这有助于调用 Java 快速排序:

(defn jqsort [^longs a] (java.util.Arrays/sort a) a))

现在,进行基准测试。

user> (def xs (let [rnd (java.util.Random.)] 
        (long-array (repeatedly 100000 #(.nextLong rnd)))))
#'user/xs
user> (def ys (long-array xs))
#'user/ys
user> (time (qsort ys))
"Elapsed time: 163.33 msecs"
#<long[] [J@3ae34094>
user> (def ys (long-array xs))
user> (time (jqsort ys))
"Elapsed time: 13.895 msecs"
#<long[] [J@1b2b2f7f>

性能是天壤之别(一个数量级,然后是一些)。

我有什么遗漏吗?我可能使用过任何 Clojure 功能吗?我认为性能下降的主要来源是当我需要从循环中返回多个值并且必须为此分配一个向量时。这可以避免吗?

顺便说一句,运行 Clojure 1.4。另请注意,我已多次运行基准测试以预热 HotSpot。这是他们安定下来的时候。

更新

我的代码中最可怕的弱点不仅仅是向量的分配,而是它们强制装箱并破坏原始链的事实。另一个弱点是使用loop 的结果,因为它们也会破坏链条。是的,Clojure 中的性能仍然是一个雷区。

【问题讨论】:

  • 你的实现是否等同于Arrays.sort
  • @ponzao 差不多,除了选择支点的方式。但这不可能解释这种差异。请自行联系有问题的Java code
  • @ponzao 检查了一些;因为我用一个随机填充的数组进行基准测试,所以将第一个元素作为枢轴是非常好的。我不会在这里深入了解最坏情况数据集的本质。
  • fyi,Java SE 7 及更高版本使用 TimSort 对数组进行排序,与 Quicksort 相比,它在实际数据上的性能略好。见en.wikipedia.org/wiki/Timsort
  • 不,@sw1nn 仍然是 quicksort

标签: clojure


【解决方案1】:

这个版本是基于@mikera 的,速度一样快并且不需要使用丑陋的宏。在我的机器上,java.util.Arrays/sort 需要约 12 毫秒,而 java.util.Arrays/sort 需要约 9 毫秒:

(set! *unchecked-math* true)
(set! *warn-on-reflection* true)

(defn swap [^longs a ^long i ^long j]
  (let [t (aget a i)]
    (aset a i (aget a j))
    (aset a j t)))

(defn ^long apartition [^longs a ^long pivot ^long i ^long j]
  (loop [i i j j]
    (if (<= i j)
      (let [v (aget a i)]
        (if (< v pivot)
          (recur (inc i) j)
          (do 
            (when (< i j) 
              (aset a i (aget a j))
              (aset a j v))
            (recur i (dec j)))))
      i)))

(defn qsort 
  ([^longs a]
     (qsort a 0 (long (alength a))))
  ([^longs a ^long lo ^long hi]    
     (when
         (< (inc lo) hi)
       (let [pivot (aget a lo)
             split (dec (apartition a pivot (inc lo) (dec hi)))]
         (when (> split lo)
           (swap a lo split))
         (qsort a lo split)
         (qsort a (inc split) hi)))
     a))

(defn ^longs rand-long-array []
  (let [rnd (java.util.Random.)] 
    (long-array (repeatedly 100000 #(.nextLong rnd)))))

(comment
  (dotimes [_ 10]
    (let [as (rand-long-array)]
      (time
       (dotimes [_ 1] 
         (qsort as)))))
  )

从 Clojure 1.3 开始,几乎不需要手动内联。仅在函数参数上提供一些类型提示,JVM 将为您执行内联。对于数组操作,无需将索引参数转换为 int - Clojure 会为您执行此操作。

需要注意的一点是,嵌套循环/递归确实会给 JVM 内联带来问题,因为循环/递归(此时)不支持返回原语。所以你必须把你的代码分解成单独的 fns。这是最好的,因为嵌套循环/递归在 Clojure 中变得非常丑陋。

如需更详细地了解如何始终如一地实现 Java 性能(当您真正需要它时),请查看并了解 test.benchmark

【讨论】:

  • 我不同意你的分析---loop/recur 编译成普通的循环字节码。我没有看到任何阻止 loop 返回原语的东西。
  • 我更改了语言。我并不是要暗示循环/递归不可能返回原语。只是它目前不支持它。
  • 是的,这是我的代码的另一个问题。不过,我不一定会介意代码样式级别的嵌套。性能仍然是 Clojure 的雷区。
  • 现在我不同意。它在许多方面比大多数人想象的更简单,涉及的巫术也更少。如果代码有很多类型提示和宏,并且我看到了很多,那么它很可能不是最优的。无论如何,正如我所说,test.benchmark 值得研究一下 >= Clojure 1.3 的少数技术
  • 公平地说,这个解决方案并不比他的快。他和我测试了它并得出了类似的结论。它们至少处于相同的速度水平,mikera 可能具有优势。
【解决方案2】:

由于宏,这有点可怕,但使用这段代码,我认为你可以匹配 Java 速度(我得到大约 11 毫秒的基准测试):

(set! *unchecked-math* true)

(defmacro swap [a i j]
  `(let [a# ~a
         i# ~i
         j# ~j
         t# (aget a# i#)]
     (aset a# i# (aget a# j#))
     (aset a# j# t#)))

(defmacro apartition [a pivot i j]
  `(let [pivot# ~pivot]
     (loop [i# ~i
            j# ~j]
       (if (<= i# j#)
         (let [v# (aget ~a i#)]
           (if (< v# pivot#)
             (recur (inc i#) j#)
             (do 
               (when (< i# j#) 
                 (aset ~a i# (aget ~a j#))
                 (aset ~a j# v#))
               (recur i# (dec j#)))))
         i#))))

(defn qsort 
  ([^longs a]
    (qsort a 0 (alength a)))
  ([^longs a ^long lo ^long hi]    
    (let [lo (int lo)
          hi (int hi)]
      (when
        (< (inc lo) hi)
        (let [pivot (aget a lo)
              split (dec (apartition a pivot (inc lo) (dec hi)))]
          (when (> split lo) (swap a lo split))
          (qsort a lo split)
          (qsort a (inc split) hi)))
      a)))

主要技巧有:

  • 用原始算术做所有事情
  • 对数组索引使用整数(这样可以避免一些不必要的强制转换,没什么大不了的,但每一点都有帮助....)
  • 使用宏而不是函数来分解代码(避免函数调用开销和参数装箱)
  • 在内部循环中使用循环/递归以获得最大速度(即对子数组进行分区)
  • 避免在堆上构造任何新对象(因此避免使用向量、序列、映射等)

【讨论】:

  • 根据the documentation on 1.3 Numerics,Clojure 内部应该只适用于longs。你真的注意到使用 (int lo) 和不使用它的区别了吗?
  • 我认为 Clojure 确实在函数中使用整数作为原语。参见例如clojure.lang.RT.intCast 的代码。不过,似乎对时间安排没有明显的影响。我相信对 long 的限制主要适用于作为函数参数传递的原语。
  • 我想知道...我已经使用了原始算术;我没有函数或宏调用;我到处使用循环递归。
  • 您的解决方案是否基于this post(或者可能是相同的代码,但发布在其他地方)?
  • @Marko - 不用担心,从风格的角度来看,dnolen 的代码肯定更好,并且对于大多数编码人员来说,模仿这个答案会更好。我仍然认为我的版本略微快了 - 我在我的机器(Clojure 1.4,Java 7 in Eclipse 逆时针)上快了大约 15%,YMMV。
【解决方案3】:

Joy of Clojure,第 6.4 章描述了一种惰性快速排序算法。惰性排序的美妙之处在于,它只会做尽可能多的工作来找到第一个 x 值。所以如果 x

(ns joy.q)

(defn sort-parts
  "Lazy, tail-recursive, incremental quicksort.  Works against
   and creates partitions based on the pivot, defined as 'work'."
  [work]
  (lazy-seq
   (loop [[part & parts] work]
     (if-let [[pivot & xs] (seq part)]
       (let [smaller? #(< % pivot)]
         (recur (list*
                 (filter smaller? xs)
                 pivot
                 (remove smaller? xs)
                 parts)))
       (when-let [[x & parts] parts]
         (cons x (sort-parts parts)))))))

(defn qsort [xs]
    (sort-parts (list xs))) 

【讨论】:

  • 这类事情是算法名称的双关语。好的,如果我真的需要数千个数组中的前 3 个元素,这实际上可能很有用。但至于数组排序,没用。
  • 这个想法很巧妙!在想要对整个数组进行排序的一般情况下,总体来说并不是很快。不过,+1 是为了分享一个有趣的快速排序实现。
  • @mikera Clojure 的喜悦这本书真的是一本很棒的书,里面充满了优秀而有趣的东西,即使对于经验丰富的 Clojure 程序员也是如此。这种排序实现是简洁 Clojure 代码的一个很好的例子。我只是犹豫称它为快速排序:)
  • 有人实际运行过这段代码吗?它的表现是可怕的。只是一个玩具。
【解决方案4】:

通过检查 mikera 回答的要点,您可以看到他们主要集中在消除使用惯用的(而不是经过调整的)Clojure 引入的开销,这在惯用的 Java 实现中可能不存在:

  • 原始算术 - 在 Java 中更简单、更惯用,你更可能使用 ints 而不是 Integers
  • 数组索引的整数 - 相同
  • 使用宏而不是函数来分解代码(避免函数调用开销和装箱) - 修复了使用该语言引入的问题。 Clojure 鼓励函数式风格,因此会产生函数调用开销(和装箱)。
  • 在内部循环中使用循环/递归以获得最大速度 - 在 Java 中,您会习惯性地使用普通循环(据我所知,无论如何循环/递归都是编译成的)

话虽如此,实际上还有另一个简单的解决方案。编写(或找到)快速排序的高效 Java 实现,用这样的签名说:

Sort.quickSort(long[] elems)

然后从 Clojure 调用它:

(Sort/quickSort elems)

清单:

  • 与 Java 一样高效 -

  • Clojure 中的惯用语 - 可以说是,我想说 Java 互操作是 Clojure 的核心功能之一。

  • 可重用 - ,您很有可能很容易找到已经编写的非常高效的 Java 实现。

我不是要拖钓,我明白你想通过这些实验找出什么我只是为了完整起见添加这个答案。让我们不要忽视显而易见的! :)

【讨论】:

  • 好吧,我已经按照您的要求包含了一个杀手级 qsort 实现:(defn jqsort [^longs a] (java.util.Arrays/sort a) a))
  • @MarkoTopolnik:没错!请记住,Java 的 Array.sort 出于某种原因在参数是对象时使用合并排序,而对原语使用 qsort。在您的示例中,它是 qsort,因为 long 是原始的。
  • 这是由于需要稳定的排序。 Java 7 已将其升级为 new kind of sort,顺便说一句。
  • @MarkoTopolnik:啊,我明白了。基元只有一个维度,因此稳定性并不重要,因为无论如何您都不能根据不同的标准对它们进行重复排序。谢谢!
  • 其实不是这样的,但是那个“稳定”对于基元来说甚至没有意义。它是针对所有不同的对象定义的,但有些对象根据某种顺序比较相等。原始值没有身份;它们只是价值观。
猜你喜欢
  • 2020-08-14
  • 2014-10-10
  • 2016-12-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-10-08
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多