【问题标题】:Why is Python set intersection faster than Rust HashSet intersection?为什么 Python set 交集比 Rust HashSet 交集快?
【发布时间】:2016-05-28 03:13:45
【问题描述】:

这是我的 Python 代码:

len_sums = 0
for i in xrange(100000):
    set_1 = set(xrange(1000))
    set_2 = set(xrange(500, 1500))
    intersection_len = len(set_1.intersection(set_2))
    len_sums += intersection_len
print len_sums

这是我的 Rust 代码:

use std::collections::HashSet;

fn main() {
    let mut len_sums = 0;
    for _ in 0..100000 {
        let set_1: HashSet<i32> = (0..1000).collect();
        let set_2: HashSet<i32> = (500..1500).collect();
        let intersection_len = set_1.intersection(&set_2).count();
        len_sums += intersection_len;
    }
    println!("{}", len_sums);
}

我相信这些大致相同。我得到以下性能结果:

time python set_performance.py
50000000

real    0m11.757s
user    0m11.736s
sys 0m0.012s

rustc set_performance.rs -O       
time ./set_performance 50000000

real    0m17.580s
user    0m17.533s
sys 0m0.032s

使用cargo--release 构建会得到相同的结果。

我意识到 Python 的 set 是用 C 实现的,因此预计会很快,但我没想到它会比 Rust 更快。是不是必须做 Rust 不需要的额外类型检查?

也许我在编译 Rust 程序的过程中遗漏了一些东西,是否还有其他我应该使用的优化标志?

另一种可能是代码并不完全等价,Rust 做了不必要的额外工作,我是否遗漏了什么?

Python 版本:

In [3]: import sys

In [4]: sys.version
Out[4]: '2.7.6 (default, Jun 22 2015, 17:58:13) \n[GCC 4.8.2]'

Rust 版本

$ rustc --version
rustc 1.5.0 (3d7cd77e4 2015-12-04)

我使用的是 Ubuntu 14.04,我的系统架构是 x86_64。

【问题讨论】:

  • 当我将集合构建移出循环并仅重复交集时,当然对于这两种情况,Rust 都比 python2.7 快。所以这个问题有点错误。
  • @bluss 好点,在我的机器上 rust 只快一点点,0m4.168s0m3.838s。并且初始化花费了很多时间。再次感谢。
  • @bluss 但是如果我在 PyPy3 上使用 set1 &amp; set2,我会得到 1.0s vs 2.3s,所以 Python 重新领先;P

标签: python rust hashset


【解决方案1】:

当我将集合构建移出循环并仅重复交集时,当然对于这两种情况,Rust 都比 Python 2.7 快。

我一直在阅读 Python 3 (setobject.c),但 Python 的实现有一些事情要做。

它使用两个 Python 集合对象使用相同的哈希函数这一事实,因此它不会重新计算哈希。 Rust HashSets 的散列函数具有实例唯一键,因此在交集期间,他们必须将一组中的键与另一组的散列函数重新散列。

另一方面,Python 必须为每个匹配的哈希调用像 PyObject_RichCompareBool 这样的动态键比较函数,而 Rust 代码使用泛型并将专门针对 i32 的哈希函数和比较代码。在 Rust 中散列 i32 的代码看起来相对便宜,并且删除了大部分散列算法(处理超过 4 个字节的输入)。


似乎是集合的构造设置 Python 和 Rust 的区别。事实上,不仅仅是构造,还有一些重要的代码正在运行以破坏 Rust HashSets。 (这可以改进,在这里提交错误:#31711

【讨论】:

  • Rust HashSet 的散列函数具有实例唯一键,因此在交集期间,它们必须使用另一组的散列函数重新散列一组键。 => 这可以优化吗出去?我正在考虑可能在BuilderHashDefault 上使用一种方法,或者只是在HashSet/HashMap 的两个实例之间比较说构建器,以尽可能优化哈希重新计算。这样,您可以在需要执行交集/并集/...的集合上使用相同的构建器或等效构建器...
  • 既然元素需要被重新散列(它们被馈送到另一个集合上的 contains_key),为什么该方法在两个集合上都需要相同类型的散列函数,即为什么只有一个泛型类型参数S: BuildHasher + Default?
【解决方案2】:

性能问题归结为HashMapHashSet 的默认哈希实现。 Rust 的默认哈希算法是一种很好的通用算法,它还可以防止某些类型的 DOS 攻击。但是,它不适用于非常少量或非常大量的数据。

一些分析显示make_hash&lt;i32, std::collections::hash::map::RandomState&gt; 占用了大约 41% 的总运行时间。从Rust 1.7 开始,您可以选择使用哪种散列算法。切换到FNV hashing algorithm 会大大加快程序速度:

extern crate fnv;

use std::collections::HashSet;
use std::hash::BuildHasherDefault;
use fnv::FnvHasher;

fn main() {
    let mut len_sums = 0;
    for _ in 0..100000 {
        let set_1: HashSet<i32, BuildHasherDefault<FnvHasher>> = (0..1000).collect();
        let set_2: HashSet<i32, BuildHasherDefault<FnvHasher>> = (500..1500).collect();
        let intersection_len = set_1.intersection(&set_2).count();
        len_sums += intersection_len;
    }
    println!("{}", len_sums);
}

在我的机器上,这需要 2.714 秒,而 Python 需要 9.203 秒。

如果你使用相同的changes to move the set building out of the loop,Rust 代码需要 0.829 秒,而 Python 代码需要 3.093 秒。

【讨论】:

    【解决方案3】:

    撇开哈希不谈,当你以错误的方式与一个小集合和一个大集合相交时,Python 会超越之前的 Rust 版本。例如。这个code on playground

    use std::collections::HashSet;
    fn main() {
        let tiny: HashSet<i32> = HashSet::new();
        let huge: HashSet<i32> = (0..1_000).collect();
        for (left, right) in &[(&tiny, &huge), (&huge, &tiny)] {
            let sys_time = std::time::SystemTime::now();
            assert_eq!(left.intersection(right).count(), 0);
            let elapsed = sys_time.elapsed().unwrap();
            println!(
                "{:9}ns starting from {:4} element set",
                elapsed.subsec_nanos(),
                left.len(),
            );
        }
    }
    

    当使用 Rust 的 1.32 或更早版本而不是当前版本运行时,表明您确实想在两个集合中的较小者上调用交集方法(即使在一个集合为空的边界情况下)。通过调用此函数而不是交集方法,我获得了不错的性能提升:

    fn smart_intersect<'a, T, S>(
        s1: &'a HashSet<T, S>,
        s2: &'a HashSet<T, S>,
    ) -> std::collections::hash_set::Intersection<'a, T, S>
    where
        T: Eq + std::hash::Hash,
        S: std::hash::BuildHasher,
    {
        if s1.len() < s2.len() {
            s1.intersection(s2)
        } else {
            s2.intersection(s1)
        }
    }
    

    Python 中的方法平等对待这两个集合(至少在 3.7 版本中)。

    PS 为什么会这样? 假设小集合 Sa 有 A 项,大集合 Sb 有 B 项,散列一个键需要 Th 时间,在具有 X 个元素的集合中定位散列键需要 Tl(X) 时间。那么:

    • Sa.intersection(&amp;Sb) 花费 A * (Th + Tl(B))
    • Sb.intersection(&amp;Sa) 成本 B * (Th + Tl(A))

    假设哈希函数很好并且桶有很多(因为如果我们担心交集的性能,所以我们应该确保集合是有效的)那么 Tl(B) 应该与Tl(A) 或至少 Tl(X) 的缩放比例应该远小于线性设置大小。因此,决定操作成本的是 A 与 B。

    PS is_disjoint 存在相同的问题和解决方法,union 也存在一些问题(复制大集合并添加一些元素比复制小集合并添加很多元素更便宜,但是不是很大)。 A pull request 已被合并,因此自 Rust 1.35 以来这种差异已经消失。

    【讨论】:

    • 你不妨看看是否可以为此向标准库提交 PR。似乎对每个人来说都是一个足够安全的改变。
    • 也许人们会反对那些预先知道左集较小的人的成本,或者更糟糕的是,他们知道它更大但由于散列函数慢而需要左集存在,或者别的东西?无论如何,至少它应该被记录在案,而现在它不是(我看过的地方)。
    • 仔细检查of the code,似乎并没有考虑太多,因此尝试提供修复和错误报告。
    • 顺便说一下,Scala 中的默认 Set 也有同样的性能怪癖:你需要先放小集合。
    猜你喜欢
    • 2021-03-30
    • 2020-08-16
    • 1970-01-01
    • 2013-04-23
    • 2019-04-12
    • 2014-12-24
    • 2014-06-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多