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 到 count 的循环,并对每个元素调用 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,然后遍历它,并从其他映射通过 Map 接口获取条目。
此实现避免了将映射转换为序列,并且不会分配条目。

序列

当接收者是列表时,与之比较的对象和接收者将被转换为 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)))

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

此中的一个子集(map =)已被移动到https://clojure.atlassian.net/browse/CLJ-2790

3 个答案

+1

小更新 - 编写了一些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

我没有验证你的结果,但你的基准测试的范范围相当有限,并且只测试了基本的小集合大小。如果你测试到1,000、10,000、100,000等项时情况如何?

我预计如果你比较那些真正利用“结构共享”的东西,情况将截然不同。例如,创建一个向量并更新最后一个元素,然后进行比较?这对于你的实现将是最坏的情况,但对于现在的实现来说却相当不错。对于映射也是一样。

话虽如此,优化的“reduce”实现相当新,所以在某些地方它们可能有比旧实现更高的效率。只是确保在得出结论之前验证更多场景。

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

除非与不连接或不受应用程序需求驱动的需求相关,否则微优化PR很难考虑。考虑此增强功能的阈值很高。

顺便说一句,那些实现都是显而易见的有缺陷的

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:在可以集合作为lvars的索引关系中 https://github.com/clojure/core.logic/blob/master/src/main/clojure/clojure/core/logic/pldb.clj

在这些中我只对odoyle进行了分析。虽然它仍然有优化空间,但它大约消耗了大约10%的pcequiv()时间。

我省略“完整”解决方案的原因主要是时间因素。我认为这已经足够说明可能的巨大改进。然后我向核心团队提出,期待三种可能的回应
1. 很好的发现,但目前不必着急。
2. 继续前进,发送完整的补丁和全面的基准测试
3. 我们自己来做

我对上述任何回应都无所谓,但正如您所说,审查是巨大的承诺,对此进行工作对我来说也不会是件小事。我不想在可能长时间积压在待办事项列表中的补丁上投入大量精力,因为核心团队忙于其他任务,而这个问题优先级不高。

如果有人感兴趣,我会很高兴制作完整的补丁和性能测试矩阵。
我认为对等性优化可能会引起兴趣。我也认为保持当前的泛型性(即“做出很少的假设”)具有挑战性,并且我们正在这些受控类型(Java东西)上实施它,不是(封闭类型),以及不受这些类型(外部Clojure集合)控制的类型。泛型性往往与像这样的具体优化以及“挑战性”不同,当然并不意味着不去尝试。 :) 然而,我认为如果您不深入到真正的实现中去,就不能真正地涉足这个问题,在那些地方您可以了解所选择的策略如何影响性能,尤其是在小的集合上。

正如Ghadi所说,了解这样的变化对真实内容的影响至关重要,以便判断其优先级。当我就这类事情进行研究时,我通常会为Clojure编写一些东西,以收集调用点上的分布,然后运行一些东西以查看=何时以及在什么类型/大小上调用分布。看来你已经做了一些这样的工作,更多的会是很有用的。

你提出了一些实现重写的建议,这些如果你了解一些事情,看起来就像是直观上很好的选择,但我怀疑根据您能做出的假设(特定的具体类型、可减少的、可迭代的、可序列化的等)有各种各样的选择。我们通常试图列出一些这样的选择。
推动Clojure 1.6中哈希更改的一个用例涉及到对集合作集的频繁使用,这在N皇后问题(基准测试为https://github.com/jafingerhut/chess-clojure)中得到了证明。
...