2024 Clojure状态调查中分享您的想法!

欢迎!请查阅关于页面获取更多有关如何操作的信息。

+9
集合
重新标记

我发现持久性集合之间的结构相等性很少做出假设,这导致效率低下,尤其是对于数组和映射。

实行的重点是通过方法进行分派,这些方法直接遍历基础数组。

这些实现可能不是最帅或者最符合习惯的,但它们是有效率的。如果真的实现了,在Java中可能会有所不同。

我尝试了这些替代实现,并发现了显著的加速

向量

(let [die (clojure.lang.Reduced. false)]
  (defn vec-eq
    [^PersistentVector v ^Iterable y]
    (let [iy (.iterator y)]
      (.reduce v (fn [_ x] (if (= x (.next iy)) true die)) true))))

当比较向量和列表时,这效果很好。
当前实现从一个循环0到计数,并对每个元素调用nth。nth每次都会调用arrayFor(),而reduce和迭代器只需要在每个数组中获取一次底层数组。

映射

(let [o (Object.)
      die (clojure.lang.Reduced. false)
      eq (fn [m2] (fn [b k v]
                   (let [v' (.valAt ^IPersistentMap m2 k o)]
                     (if (.equals o v')
                       die
                       (if (= v v') true die)))))]
  (defn map-eq
    [m1 m2]
    (.kvreduce ^IKVReduce m1 (eq m2) true)))

在这里,实现直接遍历底层数组结构。
当前实现将数组转换为序列,然后遍历它,同时通过映射接口从其他映射中获取条目。
此实现避免了将映射转换为序列,且不分配条目。

序列

当接收器是一个列表时,与之比较的对象和接收器将被转换为seq。

与迭代器相比,可能更有效率。

(defn iter-eq
  [^Iterable x ^Iterable y]
  (let [ix (.iterator x)
        iy (.iterator y)]
    (loop []
      (if (.hasNext ix)
        (if (= (.next ix) (.next iy))
          (recur)
          false)
        true))))

基准测试

用criterium,vec-eq在两种情况下都胜出。随着大小的增加回报递减,但n=64时vec-eq的速度是=的两倍。
map-eq对于更大的映射来说也快2-3倍,对于较小的映射来说快10倍。

(doseq [n [1 2 4 8 16 32 64]
        :let [v1 (vec (range n))
              v2 (vec (range n))]]
  (println 'iter-eq n (iter-eq v1 v2))
  (cc/quick-bench (iter-eq v1 v2))
  (println 'vec-eq n (vec-eq v1 v2))
  (cc/quick-bench (vec-eq v1 v2))
  (println '= n (= v1 v2))
  (cc/quick-bench (= v1 v2)))


(doseq [n [1 2 4 8 16 32 64]
        :let [v1 (vec (range n))
              v2 (list* (range n))]]
  (println 'iter-eq n (iter-eq v1 v2))
  (cc/quick-bench (iter-eq v1 v2))
  (println 'vec-eq n (vec-eq v1 v2))
  (cc/quick-bench (vec-eq v1 v2))
  (println '= n (= v1 v2))
  (cc/quick-bench (= v1 v2)))
(doseq [n [1 2 4 8 16 32 64]
        :let [m1 (zipmap (range n) (range n))
              m2 (zipmap (range n) (range n))]]
  (cc/quick-bench (map-eq m1 m2))
  (cc/quick-bench (= m1 m2)))

补充说明
还检查了以下情况

(doseq [n [10000 100000]
        :let [v1 (vec (range n))
              v2 (assoc v1 (dec (count v1)) 7)]]
  (cc/quick-bench (vec-eq v1 v2))
  (cc/quick-bench (iter-eq v1 v2))
  (cc/quick-bench (= v1 v2)))

(doseq [n [100000]
        :let [m1 (zipmap (range n) (range n))
              m2 (assoc m1 (key (last m1)) 7)]]
  (cc/quick-bench (map-eq m1 m2))
  (cc/quick-bench (= m1 m2)))

优化的实现仍然以巨大的优势胜出

该(映射等于)的子集之一已被移动到 https://clojure.atlassian.net/browse/CLJ-2790

3 个答案

+1
by

小更新 - 写了点Java,使用test.check生成映射,以下是一些结果

 | size | seed |   time before (us) |     time after (us) | improvement |
 |------+------+--------------------+---------------------+-------------|
 |   10 |    0 | 0.7821998686829845 | 0.36678822554200413 |   2.1325654 |
 |   44 |    1 |  4.330622612178792 |   2.103437417654809 |   2.0588312 |
 |   31 |    2 | 3.0628944543188688 |  1.3886572837898974 |   2.2056518 |
 |   21 |    3 |  2.028679128233322 |  0.9572009284455004 |   2.1193869 |
 |   39 |    4 | 3.9265516612189715 |  1.8362321591272501 |   2.1383743 |
 |   18 |    5 | 1.6854334183962798 |  0.8202897942521229 |   2.0546805 |
 |   55 |    6 |  4.908545983501916 |   2.279236807427374 |   2.1535919 |
 |   45 |    7 |  4.464427896621236 |  2.1081167721518987 |   2.1177327 |
 |    6 |    8 | 0.3864066521455632 |  0.1928088585042629 |   2.0040918 |
 |   26 |    9 | 2.7114264338699283 |  1.3179156998000194 |   2.0573595 |
 |   86 |   10 |  8.879776767221973 |   4.380430951657479 |   2.0271468 |
 |   16 |   11 |  1.448846888824073 |  0.6990313285286198 |   2.0726494 |
 |   86 |   12 |  8.340080118652248 |   3.922289043010332 |   2.1263298 |
 |   82 |   13 |  8.249968350056667 |   4.000736723253899 |   2.0621123 |
 |   90 |   14 |  9.004991020408164 |   4.293898687932677 |   2.0971596 |
 |   18 |   15 | 1.8062551014332244 |  0.8815394179030271 |   2.0489783 |
 |   65 |   16 |  6.491169509571479 |   3.130686928716269 |   2.0734010 |
 |    1 |   17 | 0.1196704726877019 | 0.07041214138259107 |   1.6995716 |
 |   12 |   18 | 1.1530046459080272 |  0.6082699042686944 |   1.8955477 |
 |   79 |   19 |  7.466010735312539 |  3.3860477035184937 |   2.2049337 |

实现是equiv的特定化

private boolean associativeEquiv(Associative m) {
    for(int i=0;i < array.length;i+=2)
        {
            Object k = array[i];
            IMapEntry e = m.entryAt(k);
            if (e == null)
                return false;
            if(!Util.equiv(array[i+1], e.val()))
                return false;
        }
    return true;
}

private static Object SENTINEL = new Object();

private boolean mapEquiv(Map m) {
    for(int i=0;i < array.length;i+=2)
        {
            Object k = array[i];
            Object v = m.getOrDefault(k, SENTINEL);
            if (SENTINEL == v)
                return false;
            if(!Util.equiv(array[i+1], v))
                return false;
        }

    return true;
}

@Override
public boolean equiv(Object obj){
    if(!(obj instanceof Map))
        return false;
    if(obj instanceof IPersistentMap && !(obj instanceof MapEquivalence))
        return false;

    Map m = (Map) obj;

    if(m.size() != size())
        return false;

    if (m instanceof Associative)
        return associativeEquiv((Associative) m);
    return mapEquiv(m);
}
0
by

我没有验证你的结果,但你的基准测试范围相当有限,仅测试了基本的小集合大小。当达到1.000、10.000、100.000等项时情况如何?

我怀疑如果你比较的是真正利用“结构化共享”的情况,结果可能会有很大差异。例如创建一个向量并更新最后一个元素然后进行比较?这应该是你实现的最坏情况,但当前实现很好。对于映射也是一样。

话虽如此,优化的“reduce”实现相对较新,它们在某个地方可能比旧的东西更有效。不过,在得出结论之前,请确保在更多场景下进行验证。

by
遗憾地,如果你查看equiv的实现,你会发现它没有利用结构化共享来短路,这又是另一种可能的优化。
就像我在原始帖子中提到的那样,向量等于通过最多调用nth() N次来实现。
现在对1e4元素进行验证,vec-eq大约快20%,while iter-eq快到60%。
对1e5元素的结果类似
对于1e5映射元素,map-eq只用了`=`的时间的40%,其中`m2 <- (assoc m1 (key (last m1)) 7)`。
哎呀,我盲目的假设了结构共享会工作,但我猜这可能需要深入到每个实现的细节,并且对于仅仅实现了接口的特殊类型可能会失败。我检查了CLJS实现,它已经使用了一个基于迭代器的版本。
0

除非不连接或由应用程序需求激发,否则考虑微型优化Pull Requests很难。这种增强功能的考虑阈值很高。

顺便说一下,那些实现显然是错误的

user=> (vec-eq [1 2 3] [1 2 3 4])
true
user=> (map-eq {1 2} {1 2 3 4})
true
我认为讨论这个问题的各位应该熟悉equiv()的实现方式。我只是省略了数字校验,就像我省略了instanceof校验一样。这些都仅仅是为了证明概念,表明存在大量的性能潜力,应该加以考虑。
使用集合作为键或成员集合会有很差性能。
我现在正以一个欢迎优化的角色发表以下言论

过去,从应用程序需求或问题陈述方面推进优化通常更有效,而不是从实现方面。也许我们找到了2倍的性能改进,但这对真实世界应用程序总运行时间的0.1%产生了影响。在这种情况下,即使10倍的性能提升也不值得投资。(审查是一个巨大的承诺;Fogus,Alex,和Rich花费大量时间给票据引入严谨性)

使用集合作为键或集合成员,这真的会使应用变慢吗?在这种现实中,优化似乎是很有必要的,但我怀疑这并不是事实。大约在1.6版本,由于在现实应用中遇到了性能问题,才改变了哈希实现。作为多年来致力于提升Clojure性能的人,我的一般建议是:与问题/应用保持联系,并从这个框架开始。

不要牺牲正确性——基准测试将无效。使用生成测试来引导兼容性和正确性检查。

尽管如此,我并不是说没有一些潜在的改进。历史上,转向基于reduce或迭代器的路径对性能提升非常有益。但是,要开放思维,考虑到它可能对某个应用来说并不重要。
这说得有道理。
至于可能从这项改进中受益的应用,我认为规则引擎和core.logic是优先级最高的。
示例
odoyle https://github.com/oakes/odoyle-rules/blob/master/src/odoyle/rules.cljc#L377
Clara
- activation-group-fn: https://github.com/cerner/clara-rules/blob/main/src/main/clojure/clara/rules/engine.cljc#L2113
在此处用作键的激活组函数:https://github.com/cerner/clara-rules/blob/main/src/main/clojure/clara/rules/memory.cljc
core.logic: 可取索引的集合关系的变量 https://github.com/clojure/core.logic/blob/master/src/main/clojure/clojure/core/logic/pldb.clj

在这些中,我只对odoyle进行了分析。尽管它仍有优化空间,但它在pcequiv()中花费了大约~10%的时间。

我略去“完整”解决方案的原因主要是时间。我认为这足以说明潜在的大幅改进。然后我将它呈现给核心团队,期待三者之一的回应
1. 很好的发现,但目前不需要担心。
2. 继续前进,发送一个包含综合基准测试的完整补丁。
3. 我们会自己弄。

我不介意任何这些回应,但正如你所说,审查是一个巨大的承诺,对我而言,着手这项工作也不是一件简单的事情。我不想在可能只是长时间留在待办事项列表中的补丁上投入大量精力,因为核心团队已经非常忙碌,而且这个问题优先级不高。

如果有人感兴趣,我很乐意制作一个完整的补丁,包括性能测试矩阵。
我认为对平等优化会有兴趣。我也认为保留当前泛型(较少假设的同义词)是一个挑战,同时考虑到我们是在控制类型上实施,包括我们控制的类型、闭包类型(Java 代码)和开放的类型(外部 Clojure 收集)。泛型与具体优化(如这种)相抗衡是常见的,“挑战”并不意味着不做它。 :) 我认为,如果您不涉及到真正的实施细节,就无法真正地介入这个问题,因为这些细节可以展示策略的选择如何影响性能,特别是在小集合上。

正如 Ghadi 说,了解这种变化对真实事物的实际影响,有助于判断其优先级。在研究这类问题时,我通常对 Clojure 进行一些修改,以收集调用点的分布,然后运行一些东西来查看 = 的调用频率以及在何种类型/大小上分布。看起来你已经做了一些这方面的工作,更多将是有用的。

你提出了一些实现重写的建议,如果你知道某些信息,这些看起来是直观上很好的选项,但我怀疑根据你可以做出的假设存在多种选择(具体的具体类型、可折叠的、可迭代的、可序列化的等)。我们通常会尝试列出其中的一些。
by
驱动 Clojure 1.6 中哈希变化的场景之一涉及了对集合集合的大量使用,这在 N-queens 问题(基准是在 https://github.com/jafingerhut/chess-clojure)中得到了展示。
...