Unixコマンド”yes”についてのちょっとした話

知っているUnixのコマンドで一番シンプルなものは何ですか?
例えばechoという、stdoutに文字列を出力しtrueを返す – すなわち常に0の終了コードで終了するシンプルなコマンドがあります。

シンプルな、と言えばyesもそうでしょう。引数なしで実行すると、改行されたyが無限に出力され続けます。

  1. y
  2. y
  3. y
  4. y
  5. (...you get the idea)

最初は無意味に見えたものが、最終的に有益になることもあります。

  1. yes | sh boring_installation.sh

続行するために”y”を入力し、Enterキーを押す必要があるようなプログラムをインストールしたことはありますか? そんな時、yesは救いの神です。あなたに代わってその作業を丹念にこなしてくれるので、その間にあなたは『Pootie Tang』を観て楽しめますね。

yesを書く

以下は、BASICでベーシックに書いた場合の例です。

  1. 10 PRINT "y"
  2. 20 GOTO 10

次はPythonで同じことをやるとこうなります。

  1. while True:
  2. print("y")

シンプルでしょう。え、ちょっと遅いですって?
ご名答。このプログラムはかなり遅いですね。

  1. python yes.py | pv -r > /dev/null
  2. [4.17MiB/s]

私のMacにビルトインされていたものと比較してみましょう。

  1. yes | pv -r > /dev/null
  2. [34.2MiB/s]

そんなわけで、Rustを使って速いバージョンを書いてみようと思いました。以下は最初の試作です。

  1. use std::env;
  2.  
  3. fn main() {
  4. let expletive = env::args().nth(1).unwrap_or("y".into());
  5. loop {
  6. println!("{}", expletive);
  7. }
  8. }

説明です。

  • ループで印刷したい文字列が最初のコマンドラインパラメータであり、expletiveという名前です。yesのmanページで、この言葉を知りました。
  • unwrap_orを使ってパラメータからexpletiveを取得します。パラメータが設定されていない場合、デフォルトで”y”が使用されます。
  • デフォルトのパラメータは、into()を使用して、文字列スライス(&str)からヒープ(String)上のowned文字列に変換されます。

では、テストしてみましょう。

  1. cargo run --release | pv -r > /dev/null
  2. Compiling yes v0.1.0
  3. Finished release [optimized] target(s) in 1.0 secs
  4. Running `target/release/yes`
  5. [2.35MiB/s]

いや、全然よくなってないですね。それどころか、Pythonのバージョンよりも遅くなりました。そんなわけで、私はC実装のソースコードを調べてみました。

こちらが、1979年1月10日にKen Thompsonによって書かれ、Unixバージョン7でリリースされたプログラムの最初のバージョンです。

  1. main(argc, argv)
  2. char **argv;
  3. {
  4. for (;;)
  5. printf("%s\n", argc>1? argv[1]: "y");
  6. }

魔法のようなものは何もありません。

Githubに反映されているGNU coreutilsの128行のバージョンと比較してみてください。最初のバージョンから25年が経ちますが、いまだ活発に開発が続けられています!。最新の変更は1年前で、これはかなりの速さです。

  1. # brew install coreutils
  2. gyes | pv -r > /dev/null
  3. [854MiB/s]

重要なのは、最後の部分です。

  1. /* Repeatedly output the buffer until there is a write error; then fail. */
  2. while (full_write (STDOUT_FILENO, buf, bufused) == bufused)
  3. continue;

なるほど!バッファを使用して書き込み操作を高速化していたというわけですね。バッファサイズはBUFSIZという名前の定数で定義され、I/Oを効率的にするために各システムで選択されます(ここを参照)。私のシステムでは、1024バイトと定義されていましたが、実際には8192バイトの方がパフォーマンスはよかったです。

これを受けて、Rustのプログラムを拡張しました。

  1. use std::io::{self, Write};
  2.  
  3. const BUFSIZE: usize = 8192;
  4.  
  5. fn main() {
  6. let expletive = env::args().nth(1).unwrap_or("y".into());
  7. let mut writer = BufWriter::with_capacity(BUFSIZE, io::stdout());
  8. loop {
  9. writeln!(writer, "{}", expletive).unwrap();
  10. }
  11. }

重要なのは、メモリの配列を確実にするために、バッファサイズが4の倍数であることです。

実行の結果は51.3MiB/sでした。私のシステムにビルトインされていたバージョンよりも速いですが、10.2GiB/sという速度が議論されているこのRedditの記事の結果に比べると、まだ足元にも及びません。

アップデート

しかし、Rustコミュニティは私を裏切りませんでした。
この投稿がRustのサブレディットに掲載されてすぐに、ユーザのnwydoが、同じトピックに関する以前のディスカッションを教えてくれたのです。こちらが最適化されたそのコードで、私のマシンで3GB/sを突破しました。

  1. use std::env;
  2. use std::io::{self, Write};
  3. use std::process;
  4. use std::borrow::Cow;
  5.  
  6. use std::ffi::OsString;
  7. pub const BUFFER_CAPACITY: usize = 64 * 1024;
  8.  
  9. pub fn to_bytes(os_str: OsString) -> Vec<u8> {
  10. use std::os::unix::ffi::OsStringExt;
  11. os_str.into_vec()
  12. }
  13.  
  14. fn fill_up_buffer<'a>(buffer: &'a mut [u8], output: &'a [u8]) -> &'a [u8] {
  15. if output.len() > buffer.len() / 2 {
  16. return output;
  17. }
  18.  
  19. let mut buffer_size = output.len();
  20. buffer[..buffer_size].clone_from_slice(output);
  21.  
  22. while buffer_size < buffer.len() / 2 {
  23. let (left, right) = buffer.split_at_mut(buffer_size);
  24. right[..buffer_size].clone_from_slice(left);
  25. buffer_size *= 2;
  26. }
  27.  
  28. &buffer[..buffer_size]
  29. }
  30.  
  31. fn write(output: &[u8]) {
  32. let stdout = io::stdout();
  33. let mut locked = stdout.lock();
  34. let mut buffer = [0u8; BUFFER_CAPACITY];
  35.  
  36. let filled = fill_up_buffer(&mut buffer, output);
  37. while locked.write_all(filled).is_ok() {}
  38. }
  39.  
  40. fn main() {
  41. write(&env::args_os().nth(1).map(to_bytes).map_or(
  42. Cow::Borrowed(
  43. &b"y\n"[..],
  44. ),
  45. |mut arg| {
  46. arg.push(b'\n');
  47. Cow::Owned(arg)
  48. },
  49. ));
  50. process::exit(1);
  51. }

もはや、完全に別物と言っていいでしょう。

  • いっぱいになった文字列バッファを準備します。このバッファは各ループで再利用されます。
  • Stdoutはロックによって保護されます。そのため、取得とリリースを定期的に行うのではなく、常にそれを保持します。
  • 不要な割り当てを避けるために、プラットフォーム固有のstd::ffi::OsStringstd::borrow::Cowを使用します。

私が貢献できる唯一のものは、不要なmutを取り除くことだけでした。

学んだこと

些細なプログラムyesが、結果的には全く些細とは言えないものになりました。パフォーマンスの向上には、出力バッファとメモリ配列を使用しています。Unixのツールは楽しいですね。それとコンピュータを高速化させるこうした効果的なテクニックは、本当に頼りになります。