Duck a Fourth Way:用 Rust 编写的文件和文本 I/O 基准测试






4.11/5 (4投票s)
看看 Rust 与 C-like、C++ 和 C# 相比如何
引言
本文介绍了一个 Rust 程序,并将其性能与用 C#、C 和 C++ 编写的类似程序进行了比较。
我不好意思承认,但这是我的第一个 Rust 程序。我一直在学习 O'Reilly 的《Programming Rust, 2nd Edition》这本书。这本书写得很好,也很长。一旦你克服了 Rust 最初的“这是什么!?”的困惑,就变成了“我知道如何在 C 中做这件事…… Rust 中的调用是什么?”所以我就直接上手了。我相信我还有一些主要的盲点,我认为我们都还在学习 Rust……
我知道如何编写这个文件和文本 I/O 基准测试:这是文章。简而言之,你有一个 90 MB 的 Unicode CSV 文件,将其读取到对象中(全部 10 万个),然后将对象写回 Unicode CSV 文件。
让我们来看一些 Rust 代码!
但等等,先来看性能数据!我的系统上的 Rust 程序读取需要 117 毫秒,写入需要 195 毫秒。在读取方面,它与 C-like 非常接近,后者只需要 107 毫秒,而在写入方面则不太好,C-like 需要 147 毫秒,C++ 只需要 136 毫秒。请继续阅读,了解为什么 Rust 的写入代码可能较慢。
Rust 程序源代码
所有源代码都在一个文件中,main.rs。
源代码头部
脚本以常见的命名空间辅助函数开始
use stopwatch::Stopwatch;
use std::fs::File;
use std::io::Read;
use std::io::Write;
Stopwatch
是一个第三方类,足够像 .NET 的 Stopwatch
,在这里可以用得上。
"use std::fs::File
" 使 File
类可用。你也可以说 "use std::fs::*
" 来导入整个命名空间。
std::io::Read
和 std::io::Write
实际上并不是类,但如果你想进行任何读取或写入操作,就需要使用它们。
数据记录
这是我们将从 CSV 加载并写回 CSV 的对象的类型。
// Our demographics data record
struct LineData<'a> {
first: &'a str,
last: &'a str,
address1: &'a str,
address2: &'a str,
city: &'a str,
state: &'a str,
zip: &'a str
}
'a
的内容是一种表示法,即 struct
的内容旨在与 struct
本身具有相同的生命周期。这是生命周期管理,是 Rust 的一些“这是什么!?!”之处。不多说,处理 LineData
对象的代码不能比它们本身更长,它们必须具有相同的生命周期。这可以防止“读后释放”的 bug。
& str
不是 .NET 中的 String
或 C++ 中的 std::wstring
,它是一个指向字符缓冲区的引用,本质上是一个格式良好的 const wchar_t*
。这才是让这个程序真正飞起来的原因。想象一下 C 中的一个 struct
,其中包含指向未知位置的原始字符指针。这会很高效,但也会非常可怕,对吧?嗯,Rust 已经实现了这一点,对于这类事情来说,它确实是真正的解决方案。
Unicode 字节转字符串
从我们之前尝试这个基准测试的经验来看,将整个文件读入内存是一个不错的开始。一旦我们有了所有这些字节,在这次尝试中,我们想将其转换为一个 String
对象以供以后处理。
// Turn a buffer of Unicode bytes into a String
fn utf16le_buffer_to_string(buffer: &[u8]) -> String {
let char_len = buffer.len() / 2;
let mut output = String::with_capacity(char_len);
for idx in 0..char_len {
output.push(char::from_u32(u16::from_le_bytes
([buffer[idx * 2], buffer[idx * 2 + 1]]) as u32).unwrap());
}
output
}
缓冲区输入参数是 &[u8]
,它是一个 u8
数组(字节)的引用。预先分配 String
可能是一个好主意。然后我们只需循环遍历从 0
到 char_len
- 1
的索引范围,进行一些 Unicode 操作。循环后面的未加修饰的 "output
" 是返回值,我知道这有点奇怪。
main() 开始
还有比简单直观的 main()
函数更完整的基准测试应用程序吗?
fn main() {
// Deal with inputs
let args: Vec<String> = std::env::args().collect();
if args.len() != 3 {
println!("Usage: {} <input file> <output file>", args[0]);
std::process::exit(0);
}
let input_file_path = &args[1];
let output_file_path = &args[2];
println!("{} -> {}", input_file_path, output_file_path);
我们将原始的 str std::env::args()
数组转换为更容易处理的 Vec<String> (std::vector<std::wstring>)
。&args[x]
允许 input_file_path
/ output_file_path
变量在不修改任何内容的情况下引用参数。Rust 中有很多 &
,起初看起来很吓人,到处都是指针地址,确实也是,但它是安全的。这就是 Rust 的大赌注,你会被 &
和其他咒语,如 "mut
" 所困扰,然后你就会信任编译器来保证内存的正确性,一切都会好起来的。println!
就像 printf
,其中的 {}
占位符就像 %
,但类型说明符更少。
秒表计时
// Timing is fun...look familiar?
let mut sw = Stopwatch::start_new();
let mut cur_ms : i64;
let mut total_read_ms : i64 = 0;
"mut
" 业务表明你想要一个对象的读写引用。没有 "mut
",就无法进行修改,这有点像 const
。
输入文件 I/O:文件 -> 缓冲区
// Read the input file into a buffer
sw.restart();
let mut buffer = Vec::new();
File::open(input_file_path).unwrap().read_to_end(&mut buffer).unwrap();
cur_ms = sw.elapsed_ms();
total_read_ms += cur_ms;
println!("buffer: {} - {} ms", buffer.len(), cur_ms);
我们创建一个新的向量,不必指定元素类型,编译器会自己推断出来。一行代码,我们将文件读入向量……字节,一定是字节。&mut
意味着我们将一个读写引用传递给 read_to_end()
(他们称之为借用),以便它可以修改我们的向量。
输入文本 I/O:缓冲区 > 字符串
// Read the buffer into a string
let str_val = utf16le_buffer_to_string(&buffer);
在这里,我们使用上面为此目的定义的函数,传入我们字节向量的引用。
对象输入 I/O:字符串 > 对象
let mut objects = Vec::new();
let mut parts: [&str; 7] = ["", "", "", "", "", "", ""];
let field_len = parts.len();
let mut idx: usize;
for line in str_val.lines() { // walk the lines
idx = 0;
for part in line.split(',') { // walk the comma-delimited parts
assert!(idx < field_len);
parts[idx] = part;
idx = idx + 1;
}
if idx == 0 { // skip blank lines
continue;
}
assert_eq!(idx, parts.len());
objects.push
(
LineData {
first: parts[0],
last: parts[1],
address1: parts[2],
address2: parts[3],
city: parts[4],
state: parts[5],
zip: parts[6]
}
);
}
我不得不稍微优化了一下代码,让它飞起来。Vec
对象是我们要收集记录的地方。数组部分包含七个 str
引用,对应我们记录类型中的每个字段,是一个 const wchar_t*
的小数组。在循环中,我们遍历行并将 string
收集到对象中。想象一下字符指针从 lines()
和 split()
调用中出来,进入 parts 数组,然后进入我们的记录。根本没有 string
复制,只是在数据结构之间移动文本。太棒了!
读取完成
这是读取部分的剖析
buffer: 90316528 - 29 ms
str_val: 45158266 - 63 ms
objects: 100000 - 25 ms
total read: 117 ms
飞快!
对象输出 I/O:对象 -> 字符串
// Compute a big string containing all the records
let mut big_str: String = String::with_capacity(str_val.len());
for obj in objects {
big_str += obj.first;
big_str += ",";
big_str += obj.last;
big_str += ",";
big_str += obj.address1;
big_str += ",";
big_str += obj.address2;
big_str += ",";
big_str += obj.city;
big_str += ",";
big_str += obj.state;
big_str += ",";
big_str += obj.zip;
big_str += "\n";
}
这与相对较快的 C++ 基准测试应用程序的输出代码相匹配。
字符串输出 I/O:字符串 -> 缓冲区
// Turn the big string into a vector of Unicode 16-bit values
let big_char_buffer = big_str.encode_utf16(); // this takes no time at all
// Turn the vector of 16-bits values into a vector of bytes
let mut big_output_buffer = Vec::<u8>::with_capacity(big_str.len() * 2);
for c in big_char_buffer {
big_output_buffer.push(c as u8);
big_output_buffer.push((c >> 8) as u8);
}</u8>
在 2023 年编写循环感觉像是 2000 年代的事情;我找不到现成的。但应该有办法。
文件输出 I/O:缓冲区 -> 文件
File::create(output_file_path).unwrap().write_all(&big_output_buffer);
又一个有趣的文件 I/O 单行代码。
剖析(糟糕的)输出性能
那些有趣的 Stopwatch
代码为我们提供了关于时间花费在输出方面的信息。
big_str: 12 ms
big_char_buffer: 0 ms
big_output_buffer: 60 ms
output_file: 123 ms
total write: 195 m
big_str
看起来没问题。big_char_buffer
出奇地节俭。但是 big_output_buffer
,我们将所有那些 u16
转换成 u8
,这花费了很多。实际上,这与读取缓冲区到 string
的成本大致相同。在 C/C++ 中,你可以接受一个 wchar_t*
然后说它是一个 uint8_t*
,然后, presto!它就是字节了!Rust 不喜欢那种轻率的内存魔术。而且在 Rust 中,string
以 UTF-8 存储在内存中,所以你不能像在 C/C++ 中那样随意转换。UTF-8 似乎是互联网的通用语言,但世界上有一半的人更适合使用 16 位或 32 位编码,所以我不太理解这个语言选择,它缺乏色彩且效率低下。嗯。
结论和关注点
我希望你通过这次介绍对 Rust 有了足够的了解,并对其产生浓厚的兴趣。性能基准测试对比显示 Rust 在读取数据时展现了其强大的实力,但在写入数据时似乎有些疲惫。这次基准测试暴露了它的优点和缺点;总的来说,我认为它足以用于我未来的项目。
我对你对 Rust 代码的第一印象以及你对编程、测量基准测试和解释结果的看法很感兴趣。
历史
- 2023 年 1 月 5 日:初始版本