65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.11/5 (4投票s)

2023 年 1 月 5 日

CPOL

6分钟阅读

viewsIcon

15142

downloadIcon

60

看看 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::Readstd::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 可能是一个好主意。然后我们只需循环遍历从 0char_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 日:初始版本
© . All rights reserved.