Clojure惰性序列的性能优化

4

我是一名新手Clojure程序员,我有一些需要优化的代码。我想计算并发计数。主要函数是compute-space,输出是一个嵌套的map类型。

{"w1" {"w11" 10, "w12" 31, ...}
 "w2" {"w21" 14, "w22" 1,  ...}
 ... 
 }

这意味着"w1"与"w11"同时出现10次,等等...

它需要一组文档(句子)和一组目标词,遍历两者并最终应用上下文函数,比如滑动窗口来提取上下文单词。更具体地说,我正在传递一个闭包到滑动窗口

(compute-space docs (fn [target doc] (sliding-window target doc 5)) targets)

我已经使用大约5000万个单词(约300万个句子)和约20,000个目标进行测试。这个版本需要超过一天才能完成。我还编写了一个pmap并行函数(pcompute-space),可以将计算时间缩短到约10小时,但我仍然感觉它应该更快。我没有其他可供比较的代码,但我的直觉是它应该更快。

(defn compute-space 
  ([docs context-fn targets]
    (let [space (atom {})]
      (doseq [doc docs
              target targets]
        (when-let [contexts (context-fn target doc)]
          (doseq [w contexts]
            (if (get-in @space [target w])
              (swap! space update-in [target w] (partial inc))
              (swap! space assoc-in  [target w] 1)))))
     @space)))

(defn sliding-window
  [target s n]
  (loop [todo s seen [] acc []]
    (let [curr (first todo)]
      (cond (= curr target) (recur (rest todo) (cons curr seen) (concat acc (take n seen) (take n (rest todo))))
            (empty? todo) acc
            :else (recur (rest todo) (cons curr seen) acc)))))


(defn pcompute-space
  [docs step context-fn targets]
  (reduce
     #(deep-merge-with + %1 %2)
      (pmap
        (fn [chunk]
          (do (tick))
          (compute-space chunk context-fn targets))
        (partition-all step docs)))

我使用 jvisualvm 对应用程序进行了剖析,发现 clojure.lang.Consclojure.lang.ChunkedConsclojure.lang.ArrayChunk 过度地支配了进程(见下图)。这肯定与我使用双重 doseq 循环有关(先前的实验表明,这种方式比使用 map、reduce 等方式更快,尽管我使用 time 来对函数进行基准测试)。
如果您能提供任何见解,并提出重构代码以使其运行更快的建议,我将非常感激。我猜 reducers 可以在此处提供一些帮助,但我不确定如何和/或为什么这样做。

jvisualvm memory profile

规格

MacPro 2010 2,4 GHz Intel Core 2 Duo 4 GB RAM

Clojure 1.6.0

Java 1.7.0_51 Java HotSpot(TM) 64-Bit Server VM

测试数据

GithubGist 上的整个代码


抱歉!我进行了一些重构。 - emanjavacas
不涉及优化问题,但 (partial inc) 本质上与 inc 相同。 - Shannon Severance
"sliding-window" 接受三个参数,但只有两个被传递给 "context-fn"。 - Shannon Severance
@ShannonSeverance,感谢您的观察。在context-fn的情况下,实际上是将闭包传递给了compute-space。(例如(fn [target sent] (sliding-window target sent 5)))。我会编辑问题以使其更清晰明了。 - emanjavacas
2个回答

4

测试数据如下:

  • 一个由42个字符串(目标)组成的惰性序列
  • 一个由105,040个惰性集合(文档)组成的惰性序列
  • Documents中的每个惰性序列都是一个字符串序列。文档中包含的字符串总数为1,146,190。

比你的工作量要小得多。Criterium被用来收集时间数据。Criterium会对表达式进行多次计算,首先预热JIT,然后收集平均数据。

使用我的测试数据和你的代码,compute-space花费了22秒:

WARNING: JVM argument TieredStopAtLevel=1 is active, and may lead to unexpected results as JIT C2 compiler may not be active. See http://www.slideshare.net/CharlesNutter/javaone-2012-jvm-jit-for-dummies.
Evaluation count : 60 in 60 samples of 1 calls.
             Execution time mean : 21.989189 sec
    Execution time std-deviation : 471.199127 ms
   Execution time lower quantile : 21.540155 sec ( 2.5%)
   Execution time upper quantile : 23.226352 sec (97.5%)
                   Overhead used : 13.353852 ns

Found 2 outliers in 60 samples (3.3333 %)
    low-severe   2 (3.3333 %)
 Variance from outliers : 9.4329 % Variance is slightly inflated by outliers

第一次优化 更新使用frequencies将单词向量转换为单词和它们出现次数的映射。

为了帮助我理解计算结构,我编写了一个单独的函数,接受文档集合、context-fn和一个目标单词,并返回上下文单词计数的映射。这个映射是由compute-space返回的一个目标的内部映射。我使用Clojure内置函数编写了这个函数,而不是更新计数。

(defn compute-context-map-f [documents context-fn target]
  (frequencies (mapcat #(context-fn target %) documents)))

使用编写好的compute-context-map-f函数,命名为compute-space-f,可以很轻松地计算出空间。

(defn compute-space-f [docs context-fn targets]
  (into {} (map #(vector % (compute-context-map-f docs context-fn %)) targets)))

使用与上述相同的数据,时间是原始版本的65%:

WARNING: JVM argument TieredStopAtLevel=1 is active, and may lead to unexpected results as JIT C2 compiler may not be active. See http://www.slideshare.net/CharlesNutter/javaone-2012-jvm-jit-for-dummies.
Evaluation count : 60 in 60 samples of 1 calls.
             Execution time mean : 14.274344 sec
    Execution time std-deviation : 345.240183 ms
   Execution time lower quantile : 13.981537 sec ( 2.5%)
   Execution time upper quantile : 15.088521 sec (97.5%)
                   Overhead used : 13.353852 ns

Found 3 outliers in 60 samples (5.0000 %)
    low-severe   1 (1.6667 %)
    low-mild     2 (3.3333 %)
 Variance from outliers : 12.5419 % Variance is moderately inflated by outliers

首先进行并行化优化

我选择按目标分块而不是按文档分块,这样合并映射就不需要修改目标的{上下文-单词计数, ...} 映射。

(defn pcompute-space-f [docs step context-fn targets]
  (into {} (pmap #(compute-space-f docs context-fn %) (partition-all step targets))))

在与上述相同的数据情况下,时间效率提高了16%。

user> (criterium.core/bench (pcompute-space-f documents 4 #(sliding-window %1 %2 5) keywords))
WARNING: JVM argument TieredStopAtLevel=1 is active, and may lead to unexpected results as JIT C2 compiler may not be active. See http://www.slideshare.net/CharlesNutter/javaone-2012-jvm-jit-for-dummies.
Evaluation count : 60 in 60 samples of 1 calls.
             Execution time mean : 3.623018 sec
    Execution time std-deviation : 83.780996 ms
   Execution time lower quantile : 3.486419 sec ( 2.5%)
   Execution time upper quantile : 3.788714 sec (97.5%)
                   Overhead used : 13.353852 ns

Found 1 outliers in 60 samples (1.6667 %)
    low-severe   1 (1.6667 %)
 Variance from outliers : 11.0038 % Variance is moderately inflated by outliers

技术规格

  • Mac Pro 2009,2.66 GHz 四核 Intel Xeon,48 GB RAM。
  • Clojure 1.6.0。
  • Java 1.8.0_40 Java HotSpot(TM) 64位服务器 VM。

TBD

进一步优化。

描述测试数据。


首先感谢您的代码和帮助!在我的电脑上尝试后,我有点困惑,因为我得到了矛盾的结果。我已经添加了我的规格到一个 Github Gist 中,包括所有相关的代码(输入/输出函数等),标准结果以及我正在使用的测试文件的链接。对于这种分歧的原因有什么见解吗? - emanjavacas
@emanjavacas 很有趣。不过我不知道是否有时间去看它。 - Shannon Severance
由于我没有收到原帖作者的回复,我能请你验证一下我的工作吗? - Thumbnail
@Thumbnail,你提出了一些自己经过时间验证的好点子。但是我没有时间来验证或评论你的工作。抱歉。 - Shannon Severance
我开始使用Criterium了。一次性测试所有目标确实有很大的改进,但转移到transients实际上会减慢速度!仍然没有OP的消息。 - Thumbnail

1

对问题中的compute-space算法的分析

扫描句子 - 寻找目标 - 的成本:

  • 与单词总数成正比
  • 与目标数量成正比,但
  • 与单词被分成的句子数量多少无关。

处理目标的成本:

  • 与目标击中率成正比,
  • 与其上下文中不同单词的数量成正比。

主要改进

context-fn扫描句子以寻找目标。 如果有一万个目标,则需要扫描一万次该句子

更好的办法是只扫描一次句子,寻找所有目标。如果我们将目标保留为(哈希)集合,则可以在几乎恒定的时间内测试一个单词是否为目标,无论目标有多少个

可能的改进

sliding-windows函数通过将每个单词从待处理传递到已处理来生成上下文。最好的方法是将单词放入向量中,然后将上下文作为subvec返回。

无论如何,生成上下文的简单方法是使context-fn返回与单词序列对应的上下文序列。一个可以用于sliding-windows的函数是

(defn sliding-windows [w s]
  (let [v (vec s), n (count v)
        window (fn [i] (lazy-cat (subvec v (max (- i w) 0) i)
                                 (subvec v (inc i) (min (inc (+ i w)) n))))]
    (map window (range n))))

我们现在可以根据新的contexts-fn类型来定义compute-space函数,如下所示:
(defn compute-space [docs contexts-fn target?]
  (letfn [(stuff [s] (->> (map vector s (contexts-fn s))
                          (filter (comp target? first))))]
    (reduce
     (fn [a [k m]] (assoc a k (merge-with + (a k) (frequencies m))))
     {}
     (mapcat stuff docs))))

代码的核心在于stuff:
  • 我们将stuff开发成一系列[目标上下文序列]对。
  • 然后将每个对合并到总体中,并为每个目标出现次数添加相应的邻居计数。

结果

该算法比问题中的代码快约500倍:问题中的代码需要一天半时间完成的任务,这个算法只需要大约1分钟。

假设

  • 有一个包含100,000个单词的词汇表,
  • 一个包含100,000个单词的句子,以及
  • 10,000个目标

这段代码可以在100毫秒内构建上下文映射。

对于长度为1/10的句子 - 10,000个单词 - 问题中的代码需要5秒钟。

这里使用(长)整数而不是字符串作为“单词”。因此,组装带有其哈希值的字符串的工作会稍微减少改进效果。

注意

我撤下了这个答案的原始版本,因为...

  • 代码中存在转录错误;
  • 性能声明不准确。

通过由Criterium进行的准确测试,使用暂时映射的版本被证明稍微慢一些,因此已被省略。


抱歉我没有收到你的回复通知。感谢你的答复。不幸的是,我没有足够的时间在短期内测试你的建议,但是它们听起来很有趣。另一件我想尝试的事情是转向reducers框架。 - emanjavacas
@emanjavacas 转向reducers/transducers框架将产生一个恒定因子的改进 - 我猜测是2到5,但可能更多。上面的答案产生了与目标数量成比例的改进:在您的情况下,大约是10K的因素。 - Thumbnail

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接