为什么Python设置的交集比Rust HashSet交集更快?

时间:2016-02-16 17:38:19

标签: python rust hashset

这是我的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。

3 个答案:

答案 0 :(得分:14)

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

我只是在阅读Python 3 (setobject.c),但Python的实现还有一些功能。

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

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

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

答案 1 :(得分:12)

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

一些分析显示make_hash<i32, std::collections::hash::map::RandomState>占总运行时间的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);
}

在我的机器上,与Python的9.203相比,这需要2.714秒。

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

答案 2 :(得分:4)

除了散列之外,当您以错误的方式相交一个微小的对象时,Python会超越Rust的早期版本。例如。此code on playground

XrmServiceToolkit.Rest.RetrieveMultiple("ContactSet", "?$select=Fax,FirstName&$expand=contact_customer_accounts&$filter=contact_customer_accounts/AccountId eq (guid'7DD7EE05-FC52-E811-A960-000D3A1A941E')", function(results) {
    for (var i = 0; i < results.length; i++) {
        var fax = results[i].Fax;
        var firstName = results[i].FirstName;
    }
}, function(error) {
    Xrm.Utility.alertDialog(error.message);
}, function() {
    //On Complete - Do Something
}, true);

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

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(),
        );
    }
}

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

PS为什么会这样? 假设小集合S1有A个项,大集合S2有B个项,则散列一个键需要Th时间,散列键具有X个元素的时间需要Tl(X)时间。然后:

  • 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) } } 花费A *(Th + Tl(B))
  • S1.intersection(&S2)的成本为B *(Th + Tl(A))

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

PS S2.intersection(&S1)的问题和解决方法相同,is_disjoint也有点问题(复制大集合并添加一些元素要比复制小集合并添加一个元素要便宜很多,但不是很大)。 A pull request被合并,因此这种差异将随着时间的流逝而消失。