Solaris/SPARC 視点で見た GNU coreutils 9.0

FUJIWARA Katsunori <foozy@lares.dti.ne.jp>

How to navigate in this page

詳細は rst2s5.py での説明参照

GNU coreutils Overview

GNU coreutils Overview

GNU coreutils とは

GNU coreutils は POSIX 系 OS のユーザランドを構成する各種コマンド群の実装を集めたもの。

(以下プロジェクトトップページより引用)

The GNU Core Utilities are the basic file, shell and text manipulation utilities of the GNU operating system. These are the core utilities which are expected to exist on every operating system.

GNU coreutils 9.0 リリース

2021-09-24 付で GNU coreutils 9.0 の stable 版がリリース

詳細は coreutils-9.0 released 参照。

GNU coreutils 9.0 の特徴

Solaris/SPARC 視点で coreuitls 9.0 を ("藤原レンズ" 付きで) 見た場合、 以下の変更点が興味深い

参照先ソースリポジトリ

cp のブロック消費/実行性能効率化

cp のブロック消費/実行性能効率化

効率化方式

cp 実行時における以下の効率化対応の適用が coreutils 9.0 以降はデフォルトで有効になった (プラットフォーム毎に適用可否判定あり)。

Copy-on-Write 対応

ファイルシステム層に対する Copy-on-Write 構成での複製指示は ioctl(FICLONE) で実施

ioctl(FICLONE) の適用条件

以下の条件が成立する場合、 複製先に対する ioctl(FICLONE) が、 複製元指定を伴って発行される

What is FICLONE ?

FICLONE は、 ファイルシステム層に対して、 ファイル間でのブロック共有を指示するための ioctl 要求

/* ファイル全体に渡って共有 */
int ioctl(int dest_fd, FICLONE, int src_fd);

/* 特定範囲に限定した共有 */
int ioctl(int dest_fd, FICLONERANGE,
          struct file_clone_range *arg);

What is FICLONE ?

Linux での man ページ (man ioctl_FICLONE) 曰く:

If a filesystem supports files sharing physical storage between multiple files ("reflink"), this ioctl(2) operation can be used to make some of the data in the src_fd file appear in the dest_fd file by sharing the underlying storage, which is faster than making a separate physical copy of the data. Both files must reside within the same filesystem. If a file write should occur to a shared region, the filesystem must ensure that the changes remain private to the file being written. This behavior is commonly referred to as "copy on write".

What is FICLONE ?

Solaris ZFS における De-Dup ベースの実装の場合、 ブロック共有による複製は、 少なくとも初回の複製に関しては、 原理的に "faster than ...." とはならない (技術的な詳細は以前の勉強会発表資料 "reflink(3C) on ZFS" 参照)

copy_file_range() の場合はどれなのだろう?

  1. 初回の性能低下はわかった上で、対外的なアピール性を重視
  2. 実際の複製処理はファイルシステム層実装なので、俺シラネー (ハナホジー)
  3. 殆どが「単なる参照カウント方式」での実現なので、本当に "faster than ..."

What is FICLONE ?

FICLONE そのものは Btrfs の固有 ioctl 操作由来

Linux での man ページ (man ioctl_FICLONE) 曰く:

These ioctl operations first appeared in Linux 4.5. They were previously known as BTRFS_IOC_CLONE and BTRFS_IOC_CLONE_RANGE, and were private to Btrfs.

ブロック共有状態の強制解消

※ ちょっと脱線

ブロック共有状態の強制解消

ファイルに対してブロックの事前割り当てを要求する Linux 固有システムコール fallocate(2) には、 共有状態の解消を指示する FALLOC_FL_UNSHARE フラグを指定可能

以下 man fallocate(2) より:

If the FALLOC_FL_UNSHARE flag is specified in mode, shared file data extents will be made private to the file to guarantee that a subsequent write will not fail due to lack of space.

ブロック共有状態の強制解消

fallocate(2) の基本的な挙動仕様は:

After a successful call, subsequent writes into the range specified by offset and len are guaranteed not to fail because of lack of disk space.

挙動的に FALLOC_FL_UNSHARE 指定の有無に 関係無い ような気が……

ブロック共有状態の強制解消

以下に引用する FALLOC_FL_UNSHARE 追加提案パッチ中のコメントでも、 "not to fail due to lack of space" 的な観点では、 man ページ以上のことは言及していない。

The purpose of this call (= FALLOC_FL_UNSHARE :訳注) is to preemptively reallocate any blocks that are subject to copy-on-write.

ブロック共有状態の強制解消

もしかして多くの Linux 向けファイルシステムの実装は、 FALLOC_FL_UNSHARE 無し での fallocate(2) 要求に対して 「(共有はされているけど) ブロックは割り当て済みだよ!」 という認識で、 「NOP 相当の振る舞い」をしているとか?

求ム!詳細情報!!

ブロック共有状態の強制解消

ちなみに POSIX 標準版の posix_fallocate() には FALLOC_FL_UNSHARE 指定に相当する機能は無い

ブロック共有状態の強制解消

※ 閑話休題

hole 領域の複製抑止

"sparse file" の複製の際に、 実際にはブロックが割り当てられていない (= 暗黙で 0 埋めされている) 領域、いわゆる "hole" を検出し、 当該領域に対する複製処理の実行を回避することで、 ブロック消費を効率化する

※ 副作用として実行性能も効率化される筈

hole 領域検出手順詳細

hole 領域を検出しながらの複製処理の実装は、 若干トリッキーになっているので、 順を追って説明する

複製元の最初の "非 hole 領域" の先頭を検出

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

複製元の最初の "非 hole 領域" の先頭を検出

infer_scantype() において、 複製元に対する lseek(src_fd, 0, SEEK_DATA) 発行を行い、 ファイル中の最初の "非 hole 領域" の先頭を検出

これは SEEK_HOLE利用可否判定を兼ねている

"非 hole 領域" の終端を導出

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

"非 hole 領域" の終端を導出

※ 以降の処理は sparse_copy() 関数が起点

先の手順で検出済みの "非 hole 領域" の に来る "hole 領域" の先頭を検出することで、 当該 "非 hole 領域" の終端を導出する

検出済みの "非 hole 領域" 先頭位置は ext_start が保持しているので、 以下の要領で の "hole 領域" の先頭を検出する。

off_t ext_end = lseek (src_fd, ext_start, SEEK_HOLE);

複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す

複製処理における呼び出しに備えて、 検出済みの "非 hole 領域" の先頭 (= ext_start) に SEEK 位置を戻す。

lseek (src_fd, ext_start, SEEK_SET)

"hole 領域" のサイズを導出

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

"hole 領域" のサイズを導出

ここでサイズを算出する "hole 領域" は、 "非 hole 領域" の終端導出 (= lseek (src_fd, ext_start, SEEK_HOLE)) で検出した "hole 領域" ではない 点に注意

現行の "非 hole 領域" の先頭を検出することで暗黙に導出される "非 hole 領域に 先行 する hole 領域" を指している

ext_hole_size = ext_start - last_ext_start - last_ext_len;

last_ext_start および last_ext_len は初期値が 0 なので、 ループ初回では "複製元冒頭が hole の場合" に限って非 0 になる

先行 "hole 領域" に対する hole 生成処理を実施

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

先行 "hole 領域" に対する hole 生成処理を実施

--sparse オプションの指定に応じて以下のいずれかを実施:

この時点で複製 の SEEK 位置も ext_start に移動していることを想定

"非 hole 領域" のサイズを導出

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

"非 hole 領域" のサイズを導出

の "hole 領域" 先頭を指す ext_end と、 現行 "非 hole 領域" の先頭を指す ext_start を元に、 現行 "非 hole 領域" のサイズを導出する

ext_len = ext_end - ext_start;

現行変数値を待避

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

現行変数値を待避

次のループ処理に備えて現行変数値を待避

last_ext_start = ext_start;
last_ext_len = ext_len;

"非 hole 領域" の複製処理

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

"非 hole 領域" の複製処理

"非 hole 領域" のサイズ ext_len を指定して `sparse_copy() を呼び出す

`sparse_copy() において、 copy_file_range(2) なり read(2) + write(2) なりを用いて、 "非 hole 領域" の複製が実施される

次の "非 hole 領域" の先頭を検出

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

次の "非 hole 領域" の先頭を検出

複製済みの "非 hole 領域" は、 ext_start + ext_len 位置で終了しているので、 以下の要領で 次の "非 hole 領域" の先頭を検出し、 (新たな) ext_start に格納する

/* 説明の便宜上 "ext_start + ext_len" としているが実際は異なる */
ext_start = lseek (src_fd, ext_start + ext_len, SEEK_DATA);

以前の "非 hole 領域" は、 last_ext_start + last_ext_len で終了しているので、 そこから (新たな) ext_start までが 次の "非 hole 領域" に先行する "hole 領域" であることが暗黙に導出される

hole 領域検出手順詳細

  1. 複製元の最初の "非 hole 領域" の先頭を検出
  2. "非 hole 領域" の終端を導出
  3. 複製元の SEEK 位置を先述した "非 hole 領域先頭" に戻す
  4. "hole 領域" のサイズを導出
  5. 先行 "hole 領域" に対する hole 生成処理を実施
  6. "非 hole 領域" のサイズを導出
  7. 現行変数値を待避
  8. "非 hole 領域" の複製処理
  9. 次の "非 hole 領域" の先頭を検出
  10. 以下 EOF に到達するまで手順 (2) から繰り返し

複製処理のカーネル内実施

ユーザ空間での read(2) + write(2) による複製処理を、 カーネル内での複製処理で代替することにより処理の効率化を図る

copy_file_range(2) によるカーネル内複製

ssize_t copy_file_range(int fd_in, loff_t *off_in,
                        int fd_out, loff_t *off_out,
                        size_t len, unsigned int flags);

The copy_file_range() system call performs an in-kernel copy between two file descriptors without the additional cost of transferring data from the kernel to user space and then back into the kernel.

copy_file_range(2) によるカーネル内複製

copy_file_range(2) の主眼はあくまで "カーネル空間/ユーザ空間の間でのデータ転送の抑止" ではあるが、 実際の複製処理を行うファイルシステム層において、 Copy-On-Write 等に配慮する可能性についても言及している

copy_file_range() gives filesystems an opportunity to implement "copy acceleration" techniques, such as the use of reflinks (i.e., two or more i-nodes that share pointers to the same copy-on-write disk blocks) or server-side-copy (in the case of NFS).

というか、最初は copy_file_range() が Copy-On-Write 対応の肝だと勘違いしてたよ!

copy_file_range(2) によるカーネル内複製

copy_file_range(2) は sparse file における hole の扱いに関して、 何も保証していないので、 hole 領域のブロック消費を確実に抑止したい場合は、 エンドユーザ側で hole 検出した上で呼び出す必要がある

If file_in is a sparse file, then copy_file_range() may expand any holes existing in the requested range. Users may benefit from calling copy_file_range() in a loop, and using the lseek(2) SEEK_DATA and SEEK_HOLE operations to find the locations of data segments.

Server Side Copy on NFS 4.2

※ ちょっと脱線

Server Side Copy on NFS 4.2

copy_file_range(2) の man ページ曰く:

copy_file_range() gives filesystems an opportunity to implement "copy acceleration" techniques, such as the use of reflinks (i.e., two or more i-nodes that share pointers to the same copy-on-write disk blocks) or server-side-copy (in the case of NFS).

Server Side Copy on NFS 4.2

"A new Linux-compatible system call" として copy_file_raneg() サポートをアナウンスしている "FreeBSD: 13.0-RELEASE Release Notes" における言及曰く:

an NFSv4.2 server perform a copy operation locally on the server.

つまり NFSv4.2 仕様では "範囲指定付きのサーバサイド複製要求" プロトコルが追加された模様。

Linux 固有のカーネル内複製

※ 更に脱線

Linux 固有のカーネル内複製

多いよ!

sendfile() によるカーネル内複製

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

sendfile() copies data between one file descriptor and another. Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.

(snip)

In Linux 2.4 and earlier, out_fd could also refer to a regular file; this possibility went away in the Linux 2.6.x kernel series, but was restored in Linux 2.6.33.

sendfile() によるカーネル内複製

ちなみに、可搬性/標準仕様の観点からは、 sendfile() の採用には注意が必要。

Not specified in POSIX.1-2001, nor in other standards.

Other UNIX systems implement sendfile() with different semantics and prototypes. It should not be used in portable programs.

splice() によるカーネル内複製

ssize_t splice(int fd_in, loff_t *off_in, int fd_out,
               loff_t *off_out, size_t len, unsigned int flags);

It transfers up to len bytes of data from the file descriptor fd_in to the file descriptor fd_out, where one of the file descriptors must refer to a pipe.

(snip)

In Linux 2.6.30 and earlier, exactly one of fd_in and fd_out was required to be a pipe. Since Linux 2.6.31, both arguments may refer to pipes.

vmsplice() によるカーネル内複製

ssize_t vmsplice(int fd, const struct iovec *iov,
                 unsigned long nr_segs, unsigned int flags);

The vmsplice() system call maps nr_segs ranges of user memory described by iov into a pipe. The file descriptor fd must refer to a pipe.

tee() によるカーネル内複製

ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

tee() duplicates up to len bytes of data from the pipe referred to by the file descriptor fd_in to the pipe referred to by the file descriptor fd_out. It does not consume the data that is duplicated from fd_in; therefore, that data can be copied by a subsequent splice(2).

Linux 固有のカーネル内複製

※ 閑話休題

copy_file_range(2) 呼び出しの制御

初期の copy_file_range(2) 実装には問題があったらしく、 稼働環境において "Linux の当該バージョンか否か" を調べて copy_file_range(2) 実装の妥当性を判定している。

以下、判定処理のコメントより引用:

copy_file_range() before Linux kernel release 5.3 had many issues, as described at https://lwn.net/Articles/789527/, so return FALSE for Linux kernels earlier than that. This function can be removed when such kernels (released before Sep 2019) are no longer a consideration.

copy_file_range(2) 呼び出しの制御

copy_file_range(2) 妥当性判定の実装では、 copy_file_range(2)提供されない 環境においても functional_copy_file_range() は真値を返す!

copy_file_range(2) 呼び出しの制御

ioctl(FICLONE) 呼び出しは #if/#endif 対応している一方で、 同様にプラットフォーム固有 API である copy_file_range(2) の呼び出し自体に対しては、 #if/#endif 等の対応が実施されていない

copy_file_range(2) 呼び出しの制御

シンボル未定義等の問題にはならないのか?

coreutils の依存先ライブラリ gnulib において、 "-1 応答 (+ errno = ENOSYS 設定)" のみを実施するスタブ実装が定義されていた

そもそも非対応環境では functional_copy_file_range() が偽値を返すようにした方が筋が良い気がするのだが……

Solaris 視点での coreutils 9.0 cp

Solaris 視点での coreutils 9.0 cp

Solaris での Copy-on-Write 対応

Copy-on-Write 対応 (いわゆる "reflink") に関する詳細は、 以前の勉強会発表資料 "reflink(3C) on ZFS" を参照

Solaris での hole 領域の複製抑止

そもそも SEEK_HOLE は Solaris 由来。 Jeff Bonwick の 2005 年のコラム 曰く:

As part of the ZFS project, we introduced two general extensions to lseek(2): SEEK_HOLE and SEEK_DATA.

(snip)

At this writing, SEEK_HOLE and SEEK_DATA are Solaris-specific. I encourage (implore? beg?) other operating systems to adopt these lseek(2) extensions verbatim (100% tax-free) so that sparse file navigation becomes a ubiquitous feature that every backup and archiving program can rely on. It's long overdue.

hole 検出方式の歴史

時期 出来事
2005 Jeff Bonwick が Solaris の SEEK_HOLE を紹介
2011-02-04 coreutils 8.10 で FS_IOC_FIEMAP 方式を採用
2011-10-25 Linux 3.1 で SEEK_HOLE サポート開始
2013 ls 本FS_IOC_FIEMAP を紹介
2021-09-24 coreutils 9.0 で SEEK_HOLE 方式に変更

なので、 ls 本SEEK_HOLE ではなく FS_IOC_FIEMAP を紹介しても、 それほど問題ないよね? (笑)

Solaris におけるカーネル内複製処理

Solaris の cp は以前より以下の要領で複製処理のカーネル内実施を実現

  1. 読み出しモードで open(2) した複製元ファイル 全体mmap(2)
  2. マッピングのみなのでファイル内容に関する I/O なし
  3. 複製先ファイルを書き込みモードで open(2)
  4. 複製元の mmap(2) 領域 全体 を元データ指定して write(2) 実施
  5. 複製元全体を一括指定するので write(2) 発行は 一度のみ
  6. データ転送は全て カーネル内で閉じる

Solaris におけるカーネル内複製処理

GNU coretuils の cp でこの手法を採用しないのは、 ライセンス的な問題? それとも mmmap() 等の環境間互換性とかの問題?

AVX2 命令による wc の高速化

AVX2 命令による wc の高速化

高速化の手法および対象

coreutils: src/wc_avx2.c で実現されている高速化の基本的な手法は:

Intel AVX2 (Advanced Vector Extensions 2) SIMD 命令を用いて、 比較対象データ (≒ ファイル内容) と改行文字 '\n' との比較を、 複数バイト分一括して実施

つまり高速化対象は wc -l に関する処理のみ。

前提条件

AVX2 命令による wc の高速化の実現では、 実装コード側で以下の AVX2 データ格納領域を定義している。

領域名 用途
zeros 0 値を保持 (SPARC で言う g0 レジスタ相当)
endlines 改行文字 '\n' との比較用
accumulator 改行文字との一致回数保持用 (0 初期化)
to_match 判定対象データの読み込み先
matches 改行文字との一致判定結果の格納先

実際の実装では、 accumulator, to_match, matches はもう一組用意されるが、 簡略化のため一組分だけで説明する。

ベース値格納領域を初期化

以下の AVX2 データ格納領域を初期化する

領域名 内容
zeros 全領域を 0 初期化
endlines 8 ビット毎に改行文字 '\n' 相当値を設定
accumulator 全領域を 0 初期化
accumulator = _mm256_setzero_si256 ();
zeroes = _mm256_setzero_si256 ();
endlines = _mm256_set1_epi8 ('\n');

ベース値格納領域を初期化

(実サイズは 256 ビット = 32 バイトだが簡略化のため 8 バイトのみ記載)

格納先 0 1 2 3 4 5 6 7
zeros 0 0 0 0 0 0 0 0
endlines \n \n \n \n \n \n \n \n
accumulator 0 0 0 0 0 0 0 0
to_match
matches

比較対象データの読み込み

改行コードとの比較対象となるデータ (= wc の処理対象データ) から、 32 バイト分 (= 256 ビット分) を to_match に読み込む。

to_match = _mm256_load_si256 (datap);

文字列 "foo\nbar\n" が比較対象となる場合の実行例は以下の通り。

比較対象データの読み込み

(実サイズは 256 ビット = 32 バイトだが簡略化のため 8 バイトのみ記載)

格納先 0 1 2 3 4 5 6 7
zeros 0 0 0 0 0 0 0 0
endlines \n \n \n \n \n \n \n \n
accumulator 0 0 0 0 0 0 0 0
to_match f o o \n b a r \n
matches

改行文字との一致判定

比較対象データ to_match と、 改行文字を保持している endlines の間に、 "8 ビット毎データの一致判定" を行う _mm256_cmpeq_epi8 命令 を適用し、 判定結果を matches に格納。

matches = _mm256_cmpeq_epi8 (to_match, endlines);

判定対象の 8 ビット毎に、0x00 (= 不一致) または 0xff (= 一致) が格納される。

改行文字との一致判定

(実サイズは 256 ビット = 32 バイトだが簡略化のため 8 バイトのみ記載)

格納先 0 1 2 3 4 5 6 7
zeros 0 0 0 0 0 0 0 0
endlines \n \n \n \n \n \n \n \n
accumulator 0 0 0 0 0 0 0 0
to_match f o o \n b a r \n
matches 0 0 0 0xff 0 0 0 0xff

判定結果を減算

判定結果が格納された matches と、 一致回数格納先の accumulator との間に、 "8 ビット毎データの減算処理" を行う _mm256_sub_epi8 命令 を適用し、 減算結果を accumulator に格納。

accumulator = _mm256_sub_epi8 (accumulator, matches);

直前の _mm256_cmpeq_epi8 命令 による判定結果が、 一致の場合に 0xff = 符号付き整数 -1 として matches に格納されているので、 減算処理により "一致の場合に 1 加算" と等価になる。

判定結果を減算

(実サイズは 256 ビット = 32 バイトだが簡略化のため 8 バイトのみ記載)

格納先 0 1 2 3 4 5 6 7
zeros 0 0 0 0 0 0 0 0
endlines \n \n \n \n \n \n \n \n
accumulator 0 0 0 1 0 0 0 1
to_match f o o \n b a r \n
matches 0 0 0 0xff 0 0 0 0xff

判定処理の繰り返し

32 バイト (= 256 ビット) x 2 組 = 64 バイト以上の比較対象データが残っているなら、 "比較対象データの読み込み" 以降を繰り返す。

後続の比較対象文字列が "bazz\nqux" の場合に、 "比較対象データの読み込み" から "判定結果を減算" までを実施した場合の実行結果は以下の通り。

判定処理の繰り返し

(実サイズは 256 ビット = 32 バイトだが簡略化のため 8 バイトのみ記載)

格納先 0 1 2 3 4 5 6 7
zeros 0 0 0 0 0 0 0 0
endlines \n \n \n \n \n \n \n \n
accumulator 0 0 0 1 1 0 0 1
to_match b a z z \n q u x
matches 0 0 0 0 0xff 0 0 0

改行文字一致回数の集計

改行文字 '\n' との一致判定の結果は、 accumulator において 8 ビット毎に集計されているため、 これまでの処理を 256 回繰り返すとオーバーフローが発生する可能性がある。

そのため、 32 バイト (= 256 ビット) x 255 回 x 2 組= 16320 バイトの判定毎に、 一致回数を集計しなければならない。

以下実装コード中のコメントより引用:

/* This must be below 16 KB (16384) or else the accumulators can
   theoretically overflow, producing wrong result. This is 2*32 bytes below,
   so there is no single bytes in the optimal case. */
#define BUFSIZE (16320)

改行文字一致回数の集計 (1)

改行文字一致回数を格納している accumulator と、 0 値を保持している zeros との間に、 "符号なし 8 ビット値毎の減算結果を 8 個毎に合算した結果を格納" する _mm256_sad_epu8 命令 を適用することで、 8 バイト (= 64 ビット) 毎の集計結果が accumulator 自身に格納される。

accumulator = _mm256_sad_epu8 (accumulator, zeroes);

適用対象データ長が 256 ビットなので、 accumulator に格納される改行文字一致回数は、 この時点で 4 分割されていることになる。

改行文字一致回数の集計 (1)

※ ちょっと脱線

改行文字一致回数の集計 (1)

16320 バイトの判定毎の中間集計では、 集計元データを格納している accumulator が、 集計処理 (_mm256_sad_epu8 命令) の結果格納先でもある

スーパースケーラ的な発想だと、 結果格納完了待ちによる遅延回避のため、 元データと結果格納先は通常は分ける

格納領域群の 2 組併用で性能向上できたのも、 パイプライン利用効率向上的な成果だと思うのだが……

Intel AVX2 の仕様/実装方法ではそういった配慮は不要なのかな?

求ム!詳細情報!

改行文字一致回数の集計 (1)

※ 閑話休題

一致回数の集計 (2)

前手順において accumulator に格納される改行文字一致回数は 4 分割されているので、 "16 ビット毎の値の取り出し" を行う _mm256_extract_epi16 命令 で当該集計結果 (x4) を取り出した上で、 通常の x64 命令を用いて合算結果を格納する。

lines +=   _mm256_extract_epi16 (accumulator, 0)
         + _mm256_extract_epi16 (accumulator, 4)
         + _mm256_extract_epi16 (accumulator, 8)
         + _mm256_extract_epi16 (accumulator, 12);

"256 ビットの 4 分割" なので、 取り出すデータ幅は本来 64 ビットなのだが、 前手順での合算対象が 8 ビット値 x 8 個であることから、 合算の最大値が 8 + 3 = 11 ビットに収まるため、 取り出すデータ幅は 16 ビットでも十分となる。

余剰データの改行文字一致判定

GNU coreutils 9.0 における wc 高速化では、 32 バイト (= 256 ビット) の AVX2 データ格納領域の 2 つ同時使用 = 64 バイト一括処理が強制される。

そのため比較対象データ総量が 64 バイトの倍数では無い場合、 余剰分に関しては従来通りの "ループでの 1 バイト毎判定" を実施する必要がある。

/* Finish up any left over bytes */
char *p = (char *)datap;
while (p != end)
  lines += *p++ == '\n';

SPARC 視点での coreutils 9.0 wc

SPARC 視点での coreutils 9.0 wc

SPARC における SIMD 命令

SPARC は AVX2 命令セットを (当然だが) 提供していないので、 coreutils 9.0 における wc 高速化の恩恵を直接受けることはできない。

しかし近年の SPARC では SIMD 命令が利用可能なので、 同様の手法での高速化実現方式を検討することには意味が有る。

無いかもしれないが、 趣味なんだからイイじゃない! (笑)

SPARC における SIMD 命令

※ ちょっと脱線

SPARC における SIMD 命令

SPARC アーキテクチャへの SIMD 命令の導入は、 SPARC64 VIIIfx ("京" で採用) における富士通独自の拡張 HPC-ACE (High Performance Computing-Arithmetic Computational Extensions) の一部として SIMD 命令の導入がアナウンスされたのが嚆矢と思われる

※ 歴史的経緯に関しては t キーで表示を切り替えて地の文を参照

以下、ざっと調べた範囲での SIMD 命令周りの歴史的経緯を以下に示す (前後関係/依存関係に関して確証を得ている訳ではないので要注意)。

SPARC における SIMD 命令

HPC-ACE での拡張は SIMD 命令追加以外にも、 HPC 向けの拡張として以下のようなレジスタ数の増強が図られている。

SPARC における SIMD 命令

SPARC の命令フォーマットの制約上、 256 個の浮動小数点レジスタを指定するために、 以下のような手法を採用している模様 ("TECH+: 富士通、次期スパコン向けHPC-ACEアーキテクチャを公表" の 3 ページ目 "ユニークなレジスタ数拡張手法" より)

256個のレジスタを指定するには8ビットが必要であり、 元々の命令に含まれる5ビットでは3ビット不足する。 これを、XARレジスタという レジスタ号指定フィールドを拡張するレジスタを設けて補っている。

(中略)

つまり、sxar命令は、ある意味では、 x86のPrefix命令のように次に続く命令の動作を修飾するように動作する。

SPARC における SIMD 命令

※ 閑話休題

SPARC における改行文字一致判定

SPARC における改行文字一致判定の一括実行には、 64 ビット Floating-Point (FP) レジスタを対象とする "Partitioned Unsigned Compare" 命令 FPCMPUEQ8 (8 ビット x 8 同時比較) が使用可能と思われる (但し "Oracle SPARC Arichtecture 2011" 準拠のプロセッサ限定)

比較対象データの読み込みには LDDF 命令を使用する。

判定結果の格納形式は:

SPARC における改行文字一致回数集計

FPCMPUEQ8 による改行文字一致判定の結果を集計するのは、 対象レジスタ中の非 0 ビットをカウントする "Population Count" 命令 POPC が妥当と思われる

SPARC における "並列" 度

coreutils 9.0 での AVX2 命令による wc 高速化では、 256 ビットの AVX2 データ格納領域を2組同時に使用しているが、 コメント曰く、これ以上の同時使用では恩恵が無かったとのこと

Using two parallel accumulators gave a good performance increase. Adding a third gave no additional benefit, at least on an Intel Xeon E3-1231v3. Maybe on a newer CPU with additional vector execution engines it would be a win.

SPARC における "並列" 度

SPARC で高速化を実現しようとした場合、 並列度 (= FP レジスタの同時使用数) はどの程度が適切なのだろうか?

並列度を上げることで、 「判定処理毎の判定結果/集計結果のレジスタ格納」 に対する待ち合わせを回避し、 パイプライン効率を向上させることができるのであれば、 並列度が高いに越したことは無いのだが……

SPARC における SIMD 幅

※ ちょっと脱線

SPARC における SIMD 幅

導入当初は 64 ビットまでだった SPARC(64) の SIMD 幅も、 近年では Intel AVX2 並の 256 ビットにまで拡充されている (時期周りは "富士通: SPARC64プロセッサの軌跡" 参照)

時期 CPU AKA SIMD 幅
2010 SPARC64 VIIIfx 64
2013 SPARC64 X M10 256
2014 SPARC64 X+ M10 256
2014 SPARC64 XIfx FX100 256
2017 SPARC64 XII M12 256

SPARC における SIMD 幅

但し、 SPARC64 X/X+/XII はいずれも SIMD 幅が 256 ビットとなっているが、 命令セットの対応状況が異なるため、 実行可能な処理がそれぞれ異なる ("富士通: SPARC M12/M10 サーバのアーキテクチャーホワイトペーパー" 参照)

CPU 64bit積和x4 8bit比較x32 4bit比較x64
SPARC64 X o x x
SPARC64 X+ o o x
SPARC64 XII o o o

なお "8bit 比較x32" を実施する命令等に関しては現状未確認

誰か調べてくれると嬉しい……

SPARC における SIMD 幅

※ 閑話休題

まとめ

頑張れ! Solaris, Illumos, and (Open)ZFS !

(もっともっともーっと) 頑張れ! SPARC ! っていうか富士通 !

(ついでに) 頑張れ! AMD, ARM, Apple, and RISC-V !

おわり

ご清聴ありがとうございました