`yes`コマンドについてのちょっとした話
知っている中で、いちばん単純なUnixコマンドは何でしょう?
文字列をstdoutに出力するechoや、常に終了コード0で終了するtrueがあります。
単純なUnixコマンドの仲間には、yesもあります。引数なしで実行すると、改行で区切られた「y」が無限に出力されます。
y
y
y
y
(...もうお分かりですね)最初は無意味に見えますが、実はかなり便利です。
yes | sh boring_installation.sh続行するために「y」を入力してEnterキーを押さなければならないプログラムをインストールしたことはありませんか?そんなときこそyesの出番です!yesがせっせと役目を果たしてくれるので、こちらは『Pootie Tang』を見続けられます。
yesを書く
まずは…えーと…BASICで書いた基本版です。
10 PRINT "y"
20 GOTO 10こちらは同じものをPythonで書いたものです。
while True:
print("y")簡単ですよね?ところが、そうでもありません!
このプログラム、かなり遅いのです。
python yes.py | pv -r > /dev/null
[4.17MiB/s]私のMacに組み込まれているバージョンと比べてみましょう。
yes | pv -r > /dev/null
[34.2MiB/s]そこで、もっと速いバージョンをRustで書いてみることにしました。最初の試みがこちらです。
use std::env;
fn main() {
let expletive = env::args().nth(1).unwrap_or("y".into());
loop {
println!("{}", expletive);
}
}少し説明します。
- ループ内で出力する文字列は、最初のコマンドライン引数で、expletiveという名前にしています。この単語は
yesのmanページで覚えました。 - 引数からexpletiveを取得するために
unwrap_orを使っています。引数が指定されていない場合は、デフォルトとして「y」を使います。 - デフォルト値の文字列スライス(
&str)は、into()を使ってヒープ上の所有文字列(String)に変換されます。
テストしてみましょう。
cargo run --release | pv -r > /dev/null
Compiling yes v0.1.0
Finished release [optimized] target(s) in 1.0 secs
Running `target/release/yes`
[2.35MiB/s]おっと、これでは速くなったように見えません。Python版よりさらに遅いではありませんか!気になったので、Cで実装されたもののソースコードを探してみました。
こちらは、Version 7 Unixとともにリリースされたこのプログラムの最初期のバージョンです。1979年1月10日にKen Thompsonが書いたものとして有名です。
main(argc, argv)
char **argv;
{
for (;;)
printf("%s\n", argc>1? argv[1]: "y");
}ここには魔法のようなものは何もありません。
それを、GitHubにミラーされているGNU coreutils版の128行の実装と比べてみてください。25年経った今も、活発に開発が続いているのです!最後にコードが変更されたのは約1年前です。かなり速いですね。
# brew install coreutils
gyes | pv -r > /dev/null
[854MiB/s]重要なのは最後の部分です。
/* Repeatedly output the buffer until there is a write error; then fail. */
while (full_write (STDOUT_FILENO, buf, bufused) == bufused)
continue;なるほど!つまり、書き込み処理を高速化するために、単純にバッファを使っているのですね。バッファサイズはBUFSIZという定数で定義されています。この値は、I/Oが効率的になるよう、システムごとに選ばれます(詳しくはこちら)。私の環境では1024バイトでした。実際には8192バイトのほうが高い性能になりました。
Rustプログラムを拡張してみました。
use std::env;
use std::io::{self, BufWriter, Write};
const BUFSIZE: usize = 8192;
fn main() {
let expletive = env::args().nth(1).unwrap_or("y".into());
let mut writer = BufWriter::with_capacity(BUFSIZE, io::stdout());
loop {
writeln!(writer, "{}", expletive).unwrap();
}
}重要なのは、メモリアライメントを確保するため、バッファサイズを4の倍数にしていることです。
実行すると51.3MiB/sになりました。私のシステムに付属するバージョンよりは速くなりましたが、それでも、見つけたこのRedditの投稿で著者が述べている10.2GiB/sという結果には遠く及びません。
更新
今回も、Rustコミュニティは期待を裏切りませんでした。
この記事がRust subredditに投稿されるとすぐに、nwydoさんが同じ話題についての以前の議論を教えてくれました。こちらがその最適化済みコードです。私のマシンでは3GB/sの壁を突破しました。
use std::env;
use std::io::{self, Write};
use std::process;
use std::borrow::Cow;
use std::ffi::OsString;
pub const BUFFER_CAPACITY: usize = 64 * 1024;
pub fn to_bytes(os_str: OsString) -> Vec<u8> {
use std::os::unix::ffi::OsStringExt;
os_str.into_vec()
}
fn fill_up_buffer<'a>(buffer: &'a mut [u8], output: &'a [u8]) -> &'a [u8] {
if output.len() > buffer.len() / 2 {
return output;
}
let mut buffer_size = output.len();
buffer[..buffer_size].clone_from_slice(output);
while buffer_size < buffer.len() / 2 {
let (left, right) = buffer.split_at_mut(buffer_size);
right[..buffer_size].clone_from_slice(left);
buffer_size *= 2;
}
&buffer[..buffer_size]
}
fn write(output: &[u8]) {
let stdout = io::stdout();
let mut locked = stdout.lock();
let mut buffer = [0u8; BUFFER_CAPACITY];
let filled = fill_up_buffer(&mut buffer, output);
while locked.write_all(filled).is_ok() {}
}
fn main() {
write(&env::args_os().nth(1).map(to_bytes).map_or(
Cow::Borrowed(&b"y\n"[..],
),
|mut arg| {
arg.push(b'\n');
Cow::Owned(arg)
},
));
process::exit(1);
}これはもう、まったく別物です!
- データを詰めた文字列バッファを用意し、ループのたびに再利用します。
- Stdoutはロックで保護されています
- 不要なメモリ確保を避けるため、プラットフォーム固有の
std::ffi::OsStringとstd::borrow::Cowを使います。
私が貢献できたのは、不要なmutを削除したことだけでした。😅
学んだこと
単純なプログラムであるyesは、結局のところ、それほど単純ではありませんでした。出力バッファリングとメモリアライメントを使って、性能を向上させています。Unixツールを再実装するのは楽しいですし、コンピューターを高速に動かしている巧妙な工夫にも気づかされます。
記事をランダムに読む