Last active
September 27, 2022 19:39
-
-
Save joinr/151328b75de954fd5d29169ddea9b838 to your computer and use it in GitHub Desktop.
Exploring improvements on the relatively slow HexSpeak demo from https://github.com/ttsiodras/HexSpeak
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
;;performance opts by joinr | |
;;running openjdk 1.8 because reasons, bumped clojure dep to 1.11.1 | |
(ns thanassis.hexspeakfast | |
(:gen-class)) | |
(set! *warn-on-reflection* true) | |
(set! *unchecked-math* :warn-on-boxed) | |
(defn slength ^long [^String x] (.length x)) | |
(defn good-words | |
"Reads words from a with-open-ed stream and filters them | |
to only include the ones that contain the letters passed. | |
It also drops words of length 1 and 2, and drops | |
3 'words' of length 3 that exist in /usr/share/dict/words" | |
[rdr letters] | |
(let [matcher (re-pattern (str "^[" letters "]*$")) | |
forbidden #{"aaa" "aba" "abc"}] | |
(into [] | |
(filter | |
#(and (> (slength %) 2) | |
(re-matches matcher %) | |
(not (forbidden %))) | |
(line-seq rdr))))) | |
;;This is inconsistent with the hy implementation, which avoids hashing by | |
;;shoving words into a vector of 0...128. Let's do the same. | |
#_ | |
(defn get-words-per-length | |
"The algorithm below (in solve) needs to access words of the same | |
length, and a vector is much faster than a hashmap (which is what | |
is returned by group-by below). This function uses good-words | |
to generate the list of valid words, and then arranges them into | |
a neat map: | |
1 -> ['a'] | |
3 -> ['bee', 'fed', ...] | |
4 -> ['dead', ...] | |
...etc." | |
[dictionary-file letters] | |
(let [candidates (with-open [rdr (clojure.java.io/reader dictionary-file)] | |
(good-words rdr letters))] | |
(group-by slength (conj candidates "a")))) | |
;;We now dump our WPL into a vector with fast int lookup. | |
;;Avoiding hashing altogether and indexing into vector like the hy version | |
;;shaves off another 25% from the original. | |
(defn get-words-per-length | |
"The algorithm below (in solve) needs to access words of the same | |
length, and a vector is much faster than a hashmap (which is what | |
is returned by group-by below). This function uses good-words | |
to generate the list of valid words, and then arranges them into | |
a neat map: | |
1 -> ['a'] | |
3 -> ['bee', 'fed', ...] | |
4 -> ['dead', ...] | |
...etc." | |
[dictionary-file letters] | |
(let [candidates (with-open [rdr (clojure.java.io/reader dictionary-file)] | |
(good-words rdr letters))] | |
(->> (group-by slength (conj candidates "a")) | |
(reduce-kv (fn [acc ^long k v] | |
(assoc acc k v)) (vec (repeat 128 [])))))) | |
;;legacy implementation of solve uses imperative and seq-focused | |
;;constructs for dotimes, doseq, and boxes all arithemtic. | |
#_ | |
(defn solve | |
"Using the list of valid options from our list of words, | |
recurse to form complete phrases of the desired target-length, | |
and count them all up to see how many there are." | |
[words-per-length target-length phrase-len used-words counter] | |
(dotimes [i (- target-length phrase-len)] | |
(doseq [w (get words-per-length (inc i) [])] | |
(if (not (used-words w)) | |
(if (= target-length (+ i phrase-len 1)) | |
(vswap! counter inc) ;faster than swap! and atom | |
(solve words-per-length target-length (+ phrase-len (inc i)) | |
(conj used-words w) counter)))))) | |
;;We move to a functional implementation to enable primitive hinting | |
;;and unboxed arithmetic without having to hint all callsites. | |
#_ | |
(defn ->solver [words-per-length ^long target-length] | |
(fn solve [^long phrase-len used-words counter] | |
(dotimes [i (- target-length phrase-len)] | |
(doseq [w (get words-per-length (inc i) [])] | |
(if (not (used-words w)) | |
(if (= target-length (+ i phrase-len 1)) | |
(vswap! counter (fn [^long n] (inc n))) ;faster than swap! and atom | |
(solve (+ phrase-len (inc i)) (conj used-words w) counter))))))) | |
;;Moving to unboxed arithmetic buys us ~25-30% speedup. | |
;;just performing vector lookups on wpl vector instead of | |
;;hashing into a map gets us another 20%. | |
#_ | |
(defn ->solver [words-per-length ^long target-length] | |
(fn solve [^long phrase-len used-words ^long counter] | |
(->> (range (- target-length phrase-len)) ;;0, 1 , ... target-length-1 | |
(reduce | |
(fn [^long acc ^long i] | |
(reduce (fn [^long acc w] | |
(if (not (used-words w)) | |
(if (== target-length (+ i phrase-len 1)) | |
(inc acc) | |
(solve (+ phrase-len (inc i)) (conj used-words w) acc)) | |
acc)) | |
acc (words-per-length (inc i)))) | |
counter)))) | |
;;So the hy implementation just uses mutable sets and concatenation for its | |
;;used word implementation. This seems odd since you would expect it to be slower | |
;;in the general case with large dictionaries....but for the empirical benchmark | |
;;being used here, the used words are only like 5 entries....so linear | |
;;scans of the list leveraging identical? outpeform hashsets in this | |
;;narrow case. We probably would want to let the data (size of the | |
;;unused words) determine the underlying structure and have a simple | |
;;api to shift between them, but meh. | |
(defn in [xs k] | |
(reduce (fn [acc x] | |
(if (identical? x k) | |
(reduced true) | |
acc)) false xs)) | |
;;in the worst case, for a 5 element vector we are as fast as a hashset lookup | |
;;according to criterium. in the best case (first entry is found), we are over 2x faster. | |
;;Begs the question if we can do better using an array, but meh. | |
;;using linear scans via `in` instead of hashing gets us another 5% or so. | |
;;add a primitive return to solve. | |
(defn ->solver [words-per-length ^long target-length] | |
(fn solve ^long [^long phrase-len used-words ^long counter] | |
(->> (range (- target-length phrase-len)) ;;0, 1 , ... target-length-1 | |
(reduce | |
(fn [^long acc ^long i] | |
(reduce (fn [^long acc w] | |
(if (not (in used-words w)) | |
(if (== target-length (+ i phrase-len 1)) | |
(inc acc) | |
(solve (+ phrase-len (inc i)) (conj used-words w) acc)) | |
acc)) | |
acc (words-per-length (inc i)))) | |
counter)))) | |
;;using unboxed math gets us down to the 680s | |
#_ | |
(defn -main | |
"Expects as cmd-line arguments: | |
- Desired length of phrases (e.g. 8) | |
- Letters to search for (e.g. abcdef) | |
- Dictionary file to use (e.g. /usr/share/dict/words) | |
Prints the number of such HexSpeak phrases | |
(e.g. 0xADEADBEE - a dead bee - is one of them)" | |
[& args] | |
(let [phrase-length (Integer. ^String (re-find #"\d+" (nth args 0 4))) | |
letters (nth args 1 "abcdef") | |
dictionary-file (nth args 2 "/usr/share/dict/words") | |
counter (volatile! 0) ; faster than atom | |
words-per-length (get-words-per-length dictionary-file letters) | |
solve (->solver words-per-length phrase-length)] | |
(dotimes [n 10] | |
(let [_ (vreset! counter 0) | |
_ (time (solve 0 #{} counter))] | |
(printf "Total: %d\n" @counter))) | |
(flush))) | |
;;eliminating volatile usage, reducing on a long, | |
;;and using vector words-per-length instead of map (like hy | |
;;version did) gets us down to 500's | |
#_ | |
(defn -main | |
"Expects as cmd-line arguments: | |
- Desired length of phrases (e.g. 8) | |
- Letters to search for (e.g. abcdef) | |
- Dictionary file to use (e.g. /usr/share/dict/words) | |
Prints the number of such HexSpeak phrases | |
(e.g. 0xADEADBEE - a dead bee - is one of them)" | |
[& args] | |
(let [phrase-length (Integer. ^String (re-find #"\d+" (nth args 0 4))) | |
letters (nth args 1 "abcdef") | |
dictionary-file (nth args 2 "/usr/share/dict/words") | |
counter 0 #_(volatile! 0) ; faster than atom | |
words-per-length (get-words-per-length dictionary-file letters) | |
solve (->solver words-per-length phrase-length)] | |
(dotimes [n 10] | |
(let [n (time (solve 0 #{} counter))] | |
(printf "Total: %d\n" n))) | |
(flush))) | |
;;using linear scans instead of hashset... | |
;;gets us to 470's at the low end. | |
#_ | |
(defn -main | |
"Expects as cmd-line arguments: | |
- Desired length of phrases (e.g. 8) | |
- Letters to search for (e.g. abcdef) | |
- Dictionary file to use (e.g. /usr/share/dict/words) | |
Prints the number of such HexSpeak phrases | |
(e.g. 0xADEADBEE - a dead bee - is one of them)" | |
[& args] | |
(let [phrase-length (Integer. ^String (re-find #"\d+" (nth args 0 4))) | |
letters (nth args 1 "abcdef") | |
dictionary-file (nth args 2 "/usr/share/dict/words") | |
counter 0 | |
words-per-length (get-words-per-length dictionary-file letters) | |
solve (->solver words-per-length phrase-length)] | |
(dotimes [n 10] | |
(let [n (time (solve 0 [] counter))] | |
(printf "Total: %d\n" n))) | |
(flush))) | |
;;use a word list instead of vector for cheap conj. | |
;;420's | |
;;adding a return hint on solve gets us to 380's | |
(defn -main | |
"Expects as cmd-line arguments: | |
- Desired length of phrases (e.g. 8) | |
- Letters to search for (e.g. abcdef) | |
- Dictionary file to use (e.g. /usr/share/dict/words) | |
Prints the number of such HexSpeak phrases | |
(e.g. 0xADEADBEE - a dead bee - is one of them)" | |
[& args] | |
(let [phrase-length (Integer. ^String (re-find #"\d+" (nth args 0 4))) | |
letters (nth args 1 "abcdef") | |
dictionary-file (nth args 2 "/usr/share/dict/words") | |
counter 0 | |
words-per-length (get-words-per-length dictionary-file letters) | |
solve (->solver words-per-length phrase-length)] | |
(dotimes [n 10] | |
(let [n (time (solve 0 '() counter))] | |
(printf "Total: %d\n" n))) | |
(flush))) | |
;; thanassis.hexspeakfast> (-main "14" "abcdef" "contrib/words") | |
;; "Elapsed time: 430.6477 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 389.3113 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 390.8865 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 430.753 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 388.195 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 394.8848 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 401.641 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 418.998 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 383.9683 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 391.5956 msecs" | |
;; Total: 3020796 | |
;;baseline, warm jit | |
;; thanassis.hexspeakfast> (#'thanassis.hexspeak/-main "14" "abcdef" "contrib/words") | |
;; "Elapsed time: 1024.986 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 1065.0907 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 1075.3926 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 1016.9033 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 1015.9312 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 1015.3411 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 1005.0903 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 1003.8628 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 967.1175 msecs" | |
;; Total: 3020796 | |
;; "Elapsed time: 968.3548 msecs" | |
;; Total: 3020796 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment