Skip to content

Latest commit

 

History

History
442 lines (351 loc) · 19.1 KB

11.Rust-and-CSV-parsing-part8.md

File metadata and controls

442 lines (351 loc) · 19.1 KB

性能(Performance)

在本节中,我们将讨论如何最大限度地利用 CSV reader。实际上,到目前为止,我们所看到的大多数 api 在设计时都考虑到了高度的易用性,而这通常需要付出一些代价。大多数情况下,这些代价就包含一些不必要的分配。因此,本节的大部分内容将展示如何使用尽可能少的分配来进行 CSV 解析。

有两个必须涉及且比较关键的先决条件。

首先,当你关心性能时,你应该使用 cargo build --release 而不是 cargo build 来编译代码。--release 标志表示编译器花一些时间优化你的代码。当使用 --release 标志编译时,你会发现编译后的程序在 target/release/csvtutor 而不是 target/debug/csvtutor 。在本教程中,我们使用 cargo build 构建,因为我们的数据集很小,我们不关注速度。crate 构建时使用 --release 的缺点是,花费更长的编译时间。

其次,我们前面使用的数据集只有 100 条记录。我们必须非常努力地,即使在没有 --release 标志的情况下编译,使程序在 100 条记录上运行地慢一些。因此,为了真正见证性能差异,我们需要更大的数据集。为了获得这样的数据集,我们将使用 uspop.csv 的原始源。警告:下载 41MB 解压后将有 145MB

$ curl -LO https://burntsushi.net/stuff/worldcitiespop.csv.gz
$ gunzip worldcitiespop.csv.gz
$ wc worldcitiespop.csv
  3173959   5681543 151492068 worldcitiespop.csv
$ md5sum worldcitiespop.csv
6198bd180b6d6586626ecbf044c1cca5  worldcitiespop.csvshell

最后,需要指出的是,本节并不是要提供一套严格的基准。我们将不会很严格地分析,而更多地依靠钟表时间和直觉。

摊销分配(Amortizing allocations)

为了衡量性能,我们必须关注我们所衡量的指标是什么。在改进代码时,我们还必须注意变量和不变量。出于这个原因,我们将重点测量需要多长时间来统计与马萨诸塞州城市人口统计相对应的记录数量。这表示需要我们访问每条记录的工作量非常小,因此这是衡量执行 CSV 解析所需时间的一种不错的方法。

在开始我们的第一个优化之前,让我们从一个基础开始,通过调整之前的基础示例来计算 worldcitiespop.csv 中的记录数量:

extern crate csv;

use std::error::Error;
use std::io;
use std::process;

fn run() -> Result<u64, Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());

    let mut count = 0;
    for result in rdr.records() {
        let record = result?;
        if &record[0] == "us" && &record[3] == "MA" {
            count += 1;
        }
    }
    Ok(count)
}

fn main() {
    match run() {
        Ok(count) => {
            println!("{}", count);
        }
        Err(err) => {
            println!("{}", err);
            process::exit(1);
        }
    }
}

现在,我们编译并运行它,看看我们使用了多少时间。不要忘记使用 --release 标志进行编译。(嘿嘿,尝试编译不带 --release 标志,看看运行程序需要多长时间!)

$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176

real    0m0.645s
user    0m0.627s
sys     0m0.017s

好了,我们要怎么做才能加快速度呢?本节承诺通过摊销分配来加快速度,但我们可以先做一些更简单的事情:迭代 ByteRecords 而非 StringRecords。如果您还记得上一节,StringRecord 保证是有效的 UTF-8,因此必须校验它的内容是否是 UTF-8。(如果验证失败,那么CSV读取器将返回一个错误。)如果我们从我们的程序中移除校验代码,那么我们可以实现一个很好的性能提升,如下面的例子所示:

fn run() -> Result<u64, Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());

    let mut count = 0;
    for result in rdr.byte_records() {
        let record = result?;
        if &record[0] == b"us" && &record[3] == b"MA" {
            count += 1;
        }
    }
    Ok(count)
}

编译并运行:

$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176

real    0m0.429s
user    0m0.403s
sys     0m0.023s

我们的程序现在大约快了 30%,这都是因为我们删除了 UTF-8 验证。但是,删除 UTF-8 验证真的可以吗?我们失去了什么?在这种情况下,删除 UTF-8 校验并使用 ByteRecord 是完全可以接受的,因为我们对记录所做的只是将其两个字段与原始字节进行比较:

if &record[0] == b"us" && &record[3] == b"MA" {
    count += 1;
}

特别是,记录是否是有效的 UTF-8 并不重要,因为我们只是检查它和特定原始字节是否相等。

通过 StringRecord 进行 UTF-8 校验很有用,因为它提供了对 &str 类型字段的访问,而 ByteRecord 提供了 &[u8] 类型字段。&str 是 Rust 中借用的字符串类型,它提供了对字符串便捷访问的 api,比如子字符串搜索。 String 也经常用于其他领域,因此它很有用。因此,坚持使用 StringRecord 是一个很好的默认方式,但是如果您需要额外的速度并且可以处理任意字节,那么切换到 ByteRecord 可能会更好。

接下来,让我们通过摊销分配(amortizing allocation)来提高速度。摊销分配是一种技术,它只需一次(或很少)分配,然后复用它,而不是创建额外的分配。在前面的例子中,我们使用了由 CSV reader 上的 records 和 byte_records 方法创建的迭代器。这些迭代器为它迭代的每一项分配新的空间,一条记录对应于一次分配。之所以这样做,是因为迭代过程中不能对迭代器中的项进行借用,此外,创建新的分配往往更方便。

如果我们愿意放弃使用迭代器,那么可以通过创建单个 ByteRecord 并要求 CSV reader 读入它来摊销分配。我们通过使用 Reader::read_byte_record 方法来实现这一点。

fn run() -> Result<u64, Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());
    let mut record = csv::ByteRecord::new();

    let mut count = 0;
    while rdr.read_byte_record(&mut record)? {
        if &record[0] == b"us" && &record[3] == b"MA" {
            count += 1;
        }
    }
    Ok(count)
}

编译并运行:

$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176

real    0m0.308s
user    0m0.283s
sys     0m0.023s

哦吼!这比前一个例子又提升了 30%,比第一个例子提升了 50%。

让我们通过查看 read_byte_record 方法签名来分析这段代码:

fn read_byte_record(&mut self, record: &mut ByteRecord) -> csv::Result<bool>;

该方法接受一个 CSV reader(self 参数)和一个可变的 ByteRecord 借用作为入参,并返回 csv::Result<bool>。(当且仅当读取记录,返回值为 true 时 csv::Result<bool> 等价于 Result<bool, csv::Error>)。当它为 false 时,这意味着 reader 已经耗尽了输入。该方法通过将下一条记录的内容复制到提供的 ByteRecord 中来处理。由于使用同一个 ByteRecord 读取每条记录,因此它已经为数据分配了空间。当调用 read_byte_record 时,它将用新记录覆盖原来存在的空间,这意味着它可以复用已分配的空间。因此,我们平摊了分配。

你可以考虑使用 StringRecord 而不是 ByteRecord,因此 Reader::read_record 而不是 read_byte_record。这将使您以校验 UTF-8 的代价轻松访问 Rust 字符串,但不需要为每条记录分配一个新的 StringRecord。

Serde 和零拷贝

在这一节中,我们将简要研究如何使用 Serde 以及如何提高它的速度。我们要做的关键优化是,你猜对了,是摊销分配。

和上一节一样,让我们从一个基于上一节中使用 Serde 的简单基础例子开始:

extern crate csv;
extern crate serde;
 #[macro_use]
extern crate serde_derive;

use std::error::Error;
use std::io;
use std::process;

 #[derive(Debug, Deserialize)]
 #[serde(rename_all = "PascalCase")]
struct Record {
    country: String,
    city: String,
    accent_city: String,
    region: String,
    population: Option<u64>,
    latitude: f64,
    longitude: f64,
}

fn run() -> Result<u64, Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());

    let mut count = 0;
    for result in rdr.deserialize() {
        let record: Record = result?;
        if record.country == "us" && record.region == "MA" {
            count += 1;
        }
    }
    Ok(count)
}

fn main() {
    match run() {
        Ok(count) => {
            println!("{}", count);
        }
        Err(err) => {
            println!("{}", err);
            process::exit(1);
        }
    }
}

编译并运行:

$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176

real    0m1.381s
user    0m1.367s
sys     0m0.013s

你可能会注意到的第一件事是,这比我们在前一节中的程序要慢得多。这是因为反序列化每条记录都有一定的开销。特别是,一些字段需要被解析为整数或浮点数,这些都有开销。然而,还有希望,因为我们可以优化!

我们加快程序的第一个尝试将是摊销分配。使用 Serde 完成这个操作比以前要复杂一些,因为我们需要更改我们的 Record 类型并使用手动反序列化 API。让我们看看它是什么样的:

#[derive(Debug, Deserialize)]
 #[serde(rename_all = "PascalCase")]
struct Record<'a> {
    country: &'a str,
    city: &'a str,
    accent_city: &'a str,
    region: &'a str,
    population: Option<u64>,
    latitude: f64,
    longitude: f64,
}

fn run() -> Result<u64, Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());
    let mut raw_record = csv::StringRecord::new();
    let headers = rdr.headers()?.clone();

    let mut count = 0;
    while rdr.read_record(&mut raw_record)? {
        let record: Record = raw_record.deserialize(Some(&headers))?;
        if record.country == "us" && record.region == "MA" {
            count += 1;
        }
    }
    Ok(count)
}

编译并运行:

$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176

real    0m1.055s
user    0m1.040s
sys     0m0.013s

这相当于性能提高了大约 24%。为了实现这一点,我们必须做两个重大的改变。

第一个是让我们的 Record 类型包含 &str 字段,而不是 String 字段。如果你还记得上一节,&str 是一个字符串借用,而 String 是一个字符串。借用的字符串指向一个已经存在的分配,而 String 则意味着新的内存分配。在本例中,我们的 &str 借用了 CSV 记录本身。

我们必须做的第二个更改是停止使用 Reader::deserialize 迭代器,而是显式地将我们的记录反序列化为 StringRecord,然后使用StringRecord::deserialize 方法来反序列化单个记录。

第二个更改有点棘手,因为为了让它工作,我们的 Record 类型需要借用 StringRecord 内部的数据。这意味着我们的 Record 生命周期不能超过创建它的 StringRecord 的生命周期。由于我们在每次迭代中覆盖相同的 StringRecord (为了摊销分配),这意味着我们的 Record 值必须在下一次循环迭代之前被覆盖。事实上,编译器会强制执行!

我们还可以进行一项优化:删除 UTF-8 验证。通常,这意味着使用 &[u8] 而非 &str,并使用 ByteRecord 而不是 StringRecord:

#[derive(Debug, Deserialize)]
 #[serde(rename_all = "PascalCase")]
struct Record<'a> {
    country: &'a [u8],
    city: &'a [u8],
    accent_city: &'a [u8],
    region: &'a [u8],
    population: Option<u64>,
    latitude: f64,
    longitude: f64,
}

fn run() -> Result<u64, Box<Error>> {
    let mut rdr = csv::Reader::from_reader(io::stdin());
    let mut raw_record = csv::ByteRecord::new();
    let headers = rdr.byte_headers()?.clone();

    let mut count = 0;
    while rdr.read_byte_record(&mut raw_record)? {
        let record: Record = raw_record.deserialize(Some(&headers))?;
        if record.country == b"us" && record.region == b"MA" {
            count += 1;
        }
    }
    Ok(count)
}

编译并执行:

$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176

real    0m0.873s
user    0m0.850s
sys     0m0.023s

这相当于比前一个示例提升了 17%,比第一个示例增加了 37%。

总之,Serde 解析仍然非常快,但通常不是解析 CSV 的最快方法,因为它需要执行很多其它逻辑。

不使用标准库的方式进行 CSV 解析

在本节中,我们将探索一个特殊的用例:在不使用标准库的情况下解析 CSV。虽然 csv 板条箱本身需要标准库,但底层解析器实际上是 csv-core crate 的一部分,它不依赖于标准库。不依赖标准库的缺点是 CSV 解析变得非常不方便。

csv-core 板条箱(crate)的结构与 csv 板条箱类似。有 ReaderWriter,以及相应的构建器 ReaderBuilderWriterBuilder。csv-core 板条箱没有记录类型或迭代器。相反,CSV 数据可以一次读取一个字段,也可以一次读取一条记录。在本节中,我们将专注于一次读取一个字段,因为这样更简单,但一次读取一条记录通常更快,因为每次函数调用都要执行一些额外逻辑。

为了与这一节的介绍保持一致,让我们只使用 csv-core 编写一个程序,它计算马萨诸塞州的记录数量。

(请注意,不幸的是,我们在本例中使用了标准库,尽管 csv-core 在技术上并不需要它。我们这样做是为了方便访问I/O,如果没有标准库,这会比较困难。)

extern crate csv_core;

use std::io::{self, Read};
use std::process;

use csv_core::{Reader, ReadFieldResult};

fn run(mut data: &[u8]) -> Option<u64> {
    let mut rdr = Reader::new();

    // 计算 Massachusetts 州的记录数
    let mut count = 0;
    // 当前字段索引。在每条记录开始时重置为 0。
    let mut fieldidx = 0;
    // True when the current record is in the United States.
    // 当前记录是在美国。
    let mut inus = false;
    // Buffer for field data. Must be big enough to hold the largest field.
    // 字段数据的缓冲区。必须足够大才能容纳最大的字段。
    let mut field = [0; 1024];
    loop {
        // 尝试递增地去读取下一条 csv 字段
        let (result, nread, nwrite) = rdr.read_field(data, &mut field);
        // nread 是从输入中读取的字节数。我们再也不需要将这些字节传递给 read_field。
        data = &data[nread..];
        // nwrite 是写入输出缓冲区 `field` 的字节数。此后,缓冲区内容就是未定义的了。
        let field = &field[..nwrite];

        match result {
            // 无需处理这个 case,因为预先读取了所有的数据。如果我们增量地读取数据,这就代表要读取更多数据。
            ReadFieldResult::InputEmpty => {}
            // 这种情况中,意味着一个字段超过 1024 字节了。可以简单处理,返回读取失败。
            ReadFieldResult::OutputFull => {
                return None;
            }
            // 成功读取字段。如果该字段是记录中的最后一个字段,则 `record_end` 为 true。
            ReadFieldResult::Field { record_end } => {
                if fieldidx == 0 && field == b"us" {
                    inus = true;
                } else if inus && fieldidx == 3 && field == b"MA" {
                    count += 1;
                }
                if record_end {
                    fieldidx = 0;
                    inus = false;
                } else {
                    fieldidx += 1;
                }
            }
            // CSV reader 成功读取完所有的输入时。
            ReadFieldResult::End => {
                break;
            }
        }
    }
    Some(count)
}

fn main() {
    // 预先读入 stdin 的所有内容
    let mut data = vec![];
    if let Err(err) = io::stdin().read_to_end(&mut data) {
        println!("{}", err);
        process::exit(1);
    }
    match run(&data) {
        None => {
            println!("error: could not count records, buffer too small");
            process::exit(1);
        }
        Some(count) => {
            println!("{}", count);
        }
    }
}

编译并运行:

$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176

real    0m0.572s
user    0m0.513s
sys     0m0.057s

这不如我们之前用 csv 板条箱读入 StringRecord 或 ByteRecord 的例子性能高。这主要是因为本例每次只读取一个字段,这比每次读取一条记录带来的开销更大。要解决这个问题,你需要使用 Reader::read_record 方法,它是在 csv_core::Reader 中定义的。

这里需要注意的另一件事是,这个示例比其他示例花了更多精力。这是因为我们需要做更多的记录工作来跟踪我们正在读取的字段,以及我们已经向 reader 提供了多少数据。使用 csv_core 板条箱主要有两个原因:

  • 在一个标准库不可用的环境中。
  • 你想构建自己的类 csv 库,也可以基于 csv-core 之上构建。

展望

恭喜你走到最后!一个人能在 “CSV 解析”这样基本的东西上写这么多字,这似乎令人难以置信。我希望这个指南不仅能让 Rust 的初学者使用,也能让没有经验的程序员使用。我希望大量的例子将帮助你朝着正确的方向前进。

话虽如此,这里还有一些你可能想看看的资料:

  • csv 板条箱的 API 文档记录了该库的所有方面,而且本身还夹杂着更多的示例。
  • csv-index crate 提供了数据结构,可以索引易于写入磁盘的 CSV 数据。(这个库仍在开发中。)
  • xsv 命令行工具是一款高性能的 CSV 瑞士军刀。它可以对任意 CSV 数据进行切片、选择、搜索、排序、连接、索引、格式化和统计计算。可以试一试!