テストステ論

高テス協会会長が, テストステロンに関する情報をお届けします.

dm-lcの紹介 (5) write barrier

dm-lcは, writeをDRAM上でログ化してどかっと書き出すから超速い!これまでそんな奇跡のソフトウェアを作るためにはどうすればいいかを話して来ましたが, 世の中そうそう上手い話ばかりではないのです. 世界中の(生真面目な)ストレージ野郎を悩まさせているのがwrite barrierです. 以前に話したように, ブロックデバイスというのはvolatileなキャッシュを持つことがほとんどです. では, ブロックの上のfilesystemが「書き込んだデータがnon-volatileな物理の上にある」ということを確信するためにはどうすればいいでしょうか?これがwrite barrierです. dm-lcも, この扱いに悩まされました. dm-lcは, 多層のキャッシュを制御しているため, これらをうまく扱うのは容易ではないです. 私が最終的に出した現実解は「遅延 (deferred/lazy I/O)」です.

Linux (3.x)においてwrite barrierを表現しているのは, REQ_FLUSH, REQ_FUAというフラグです. ドキュメント (Documentation/block/writeback_cache_control.txt)を読みましょう. 訳すのはめんどくさいので, 重要なところを赤字にしました. ようするに, REQ_FLUSHは「データなしbioにつけることも出来る. データありbioにつける時は, そのデータを処理する前に, 過去のcompleted I/Osをすべてnon-volatileにしなさい!」というもので, REQ_FUAは「こいつ自体をnon-volatileに書き込みなさい」という意味です.


Explicit volatile write back cache control

Introduction

Many storage devices, especially in the consumer market, come with volatile write back caches. That means the devices signal I/O completion to the operating system before data actually has hit the non-volatile storage. This behavior obviously speeds up various workloads, but it means the operating system needs to force data out to the non-volatile storage when it performs a data integrity operation like fsync, sync or an unmount.

The Linux block layer provides two simple mechanisms that let filesystems control the caching behavior of the storage device. These mechanisms are a forced cache flush, and the Force Unit Access (FUA) flag for requests.

Explicit cache flushes

The REQ_FLUSH flag can be OR ed into the r/w flags of a bio submitted from the filesystem and will make sure the volatile cache of the storage device has been flushed before the actual I/O operation is started. This explicitly guarantees that previously completed write requests are on non-volatile storage before the flagged bio starts. In addition the REQ_FLUSH flag can be set on an otherwise empty bio structure, which causes only an explicit cache flush without any dependent I/O. It is recommend to use the blkdev_issue_flush() helper for a pure cache flush.

Forced Unit Access

The REQ_FUA flag can be OR ed into the r/w flags of a bio submitted from the filesystem and will make sure that I/O completion for this request is only signaled after the data has been committed to non-volatile storage.


dm-lcでは, write I/Oをログ化してから書き込みます. この点が問題になります. 「もし, ログが途中までしか書かれていないのにREQ_FLUSHなりREQ_FUAが降ってきてしまったらどうするの?」というのが問題です. REQ_FLUSHについて, 現状のバッファを同期的にログ書き出ししてしまうのでしょうか?REQ_FUAについて, これはbacking storeに直接forwardingすれば良いのでしょうか?どちらもNOです. 前者は, 性能が落ち過ぎます. 後者は, キャッシュが不整合を起こす可能性を否定出来ません. 後者については, dm-lcでは, 整合性を重視して, 「いかなるデータもログを通さずしてキャッシュデバイス(SSD)に書き込まれることはあり得ない」という一貫したルールに基いた制御をしています.

続いて, dm-lcが何をすれば良いかをはっきりさせるために, device-mapperフレームワークがwrite barrierについてどのような考えを持っているかを調査します.

まず, このメールを見ると,
- It's now guaranteed *1 that all FLUSH bio's which are passed onto dm targets are zero length. bio_empty_barrier() tests are replaced with REQ_FLUSH tests.
- Block layer now filters out REQ_FLUSH/FUA bio's if the request_queue doesn't support cache flushing. Advertise REQ_FLUSH | REQ_FUA capability
ということが書かれています. 「targetに対しては, データのないREQ_FLUSH bioしか出ない」「REQ_FLUSH/REQ_FUAをフィルタすることは出来る (確か, dm_targetのnum_flush_requestsを0にするとフィルタ出来たような気がします)」が要点です.

コード上は,
1. データなしのREQ_FLUSHを発行する.
2. completionで, REQ_FLUSHを引き剥がしてデータ部分のbioを発行する (queue_io). という実装になっています. 具体的なコードは以下です.

(__split_and_process_bio内)

        if (bio->bi_rw & REQ_FLUSH) {
                ci.bio = &ci.md->flush_bio;
                ci.sector_count = 0;
                error = __clone_and_map_empty_flush(&ci);
                /* dec_pending submits any data associated with flush */

(clone_endioの中で呼ばれるdec_pending内)

                if ((bio->bi_rw & REQ_FLUSH) && bio->bi_size) {
                        /*
                         * Preflush done for flush with data, reissue
                         * without REQ_FLUSH.
                         */
                        bio->bi_rw &= ~REQ_FLUSH;
                        queue_io(md, bio);

データありのREQ_FLUSHを受け取った場合, まず, 空のREQ_FLUSHを発行して, それがcompletionされない限りはデータ部分に進まないという実装にしているため, target側では,
1. REQ_FLUSHありのbioを受け取ったらデータは空とみなして良く(データについてはケアしなくてOK)
2. そいつをcompletionしない限りは, REQ_FLUSHのデータ部分は処理されないので, REQ_FLUSH bioの前のデータをnon-volatileにしてからREQ_FLUSHのcompletionを返す実装をすればいい. というように, 問題がより簡単な問題に帰着しました.

では, このようなtargetへの要請に対して, 既存のtargetはどのように対処しているのでしょうか. 例えば, dm-thin (Thin Provisioning target)は, REQ_FLUSHをdeferしています. dm-lcは, 基本的にこの実装をパクりました.

static int thin_bio_map(struct dm_target *ti, struct bio *bio)
{
        int r;
        struct thin_c *tc = ti->private;
        dm_block_t block = get_bio_block(tc, bio);
        struct dm_thin_device *td = tc->td;
        struct dm_thin_lookup_result result;
        struct dm_bio_prison_cell *cell1, *cell2;
        struct dm_cell_key key;

        thin_hook_bio(tc, bio);

        if (get_pool_mode(tc->pool) == PM_FAIL) {
                bio_io_error(bio);
                return DM_MAPIO_SUBMITTED;
        }

        if (bio->bi_rw & (REQ_DISCARD | REQ_FLUSH | REQ_FUA)) {
                thin_defer_bio(tc, bio);
                return DM_MAPIO_SUBMITTED;
        }

事前に実験として, REQ_FLUSHを受け取ったら同期的にログを書き出すという実装を試したことがありますが, xfsを使った場合, これは性能が悪すぎました. xfsにはwrite barrierの機能があります. nobarrierでmountすることによってこれを無効化することは出来ますが, dm-lcが必要とされるようなサーバ環境では適切な対処ではないと思いますし, 運用上もnobarrierがされているケースは少ないと思います. また, nfsやcifsでマウントした場合にも定期的にfsyncが発行されるため, REQ_FLUSHに対して同期的に対処するのは現実的ではありません.

そこで私は, 「timeoutありの遅延I/O」を実装することにしました. 例えば, 30ms経ってもまだバッファがいっぱいにならない場合はその時点で強制的にログ書き出しを行い, REQ_FLUSHのcompletionを返すというものです. dm-lcではこのtimeout時間をbarrier_deadline_msという値で管理しており, sysfsから変更可能です. この実装を行うと, xfsはストレスなしに動くようになりました.

実装としては,
1. REQ_FLUSHの場合, そのままqueue_barrier_ioする. REQ_FUAの場合はデータを持っているため, キャッシュのパスを通した上でバッファにライトして, queue_barrier_ioする, completionは返さない.
2. timeoutするかバッファがいっぱいになるかで, ログが書き出される.
3. まず, バッファのI/Oを同期的に完了させる (backgroundのスレッドでのことなのでforegroundにとっては同期ではありません)
4. 最後に, barrier_iosが溜まっている場合は, そいつらをendioする.
という感じの動作です. 詳しいコードは以下.

                struct dm_io_request io_req = {
                        .client = lc_io_client,
                        .bi_rw = WRITE,
                        .notify.fn = NULL,
                        .mem.type = DM_IO_KMEM,
                        .mem.ptr.addr = ctx->wb->data,
                };
                struct dm_io_region region = {
                        .bdev = cache->device->bdev,
                        .sector = seg->start_sector,
                        .count = (seg->length + 1) << 3, // lengthというのは, バッファ中「dirty足り得る」データの数.
                };
                dm_safe_io_retry(&io_req, &region, 1, false); // バッファのI/O. 同期

                cache->last_flushed_segment_id = seg->global_id;

                // バッファI/Oだん
                complete_all(&seg->flush_done);
                complete_all(&ctx->wb->done);

                DMDEBUG("flush I/O done");

                // barrier iosをendioする.
                if(! bio_list_empty(&ctx->barrier_ios)){
                        blkdev_issue_flush(cache->device->bdev, GFP_NOIO, NULL);
                        struct bio *bio;
                        while((bio = bio_list_pop(&ctx->barrier_ios))){
                                bio_endio(bio, 0);
                        }
                        mod_timer(&cache->barrier_deadline_timer,
                                        msecs_to_jiffies(cache->barrier_deadline_ms));
                }

この実装を見ると分かると思いますが, barrier_iosがない場合にはオーバーヘッドがほとんどありません. これも重要な要件です. また, ログ書き出しというdm-lcの動作と親和性が高かったのも遅延I/Oを採用した理由です. write barrierサポートを導入するための変更は, 局所的な追加で済んでいます. 工数という言葉は嫌いですが, もっともシンプルでコードがダメージを受けない方法を選択するというのも, エンジニアリングでは重要です.

今回は, dm-lcという多層キャッシュ制御プログラムが, write barrierをどのようにhandleしているかということについて説明しました. 本当は, 他にも書くべきことはあるのですが, 私の文章力不足なのか, 文章の柔軟性がもう残っていないように思うのでやめます. 例えば, 当然の疑問として, 「上位に対してnon-volatileだよ!と表明しているデータをmigrateする時に, backing storeのDRAMキャッシュに書いて終わりでは破綻してしますよね?どうしているの?」というものがあります. これについては私も悩みましたが, 「migrateは全部non-volatileに書く」という方針を採用しています. ただし, 一つのsegmentのmigrationをアトミックに行うという実装を採用しているため, これによるペナルティは思われるほど大きくはありません. また, migrateは, backing storeの負荷が低い時にしか実行しませんから, そもそもbacking storeの負荷は大きな問題ではありません.

dm-lcの競合相手の一つに, bcacheがあります. bcacheは, ドキュメント上は, "Barriers/cache flushes are handled correctly."と書いてありますが, 私がコードを眺めた限りでは, どうやっているかが理解出来ませんでした. 実はbcacheがbarrierをサポートしていないのでは?という疑問は数年前に沸き上がっており, 開発者のoverstreet氏が必死に否定していたという経緯があります. ドキュメントにわざわざ「サポートしている!」と書くくらいですから, よほど努力があったのだと思います. どのように実装されているのか分かる人は教えてください. dm-lcは実装がクリーンであり, 理解もしやすいです. これは, 将来的に運用を考える時に, コードが把握出来ないwritebackキャッシュなんて使わないだろうと思うからです. writebackキャッシュというきな臭いソフトウェアです*2から, コードはシンプルでなければいけないという信念に基いています. bcacheのコードはカオスであり, 理解している人はほとんどいないと思います. そんなコードを理解している方は, 勉強のために教えてください.

以上です.

*1:保証されているというのがポイントです. 未来永劫保証されると思います

*2:実装にバグがあれば, データがすっ飛ぶ可能性があります