Quicksort à Clojure
J'essaie de prouver que les performances de Clojure peuvent être sur un pied d'égalité avec Java. Un cas d'utilisation important que j'ai trouvé est le Quicksort. J'ai écrit une implémentation comme suit:
(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)
En outre, cela aide à appeler le Java quicksort:
(defn jqsort [^longs a] (java.util.Arrays/sort a) a))
Maintenant, pour le benchmark.
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>
Les performances sont des mondes à part (un ordre de grandeur, puis quelques uns).
Y a-t-il quelque chose qui me manque, une fonctionnalité Clojure que j'ai peut-être utilisée? Je pense que la principale source de performance la dégradation est quand j'ai besoin de retourner plusieurs valeurs d'une boucle et doit allouer un vecteur pour cela. Cela peut-il être évité?
BTW en cours d'exécution Clojure 1.4. Notez également que j'ai exécuté le benchmark plusieurs fois afin de réchauffer le HotSpot. Ce sont les moments où ils s'installent.
Mettre à jour
La faiblesse la plus terrible de mon code n'est pas seulement l'allocation de vecteurs, mais le fait qu'ils forcent la boxe et cassent la chaîne primitive. Une autre faiblesse est en utilisant les résultats de loop
car ils cassent également la chaîne. Oui, la performance à Clojure est toujours un champ de mines.
4 réponses
Cette version est basée sur @mikera, est tout aussi rapide et ne nécessite pas l'utilisation de macros laides. Sur ma machine, cela prend ~12ms vs ~ 9ms pour java.util.Tableaux / tri:
(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)))))
)
Le besoin d'inlining manuel est surtout inutile à partir de Clojure 1.3. Avec quelques conseils de type uniquement sur les arguments de la fonction, la JVM fera l'inlining pour vous. Il n'est pas nécessaire de convertir des arguments d'index en int pour les opérations du tableau - Clojure le fait pour vous.
Une chose à surveiller Car est-ce que loop/recur imbriqué présente des problèmes pour l'inlining JVM puisque loop / recur ne supporte pas (pour le moment) le retour des primitives. Vous devez donc séparer votre code en fns séparés. C'est pour le mieux car les boucles/récurs imbriqués deviennent très moches dans Clojure de toute façon.
Pour un aperçu plus détaillé de la façon d'atteindre systématiquement les performances Java (lorsque vous en avez réellement besoin), veuillez examiner et comprendre test.référence.
C'est un peu horrible à cause des macros, mais avec ce code, je pense que vous pouvez faire correspondre la vitesse Java (je reçois environ 11ms pour le benchmark):
(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)))
Les principales astuces sont:
- Faites tout avec l'arithmétique primitive
- Utilisez ints pour les index de tableau (cela évite certains casts inutiles, pas un gros problème mais chaque petit aide....)
- Utilisez des macros plutôt que des fonctions pour briser le code (évite les frais généraux d'appel de fonction et la boxe de paramètre)
- Utilisation boucle / récur pour la vitesse maximale dans la boucle interne (c'est-à-dire le partitionnement du sous-tableau)
- évitez de construire de nouveaux objets sur le tas (évitez donc les vecteurs, les séquences,les cartes, etc.)
La Joie de Clojure, Chapitre 6.4 décrit un algorithme de quicksort paresseux.La beauté du tri paresseux est qu'il ne fera que le travail nécessaire pour trouver les premières valeurs X. Donc, si 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)))
En examinant les principaux points de la réponse de mikera, vous pouvez voir qu'ils sont principalement axés sur l'élimination de la surcharge introduite en utilisant idiomatique (par opposition à tweaked) Clojure, qui n'existerait probablement pas dans une implémentation Java idiomatique:
-
arithmétique primitive - légèrement plus facile et plus idiomatique en Java, vous êtes plus susceptible d'utiliser
int
s queInteger
s - ints pour les index de Tableau - le même
- utiliser des macros plutôt que fonctions pour briser le code (évite les frais généraux d'appel fonctionnels et la boxe) - corrige un problème introduit en utilisant le langage. Clojure encourage le style fonctionnel, d'où un frais généraux d'appel de fonction (et la boxe).
- Utilisez loop / recur pour la vitesse maximale dans la boucle interne - en Java, vous utiliseriez idiomatiquement une boucle ordinaire (ce que loop/recur compile de toute façon, pour autant que je sache)
Cela étant dit, il existe en fait une autre solution triviale. Écrire (ou de trouver) une implémentation Java efficace de Quick Sort, dites quelque chose avec une signature comme ceci:
Sort.quickSort(long[] elems)
Et puis appelez-le de Clojure:
(Sort/quickSort elems)
Liste de contrôle:
Aussi efficaces Que dans Java - oui
Idiomatique dans Clojure - sans doute oui , je dirais que Java-interop est l'une des fonctionnalités de base de Clojure.
Réutilisable - oui, il ya une bonne chance que vous pouvez facilement trouver un très efficace Implémentation Java déjà écrite.
Je n'essaie pas de troll, je comprends ce que vous essayez de découvrir avec ces expériences, je n'ajoute que cette réponse par souci d'exhaustivité. Ne négligeons pas le plus évident! :)