【譯】經過 Rust 強化 sed

正文開始git

做爲我正在作的項目中的一部分,有時我發現本身不得不處理至關大的 X12 文件。對於那些尚未接觸過的 X12 的人來講,X12 是電子數據交換的 ANSI 標準。它能夠被認爲是 XML 的鼻祖;它是爲了知足一樣的需求而設計的,可是在好久之前,單個字節是很是有價值的東西。標準委員會最初成立於 1979 年,因此它的年齡是比我還大的。github

那又如何。正則表達式

X12 有各類各樣的問題,可是讓我寫這篇文章的緣由是一個文件時常只包含一行。一個 X12 文件以一個名爲 ISA 段的固定長度 106 字節頭開始的。除此以外,這個字節頭指定了文檔其他部分中使用的三個不一樣的結束符。shell

一旦固定長度的頭被排除了,文檔的其他部分就由一些列可變長度的記錄組成,這些記錄稱爲段。每一個段的末尾由段結束符標記。處理工具普遍容許使用尾部換行,但在這裏不須要。安全

全部內容在一行中形成的問題是,用於數據處理和探索的標準 Unix 工具箱中的絕大部分工具都是設計成一次一行地處理數據。例如,假如你想查看一個大文件的開頭,以查看它是否包含:$ head < file,並向你的終端打印前 10 行。若是你正在處理的整個文件只有一行,大小是 1.3G,那麼這就沒有多大幫助了。bash

固然,工具箱中確實包含了處理這類問題的方法。X12 中傳統的、應用最普遍的段終止符是波浪號(~)。若是咱們想要 X12 文件的前 10 行,咱們可使用 sed 在每一個波浪線以後插入新的一行,而後再將內容輸出到 head工具

$ sed -e 's/~/~\n/g' < INPUT | head
複製代碼

(用這種方法對於大多數文檔格式來講太過理想化,在咱們的實例中是安全地,由於 X12 不支持「轉義」等沒必要要的無用操做;由文件建立者來確保所選擇的終止符不會出如今內容字段中。這就是爲何每一個文檔均可以指定本身的結束符。)oop

這是可行的,但它有點問題: * Using ~ as a terminator is extremely common, but not required. A general-purpose tool needs to look up the correct terminator in the header. * 使用 ~ 做爲終止符很是常見,但不是必需的。通用工具須要在文件頭部查找正確的終止符。 * 你每次都要記住並手動輸入 * 它須要一次將整個源文件讀入內存,若是是一個大文件,這就麻煩了。 * 它還須要處理這個輸入文件,即便 head 只取前 10 行。對於大文件,這可能須要一些時間。 * 它不是冪等的。在已經有換行的文件上運行這個命令會獲得雙倍行距的行,若是有一個命令老是在每一個段以後生成一個換行符,而無論輸入文件中是什麼,那就行了。性能

還有其餘工具能夠解決這些問題;例如,咱們可使用 Perl:測試

$ perl -pe 's/~[\n\r]*/~\n/g' < INPUT | head
複製代碼

這解決了冪等性問題嗎,但沒有解決其餘的問題,並且這是更須要注意的。

我真正想要的是一個小型的、自包含的工具,我能夠將一個 X12 文件傳給它,並依賴它執行正確的操做,而不須要任何沒必要要的命令。因爲我正在處理大型源文件,若是它至少包含像 sed 這樣的標準工具同樣快就行了。聽起來像是。。。

用 Rust 來拯救

使人高興的是,Rust 使編寫這種命令行使用程序變得很是容易,而不會出現 c 語言中此類代碼的問題。

既然咱們對執行速度比較追求,那讓咱們設置一個速度基準。我在 Intel Core i9-7940X 的機器上運行 Linux。我將使用存儲在 RAM 上的 1.3 GB X12 文件進行測試,該文件沒有多餘的行。

$ time sed -e 's/~/~\n/g' < testfile.x12 > /dev/null
# -> 7.65 seconds
複製代碼

如今咱們有一些能夠用來比較的數據了,讓咱們嘗試一個簡單的 Rust 版本程序:

use aho_corasick::AhoCorasick;
use std::io::{self, stdin, stdout, BufReader, BufWriter};

fn main() -> io::Result<()> {
    let reader = BufReader::new(stdin());
    let writer = BufWriter::new(stdout());
    let patterns = &["~"];
    let replace_with = &["~\n"];
    let ac = AhoCorasick::new(patterns);
    ac.stream_replace_all(reader, writer, replace_with)
}
複製代碼

咱們尚未作終止符檢查,咱們只經過 STDINSTDOUT 處理 IO,可是用相同的文件下統計時間能夠獲得 1.68s,不行,還沒達到要求。

從 ISA 段讀取正確的終止符是一個簡單的改進方法:

use aho_corasick::AhoCorasick;
use std::io::{self, stdin, stdout, Read, BufReader, BufWriter};
use std::str;

fn main() -> io::Result<()> {
    let mut reader = BufReader::new(stdin());
    let writer = BufWriter::new(stdout());

    let mut isa = vec![0u8; 106];
    reader.read_exact(&mut isa)?;
    let terminator = str::from_utf8(&isa[105..=105])
        .unwrap();

    let patterns = &[terminator];
    let replace_with = &[format!("{}\n", terminator)];
    AhoCorasick::new(patterns)
        .stream_replace_all(reader, writer, replace_with)
}
複製代碼

這將須要向源代碼添加幾行代碼,但不會顯著的影響運行時。(細心的人可能已經注意到這個版本沒有編寫 ISA 段,爲了讓代碼更短,我忽略這一點。)

就速度而言,這已是 sed 單行程序的一個較大改進,它將自動爲咱們檢測正確的終止符。不幸的是,若是想正確處理換行,這種方法就會有困難。咱們能夠很容易地要麼替換全部換行,要麼替換終止符後面的單個換行,可是咱們不能匹配「任意數量的換行,而只能匹配終止符後面的換行」。

咱們能夠嘗試將正則表達式應用於流,可是對於這樣一個簡單的轉換來講,這樣作彷佛有些過了。若是本身處理字節流而不依賴 aho_corasick 庫呢?

use std::io::{self, stdin, stdout, Read, BufReader, BufWriter, ErrorKind};
use byteorder::{ReadBytesExt, WriteBytesExt};

fn main() -> io::Result<()> {
    let mut reader = BufReader::new(stdin());
    let mut writer = BufWriter::new(stdout());

    let mut isa = vec![0u8; 106];
    reader.read_exact(&mut isa)?;
    let terminator = isa[105];

    loop {
        match reader.read_u8() {
            Ok(c) => {
                writer.write_u8(c)?;
                if c == terminator {
                    writer.write_u8(b'\n')?;
                }
            }
            Err(ref e) if e.kind() == ErrorKind::UnexpectedEof => {
                return Ok(());
            }
            Err(err) => {
                return Err(err);
            }
        };
    }
}
複製代碼

不會太長,並且,儘管咱們在這個版本沒有處理換行但至少咱們有一個容易的地方爲它們添加代碼。

如何比較性能?

13 秒。哎呦,原來 stream_replace_all 爲提升操做效率提供了很大助力。

經過管理咱們本身的緩衝區,而不是依賴於 BufReader 和大量的 1-byte 的 read_u8() 調用,咱們能夠從新得到大量的時間 —— 但要付出更多的開銷:

use std::io::{self, stdin, stdout, Read, Write, BufReader, BufWriter, ErrorKind};
use byteorder::{WriteBytesExt};

const BUF_SIZE: usize = 16384;

fn main() -> io::Result<()> {
    let mut reader = BufReader::new(stdin());
    let mut writer = BufWriter::new(stdout());

    let mut isa = vec![0u8; 106];
    reader.read_exact(&mut isa)?;
    let terminator = isa[105];

    let mut buf = vec![0u8; BUF_SIZE];
    loop {
        match reader.read(&mut buf) {
            Ok(0) => { return Ok(()) } // EOF
            Ok(n) => {
                let mut i = 0;
                let mut start = 0;

                loop {
                    if i == n {
                        // No terminator found in the rest
                        // of the buffer. Write it all out
                        // and read some more.
                        writer.write_all(&buf[start..])?;
                        break;
                    }
                    if buf[i] == terminator {
                        writer.write_all(&buf[start..=i])?;
                        writer.write_u8(b'\n')?;
                        start = i + 1;
                    }
                    i += 1;
                }
            }
            Err(ref e) if e.kind() == ErrorKind::UnexpectedEof => {
                return Ok(());
            }
            Err(err) => {
                return Err(err);
            }
        };
    }
}
複製代碼

除了大大減小 read_u8 調用的數量,咱們還經過把每個段寫入隨後只單次地調用 write_all(或者兩次,緩衝區中最後一個段也可能須要),而不是爲每一個字符編寫一個 write_u8 調用,如此,大大提升了速度。

代碼從這裏開始變得有些複雜了,可是執行只須要 1.75s。這樣好些了!但。。。仍是比第一個版本要慢。這是爲何?查看 aho_corasick crate 的依賴關係,它提供了一個線索:依賴於 memchr

SIMD 和 memchr

現代處理器支持各類不一樣的指令來執行對向量化的數據的操做,這些指令一般被普遍地集中在 SIMD 的名稱下,用於單指令多數據(SIMD)。無需過多細節,這意味着沒必要檢查整個文件中的每一個字節來查看它是不是終止符,而是能夠一次性的將他們加載到寄存器中,並在一個時鐘週期內對它們進行比較。具體有多少取決於特定的 CPU 所提供的特性。

聽起來管理這個有點複雜,不是嗎?實際上確實如此,可是 Rust 中出色的 memchr 庫隱藏了複雜度,併爲咱們提供了比上面的手工代碼更高層次的接口,使用起來更容易,速度也更快。

因爲沒有其餘的改動,我只是將主代碼放在一個循環中:

loop {
    let n = reader.read(&mut buf)?;
    if n == 0 { return Ok(()); }
    let mut start = 0;

    while start < n {
        // Finds the index of the next terminator in the buffer,
        // checking a chunk of buffer in parallel if possible.
        match memchr(terminator, &buf[start..n]) {
            Some(offset) => {
                writer.write_all(&buf[start..=start + offset])?;
                writer.write_u8(b'\n')?;
                start = start + offset + 1;
            }
            None => {
                writer.write_all(&buf[start..])?;
                break;
            }
        }
    }
}
複製代碼

具體快多少呢?使用這種更好的代碼將會在 1.01 秒內處理相同大小的文件。

實際上,咱們還咩有處理換行,不幸的是,這使得代碼更加複雜,由於它引入了一些棘手的臨界的狀況。問題是,咱們可能使用終止符做爲緩衝區最後一個字符,而後在下一次讀取緩衝區時使用一些列換行符做爲開始的幾個字符。這會使情況有點糟糕,但並無影響性能,因此此次就講到這裏!若是你堆帶有換行和錯誤處理的完整代碼感興趣,能夠在 GitHub 上查看代碼。

結語

讓咱們回顧一下不一樣的處理方法,使用相同的 1.3GB 大小的文件測試

方法 耗時
perl -le '$/="~";print $_, "~" while <>' 6.25s
sed -e 's/~/~\n/g' 7.25s
awk 'BEGIN{RS="~";OFS=""}{print $0, RS}' 7.54s
Aho-Corasick 1.68s
簡單地手動循環字符 13s
緩衝區統一寫 1.75s
使用 memchr 相似的方式 1.01s

咱們可以力壓通用的一些方法並不奇怪 —— 這是一個不公平的比較,由於咱們所作的優化要少的多。

通常來講,只要你願意在編寫、調試至關多的代碼和處理過程當中忽然出現的臨界狀況之間進行權衡,那麼對於一個特定的用例,幾乎老是可能賽過通用的方法的。

真正的問題是它是否值得在任何特定的狀況下花費時間和精力;sedperl 替代方案都是快速而簡單的一行程序,你能夠輕鬆的編寫它們。GNU awk 的完整版本只有 14 行,包含了我所須要的全部特性,包括終止符檢測和換行處理。Rust 版本大約有 100 行代碼(其中一些代碼處理 CLI 選項和文件處理),在我獲得最終的版本以前,它花費了大量的時間和實驗。

最終的代碼能夠在 GitHub 上找到,這正是我想要的;它給我一個簡單、快速的方法 —— 將 X12 文件分割成小段的方法,用於快速檢查或進一步的處理。

相關文章
相關標籤/搜索