Rustで高速な標準出力

κeenです。Rustで何も考えずに標準出力に吐いてると遅いよねーって話です。

今回、標準出力に「yes」と1000万回出力するアプリケーションを書いてみたいと思います。

println!

まあ、最初に思いつくのはこれでしょうか。

fn main() {
    for _ in 0..10_000_000 {
        println!("yes");
    }
}
$ rustc -O yes.rs
$ time ./yes > /dev/null
./yes > /dev/null  1.19s user 0.49s system 99% cpu 1.681 total

はい、1.681秒。結構時間掛かります。

ロックレス

上記のprintln!が遅いのは毎度ロックを取ってるからなので、直接stdoutを取得して一度だけロックを取るようにすると速度は改善します。

use std::io::{stdout, Write};

fn main() {
    let out = stdout();
    let mut out = out.lock();
    for _ in 0..10_000_000 {
        writeln!(out, "yes").unwrap();
    }
}
$ rustc -O yes.rs
$ time ./yes > /dev/null
./yes > /dev/null  0.62s user 0.49s system 99% cpu 1.111 total

1.111秒。多少は速くなりましたがあんまり変わんないですね。

改行?

ところで上記のプログラム、writeln!の代わりにwrite!を使うと(改行を挟まないと)急激に速くなります。

use std::io::{stdout, Write};

fn main() {
    let out = stdout();
    let mut out = out.lock();
    for _ in 0..10_000_000 {
        write!(out, "yes").unwrap();
    }
}
$ rustc -O yes.rs
$ time ./yes > /dev/null
./yes > /dev/null  0.25s user 0.00s system 98% cpu 0.255 total

0.255秒、4.4倍くらいになりました。 何故だか分かりますか?

writeln!の方は改行でフラッシュされるからです。write!だと(改行がないと)されない。あれ?フラッシュ?

バッファリング

はい、ということで忘れがちですが標準出力もデフォルトではバッファリングされないのでバッファリングしてあげましょう。バッファリングしなくてIOが遅い、Rustあるあるですね。

use std::io::{stdout, Write, BufWriter};

fn main() {
    let out = stdout();
    let mut out = BufWriter::new(out.lock());
    for _ in 0..10_000_000 {
        writeln!(out, "yes").unwrap();
    }
}
$ rustc -O yes.rs
$ time ./yes > /dev/null
./yes > /dev/null  0.15s user 0.00s system 97% cpu 0.152 total

はい、最初から比べると10倍以上速くなりました。めでたしめでたし。

さらなる高み

一般的にはここまでで十分ですが、興味としてさらに高速化してみましょう。

1行yesを書く度に毎度write!を呼んでいては遅いです。ある程度まとめてwrite!を呼びましょう。

今回2048 yes毎にwrite_allを呼ぶようにしてみます。

use std::io::{stdout, Write, BufWriter};

fn main() {
    let out = stdout();
    let mut out = BufWriter::new(out.lock());
    let yes = {
        let mut s = String::with_capacity(4096);
        for _ in 0..2048 {
            s += "yes\n";
        }
        s
    };
    let rest = {
        let mut s = String::with_capacity(4096);
        for _ in 0..(10_000_000 % 2048) {
            s += "yes\n";
        }
        s
    };

    for _ in 0..(10_000_000 / 2048) {
        out.write_all(yes.as_bytes()).unwrap();
    }

    out.write_all(rest.as_bytes()).unwrap();
}
$ rustc -O yes.rs
$ time ./yes > /dev/null
./yes > /dev/null  0.00s user 0.00s system 0% cpu 0.003 total

比べようもないくらい速くなりました。

参考

因みにですが手元の環境だとGNU yesより速いです。※Rust版の方は出力数を10億回に増やしたものを使用

$ ./yes | pv > /dev/null
3.73GiB 0:00:00 [7.73GiB/s]
$ yes | pv > /dev/null
^C.6GiB 0:00:06 [7.29GiB/s]
$ yes --version
yes (GNU coreutils) 8.26
Copyright (C) 2016 Free Software Foundation, Inc.
ライセンス GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

作者 David MacKenzie。

2017-10-08 追記

ということで実験してみましょう。

まずは指摘されたコード。

use std::io::{stdout, Write, BufWriter};

fn main() {
    let out = stdout();
    let mut out = BufWriter::new(out.lock());
    for _ in 0..10_000_000 {
        out.write(b"yes\n").unwrap();
    }
}
$ rustc -O yes.rs
$ time ./yes > /dev/null
./yes > /dev/null  0.02s user 0.00s system 89% cpu 0.027 total

かなり速くなってます。ふむむむ。

もう1つ、私が言及したwriteln!よりもwrite!の方が速いというやつ。

use std::io::{stdout, Write, BufWriter};

fn main() {
    let out = stdout();
    let mut out = BufWriter::new(out.lock());
    for _ in 0..10_000_000 {
        write!(out, "yes\n").unwrap();
    }
}

2023-09-05 追記: コードが間違っていたので直しました。今日の三井君さんありがとうございます。

/追記
$ rustc -O yes.rs
$ time ./yes > /dev/null
./yes > /dev/null  0.15s user 0.00s system 98% cpu 0.155 total

あれ!?write!を使うと遅い…。write!は単にwrite_fmtに置き換えられるだけなので大したコストじゃないと思ってたんですがwrite_fmtって以外とコストかかるんですね。

ところでwriteln!マクロのconcat!を呼んでいるので実行時にはアロケーションコストが掛からなそうです(汗。調べもせずに適当なことを言うのはやめましょう(戒め)

/追記

Written by κeen
Later article
心臓のこと