Rust: 十亿行数据挑战,从 5 分钟到 9 秒的历程

推荐:

原文:十亿行数据挑战:Rust请求出战( 5 分钟到 9 秒的历程)
原创 讳疾忌医 2024年12月25日 08:00 广东

本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身

导读

本篇文章将讲述我应对十亿行数据挑战(1BRC)的历程以及在此过程中收获的感悟。我最初的执行时间长达 5 分钟,后来成功将其缩减到了 9 秒 。没错——速度提升了超过 33 倍呢!但数字只是故事的一部分,真正的精彩在于学习、优化以及过程中的那些精彩时刻。

什么是十亿行数据挑战?

> 输入:一个 14GB 的文本文件,其中包含来自各个气象站的十亿行温度测量数据。每行的格式为 <字符串:站名>;<双精度数:测量值>> 
> 输出:一个按字母顺序排序的城市列表,附带其温度统计信息,格式为:{城市 1=<最小值>/<平均值>/<最大值>, 城市
> 2=<最小值>/<平均值>/<最大值>, …}

首次尝试

应对这个挑战的首次尝试采用了最朴素的方法。其工作原理如下:

• 将整个文件读入一个 String 类型变量。
• 遍历新的行:
• 解析该行以获取站名和数值。
• 将此项添加到 HashMap 中。
• 如果条目已存在,则更新相应的值。
• 将数据转移到 BTreeMap 以生成排序后的输出。

use std::collections::HashMap;
struct StationValues{
    min:f32,
    max:f32,
    mean:f32,
    count:u32,
}

// 解析行,提取站名和数值
fn read_line(data:String)->(String,f32){
    let parts:Vec<&str>= data.split(';').collect();
    let station_name= parts[0].to_string();
    let value= parts[1].parse::<f32>().expect("Failed to parse value");
    (station_name, value)
}

// 计算站点数值
fn calculate_station_values(data:String)->HashMap<String,StationValues>{
    let mut result:HashMap<String,StationValues> = HashMap::new();
    for line in data.lines(){
    let line= line.trim();
    let(station_name, value)=read_line(line.to_string());
    result
    .entry(station_name)
    .and_modify(|e|{/*... */})
    .or_insert(StationValues{/*... */});
    }
    result
}

• 执行时间的 45% 花费在 read_line 函数中。
• 在 read_line 函数内部:
• collect() 占 22%。
• parse() 占 10%。
• to_string() 占 4%。
分析表明 collect() 调用会触发 malloc 系统调用。这意味着,对于数十亿行数据中的每一行,我们都在堆上分配内存,处理完后又立即丢弃这些内存。这种不必要的分配和释放似乎是一个性能瓶颈。

我使用向量(vector)的原因是为了能通过索引轻松访问站名及其数值。然而,我们可以更高效地做法:

• 不用将 split() 的结果收集到向量中,而是可以直接使用它返回的迭代器。
• 使用 next() 来访问元素,这样完全避免了分配内存。这一认识为我们的下一次优化尝试奠定了基础。

第二步:避免冗余的向量创建(241 秒)

在这次迭代中,我着重于去除行解析过程中不必要的内存分配。用更高效的基于迭代器的 next() 方法取代基于向量的解析方式,使我们的执行时间减少了 12 秒。

fn read_line(data:String)->(String,f32){
    let mut parts= data.split(';');
    let station_name= parts.next().expect("error");
    let value= parts.next().expect("error").parse::<f32>().expect("error");
    (station_name.to_owned(), value)
}

验证了 read_line 函数现在仅占用 35% 的执行时间,相较于上一次迭代的 45% 有所下降。

当前实现

fn read_line(data:String)->(String,f32){
    let mut parts= data.split(';');
    let station_name= parts.next().expect("error");
    let value= parts.next().expect("error").parse::<f32>().expect("error");
    (station_name.to_owned(), value)
}
fn calculate_station_values(data:String)->HashMap<String,StationValues>{
    for line in data.lines(){
        let(station_name, value)= read_line(line.to_string());//←问题??
        //... 处理数据...
    }
}
    
fn main(){
    let data= std::fs::read_to_string(args.file).expect("error");//←问题??
    let result=calculate_station_values(data);
    //... 输出结果...
}

新观察与假设
• read_to_string 占用执行时间的 1.05%。
• to_string 占执行时间的 8%。

潜在问题
• 大内存分配:read_to_string 会分配与文件大小相等的内存(14GB)。这种大量的内存分配效率低下,在内存有限的系统上可能不可行。
• 字符串到字符串切片(&str)的转换:lines() 方法返回字符串切片(&str),但我们通常需要拥有所有权的 String 来进行操作。这就需要调用 to_string(),从而导致更多的内存分配。

下一步措施
为解决这些问题,应该考虑:

• 以更小的块来读取文件,以减少内存使用。
• 使用能直接提供拥有所有权的字符串的接口,避免 to_string() 转换。BufReader 似乎是解决这两个问题的一个很有用的方案。它允许高效的分块读取,并且可以配置为直接处理拥有所有权的字符串。

第三步:使用 BufReader(137 秒)

在这次迭代中,我们用 BufReader 替换了 read_to_string,使得执行时间减少了将近 100 秒。这一改变验证了我们关于 BufReader 在处理大文件时效率较高的假设。用下面这条 Reddit 评论来解释就是:

系统调用涉及做大量准备工作,以便将控制权交给操作系统,使其能够执行读取操作。无论要读取的数据量是多少,这部分准备工作都是一样的。read_to_string 在系统调用次数方面效率很高,但它有个缺点,就是会产生大量不必要的内存使用,并且每行都需要重复分配拥有所有权的 String,之后又将其丢弃。

BufReader 的目的是在不过度使用内存或进行过多系统调用之间找到一个平衡点。BufReader 使得系统调用每次总是能读取大量数据,即便你只请求少量数据时也是如此,这样当你请求下一小部分数据时,就不需要再重复做那些准备工作了。

第四步:更快的浮点数解析(134 秒)

使用 fast-float 库将字符串解析为浮点数帮助我们将执行时间减少了 4 秒。虽然改进不是很大,但有总比没有好呀 😃

有趣的是,在查看 fast-float 的 GitHub 仓库时,我发现这个仓库中使用的技术现在已经合并到标准库核心的 parse 方法中了。那为什么我们还能获得这额外 4 秒的时间减少呢? 下面这条评论解释了原因: 为什么实际案例的基准测试更青睐核心库,而单个浮点数测试却更倾向于 fast-float-rust 呢?这可能是因为 fast-float-rust 中的内联优化非常激进。

第五步:更好的哈希算法(123 秒)

这次改进纯粹是运气好,再加上查阅手册(RTFM)的技能。在阅读 Rust 浮点数解析相关内容时,我偶然看到了哈希(Hashing)页面。这引导我去阅读 Rust HashMap 的手册,其中提到:

当前默认的哈希算法是 SipHash 1 - 3,不过未来这个可能会改变。虽然对于中等长度的键,它的性能很有竞争力,但对于像整数这样的小键以及像长字符串这样的大键,其他哈希算法的性能会更优,不过那些算法通常无法防范诸如哈希拒绝服务攻击(HashDoS)之类的攻击。

SipHash 1 - 3 算法质量很高——它能很好地防止冲突——但相对较慢,特别是对于像整数这样的短键而言。《Rust 性能手册》建议使用 rustc_hash 库中的 FxHashMap,最后我就采用了它。

这使得执行时间减少了 11 秒。看起来 Rust 更倾向于安全性而非性能呀。

第六步:在 BufReader 上使用 read_line(105 秒)

BufReader 的 read_line 方法会读取字节,直到遇到换行符(\n,即 0xA 字节),然后将这些字节添加到提供的字符串缓冲区中。这帮助我们减少了 18 秒的时间。这种解决方案的不足之处在于我们必须手动管理缓冲区,但我们这里追求的不是操作的便捷性呀。

fn calculate_station_values(reader:&mut BufReader<File>)->FxHashMap<String,StationValues>{
    let mut buf=String::new();
    
    while let Ok(bytes_read)= reader.read_line(&mut buf){
        if bytes_read ==0{
        break;
        }
        let line= buf.trim();
        let (station_name, value) = read_line(line);
        //....
        buf.clear();
        //....
    }
    //....
}

处理缓冲区让我意识到到目前为止我们一直在和字符串打交道。而 Rust 中的字符串是 UTF - 8 编码的——这意味着每次读取字符串时我们都要承担验证的开销。既然我们知道正在处理的文件是一个有效的 UTF - 8 编码文件,我想我们可以完全省去这个验证检查。思路就是,不将数据存储在字符串中,而是直接使用字节!

第七步:使用字节而非字符串(83 秒)

BufReader 有一个 read_until 方法,它接受一个字节限定符(\n)和一个可变的字节向量(&mut Vec)缓冲区。读取器会读取文件,并只要我们持续调用这个方法,就会不断将数据推入缓冲区。我们还将哈希表中站名的类型改为字节向量(Vec),而不是字符串(String)。这两个改变有效地从代码中移除了字符串。

这些改变让我们取得了显著的成效,执行时间减少了将近 22 秒。

fn calculate_station_values(reader:&mut BufReader<File>)->FxHashMap<Vec<u8>,StationValues>{
    let mut result:FxHashMap<Vec<u8>,StationValues>=FxHashMap::default();
    let mut buf =Vec::new();
    
    while let Ok(bytes_read)= reader.read_until(b'\n',&mut buf){
        if bytes_read ==0{
        break;
        }
        // 移除换行符
        buf.truncate(bytes_read -1);
        // read_line 会分配一个新向量来存储站名
        let(station_name, value)=read_line(&buf);
            buf.clear()
        }
        //...
}
    
fn read_line(data:&Vec<u8>)->(Vec<u8>,f32){
    let mut parts= data.rsplit(|&c| c ==b';');
    //....
    (station_name.to_vec(), value)
}

第八步:内存映射(78 秒)

memmap2 库为我们提供了一个访问文件的良好接口,并给我们一个 &[u8] 引用用于处理。在这一步,我们将使用流式访问(BufReader)读取文件的方式替换为使用随机访问(mmap)的方式。这样我们就把相关责任转交给操作系统,让它去处理分页机制等问题。

内存映射帮助我们将执行时间减少了 5 秒。Mmap 还有助于避免任何额外的内存分配。

fn calculate_station_values(data:&[u8])->FxHashMap<&[u8],StationValues>{
    //....
}
    
fn read_line(data:&[u8])->(&[u8],f32){
    let mut parts= data.rsplit(|&c| c ==b';')
    //...
}
    
fn main(){
    let file = std::fs::File::open(&args.file).expect("Failed to open file");
    let  mmap = unsafe{Mmap::map(&file).expect("Failed to map file")};
    let data=&*mmap;
    let result=calculate_station_values(data);
    //....
}

我们在 calculate_station_values 和 read_line 中使用的 split 函数分别占用了大约 13%和 11%的执行时间。这些地方是我们下一个优化目标。

第九步:使用 SIMD 优化字节搜索(71 秒)

在这一步,我们涉足 SIMD(单指令多数据)领域来优化字节搜索过程。我们不再使用 split() 来获取行的迭代器,而是循环遍历字节,并使用经过高度优化的 memchr 库来搜索换行符。这帮助我们减少了 7 秒的时间。

// 例如:data 看起来像 "Hamburg;19.8\n"
fn calculate_station_values(data:&[u8])->FxHashMap<&[u8],StationValues>{
    let mut result:FxHashMap<&[u8],StationValues> = FxHashMap::default();
    let mut buffer= data;
    loop{
        matchmemchr(b';',&buffer){
            None => {break;}
            Some(comma_seperator) => {
                let end = memchr(b'\n',&buffer[comma_seperator..]).unwrap();
                let name=&buffer[..comma_seperator];
                let value=&buffer[comma_seperator+1..comma_seperator+end];
                let value = fast_float::parse(value).expect("Failed to parse value");
                //....
                buffer =&buffer[comma_seperator+end+1..];
            }
    
        }
    }
}

下一步
• 处理不安全的 mmap 代码以提高安全性。
• 实现并行化以利用多核处理器。
这些最终的优化应该能帮助我们榨干最后一点性能!

第十步:代码并行化 - 第一部分(12 秒)

执行时间现在已经降到了 12 秒。这个解决方案快了将近 6 倍。我们实现了两个主要的改变,从而提高了性能。

文件的流式访问

我们用流式访问取代了内存映射,这使得我们能够在保持高效字节级访问的同时消除不安全代码。

loop {
    let bytes_read= file.read(&mut buf[..]).expect("Failed to read file");
    // println!("bytes_Read {:?}", bytes_read);
    if bytes_read ==0{
        break;
    }
    //.....
}

这种方法利用了 File 实现的 Read trait 的 read 方法,允许我们直接读取到一个 &mut [u8] 缓冲区中。这个方法避免了每行的冗余分配,同时提供了对文件内容的字节级访问。

使用生产者 - 消费者模型进行并行处理

我们使用 crossbeam_channel 库实现了单生产者、多消费者架构。
我们有一个单一的生产者(发送者)负责读取文件并生成数据块。每个数据块是文件中完整行的一个子集,最大大小为 128KiB。

然后我们创建消费者(接收者)(数量与系统的 CPU 数量相等),每个消费者维护自己的站点计算值映射,用于处理它们接收到的数据。

最后,我们合并所有消费者的结果来计算最终值。

最终架构

数据块处理策略我们使用两种类型的缓冲区来确保我们总是处理完整的行:

• 读缓冲区:存储直接从文件获取的数据。
• 未处理缓冲区:存储无法包含在前一个数据块中的不完整行。
它的工作原理如下: 如果一次读取不以换行符结尾,我们在缓冲区中找到最后一个换行符。直到那个换行符的数据被发送到接收线程。剩余的数据存储在未处理缓冲区中。

在下一次读取时,我们在处理之前将未处理缓冲区的数据前置到新数据之前。这种策略确保无论文件读取与行结尾如何对齐,只有完整的行才会被发送到接收线程。

第十步:代码并行化 - 第二部分(9 秒)

我们终于实现了个位数的执行时间。我们已经消除了单独的“未处理缓冲区”,现在所有操作都只使用“读缓冲区”:

数据直接读入读缓冲区。 如果创建数据块后仍有未处理的数据: - 这些数据被移到缓冲区的开头。 - 剩余空间用从文件读取的新数据填充。

假设和边界情况 我们假设如果存在未处理的数据,下一次读取将包含换行符。鉴于以下情况,这个假设是合理的:

• 我们已知的输入格式(短行)
• 较大的缓冲区大小(128KiB)
如果在读取未处理数据后没有找到换行符,我们将触发 panic,认为这是文件格式有问题的迹象。

关键要点:
• 使用 --release 标志优化构建。
• 避免在关键路径中使用 println!;使用日志库进行调试。
• 谨慎使用 FromIterator::collect();它会触发新的内存分配。
• 尽量减少不必要的分配,尤其是 to_owned() 和 clone()。
• 对于大型文件,优先选择缓冲读取而不是一次性加载整个文件。
• 在不需要 UTF - 8 验证时,使用字节切片([u8])而不是字符串。
• 在优化单线程性能之后再进行并行化。

参考文献: 《Tackling the 1 Billion Row Challenge in Rust, A Journey from 5 Minutes to 9 Seconds》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值