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接通过接口从另一个映射获取条目。
这种实现避免了将映射转换为序列,并且不会分配条目。

序列

当接收者是一个列表时,与之比较的对象和接收者都会被转换为一个序列。

通过迭代器与其他集合进行比较可能会更有效率。

(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的实现,会发现它没有使用结构共享来短路,这是另一种可能的优化。
就像我在原始帖子中提到的,向量相等,例如,最多通过调用 `nth()` N 次来实现。
现在对 1e4 个元素进行测试,vec-eq 相比之下快了约 20%,而 iter-eq 快了高达 60%。
对 1e5 个元素有类似的结果
对于 1e5 个字典元素,map-eq 仅需要 `=` 时间的 40%,其中 `m2 <- (assoc m1 (key (last m1)) 7)`
by
哎呀,我有点盲目地认为结构共享会起作用,但我猜这可能需要深入了解实现的具体细节,并且对于只是实现了接口的特定类型可能无法成功。我检查了CLJS实现,并且它已经使用了基于迭代器的版本。
0
by

除非不连接或由应用程序需求激发,否则微优化PR很难考虑。考虑此类增强的阈值很高。

顺便说一下,那些实现很简单就有问题

user=> (vec-eq [1 2 3] [1 2 3 4])
true
user=> (map-eq {1 2} {1 2 3 4})
true
by
我认为考虑这个问题的人熟悉equiv的实现方式。就像我忽略了计数检查一样,我忽略了 instanceof 检查。这些都只是证明概念的例子,表明有很多性能可以待挖掘并且应该加以考虑。
需要有应用程序需求?使用集合作为键或集合成员性能很差。
by
以下是我作为一个欢迎优化的味道说出来的

以前,从应用程序需求或问题陈述方面开始激励优化要比从实现方面开始更有成效。也许我们发现相等性的性能提高了 2 倍,但这对实际应用程序的总运行时间的 0.1% 产生影响。在这种情况下,即使 10 倍的改进也不值得投资。(审查是一个巨大的承诺;Fogus、Alex 和 Rich 在将严谨性带给票据上投入了大量时间)

使用集合作为键或集合成员会让应用程序变慢,这种说法真的正确吗?在现实中,优化确实是很有吸引力的,但我觉得这并不成立。大约在1.6版本,由于一个现实应用中的性能问题,我们改变了哈希的实现。作为多年来致力于Clojure性能改进的人,我的一般建议是:始终坚持问题的视角,从问题的框架出发。

不要“忽略”正确性——基准测试将是无效的。使用生成性测试来指导兼容性和正确性检查。

尽管如此,我并不是说没有潜在的改进空间。转向基于“reduce”或迭代器的路径在历史上已经非常有助于提高性能。但是,开放心态,考虑到它可能对某些应用并不重要。
by
这很有道理。
至于可能受益于这种改进的应用,我认为规则引擎和 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. 我们自己来做

我不介意任何这些回应,但正如你所说的,审查是一个巨大的承诺,而且对我来说,进行这项工作也不会是件简单的事情。我不想在一个可能长期躺在待处理事项列表中的补丁上投入大量的精力,因为核心团队手头已经有太多事情要做了,而这个问题又不够优先。

如果有兴趣,我将很高兴编写一个包含性能测试矩阵的完整补丁
by
我认为对平等优化会有兴趣。我也觉得在保持当前泛型性的同时具有挑战性(泛型性即“几乎没有假设”的同义词),还需要考虑到我们正在控制的类型上实施此优化,而不是关闭的类型(Java内容),以及开放的类型(外部Clojure集合)。泛型性与具体优化(如这种)进行斗争是常见的,“具有挑战性”当然并不意味着不去做。 :) 我认为如果不深入研究实现的细节,你真的无法参与这个问题,因为你可以在那里看到策略的选择如何影响性能,尤其是在小集合上。

正如Ghadi所说,了解这种更改对真实内容的影响有多大,有助于判断其优先级。当我研究这样的问题时,我通常会修改Clojure来收集调用点的分布,然后运行一些东西来查看等号(=)调用的频率以及分布中的类型/大小。看起来你已经做了一点这方面的研究,更多的这类研究是有用的。

你提出了一些实现重构的建议,如果你了解某些情况,这些似乎像是直觉上的好选择,但我怀疑在你可以做出哪些假设的情况下(具体的实体类型、可简化、可迭代、可序列化等)有很多不同的选择。我们通常会尝试列举这些其中的几个。
一个使用案例激发了Clojure 1.6中散列更改的兴趣,这涉及到对集合的集合的广泛使用,这在N-queens问题(基准为:https://github.com/jafingerhut/chess-clojure)中得到了展示。
...