テストステ論

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

device-mapperのREQ_WRITE_SAMEサポート調査

dm-lcは先週, RFCが投稿された. しかし未だに一切の返信がない. 辛いし, 半ば憤慨している. よくわからないメルマガが送られてきて, もしやと思ってメールボックスを開いてみると糞メルマガで, そんなことを繰り返してきて, 疲れてきた. コミュニティは冷たい. 土日だけ待ってみようと思うが, 希望は薄いように思う.

それはそれとして, 次の作戦として, upstream向けのパッチを送りつけてやるというのがある. いくらでも嫌がらせのごとく, BANされるまでメールを送りつけてやる. しかし, dm-lcをupstreamに送るに当たって, やらなければならないことがある. それが, WRITE SAMEコマンドへの対応である. upstream向けで実装したあとに, portable codeにもバックポートする可能性があるが, バグると嫌なのでほっとくかも知れない. portable codeは保守的に開発すべきだと思う. 一方, upstreamコードはどんどん走る必要がある.

以下は, dm-linearのコード片である.

        ti->num_flush_bios = 1;
        ti->num_discard_bios = 1;
        ti->num_write_same_bios = 1;

WRITE SAMEとは何だろうか. それはSCSIのコマンドである. Seagateによる仕様書らしきによると,

WRITE SAME command requests that the device server transfer a single logical block from a data-out buffer and write the contents of the logical block, with modifications based on the LBDATA bit and the PBDATA bit, to the specified range of logical block addresses. Each logical block includes user data and may include protection information, based on the WRPROTECT field and the medium format.

とのことである. Linuxカーネルの中に, WRITE SAMEを活用したコードはあるだろうか. YES. dm_kcopyd_copyの一部を見てみよう.

                memset(&job->source, 0, sizeof job->source);
                job->source.count = job->dests[0].count;
                job->pages = &zero_page_list;

                /*
                 * Use WRITE SAME to optimize zeroing if all dests support it.
                 */
                job->rw = WRITE | REQ_WRITE_SAME;
                for (i = 0; i < job->num_dests; i++)
                        if (!bdev_write_same(job->dests[i].bdev)) {
                                job->rw = WRITE;
                                break;
                        }

kcopydというのは, ブロック間コピーである. 例えば典型的な例としては, キャッシュソフトウェアにおけるreadの非同期ステージングがある. 非同期ステージングについて詳しく知りたければ, NetAppによるMercury: Host-side Flash Caching for the Data Centerあたりの論文を読み解けばよい.

さて, このコードは, WRITE SAMEをall destsをzeroingするために使っている. なんとなく, 1ページ分のzero埋め用ページリスト(zero_page_list)をdata-out bufferとして使って, あとはよろしくというコードに見える.

WRITE SAMEを活用した製品としてはVAAIがある(参考). 仮想ディスクをゼロ埋めする処理をストレージ側にオフロードする仕組みである. WRITE SAMEを使わないと, サーバ側からいちいち0ページのwriteコマンドを送る必要があるので, サーバ側のCPUやメモリ帯域を使ってしまうということを言っている.

続いて, device-mapperがWRITE SAMEをどう処理しているかを見ていく.

まず, __send_write_sameという関数が呼ばれる.

static int __split_and_process_non_flush(struct clone_info *ci)
{
        struct bio *bio = ci->bio;
        struct dm_target *ti;
        sector_t len, max;
        int idx;

        if (unlikely(bio->bi_rw & REQ_DISCARD))
                return __send_discard(ci);
        else if (unlikely(bio->bi_rw & REQ_WRITE_SAME))
                return __send_write_same(ci);

__send_write_sameの中身は, __send_changing_extent_onlyである. 引数について説明,
* get_num_biosは, 実際にはnum_write_same_biosを返す. __send_duplicate_biosの中では, target_bio_nrというIDをつけて, split済のclone bioを発行する. これは例えば, RAID0相当のdm-stripeなどで活用されている機能である. dm-stripeでは, そのIDによって, どのstripeに対するI/Oなのかを判別するという実装をとっている. なお, cloneとは言っているが本当にメモリコピーを行うわけではなく, 元bioが持っているメモリ領域をシェアするためオーバーヘッドは少ない.
* is_split_requiredは, NULLが代入されているが, NULLならば通常の分割を行う. 「splitしたくない時にはこいつを設定しろ」という意味であり, 直感に若干反する. しかし読めば分かるので大きな問題ではない.

static void __send_duplicate_bios(struct clone_info *ci, struct dm_target *ti,
                                  unsigned num_bios, sector_t len)
{
        unsigned target_bio_nr;

        for (target_bio_nr = 0; target_bio_nr < num_bios; target_bio_nr++)
                __clone_and_map_simple_bio(ci, ti, target_bio_nr, len);
}

static unsigned get_num_write_same_bios(struct dm_target *ti)
{
        return ti->num_write_same_bios;
}

static int __send_write_same(struct clone_info *ci)
{
        return __send_changing_extent_only(ci, get_num_write_same_bios, NULL);
}

static int __send_changing_extent_only(struct clone_info *ci,
                                       get_num_bios_fn get_num_bios,
                                       is_split_required_fn is_split_required)
{
        struct dm_target *ti;
        sector_t len;
        unsigned num_bios;

        do {
                ti = dm_table_find_target(ci->map, ci->sector);
                if (!dm_target_is_valid(ti))
                        return -EIO;

                /*
                 * Even though the device advertised support for this type of
                 * request, that does not mean every target supports it, and
                 * reconfiguration might also have changed that since the
                 * check was performed.
                 */
                num_bios = get_num_bios ? get_num_bios(ti) : 0;
                if (!num_bios)
                        return -EOPNOTSUPP;

                if (is_split_required && !is_split_required(ti))
                        len = min(ci->sector_count, max_io_len_target_boundary(ci->sector, ti));
                else
                        len = min(ci->sector_count, max_io_len(ci->sector, ti));

                __send_duplicate_bios(ci, ti, num_bios, len);

                ci->sector += len;
        } while (ci->sector_count -= len);

        return 0;
}

device-mapperのサポートは分かった. では, dm-lcはWRITE SAMEをどうサポートすべきだろうか.
num_write_same_biosを1にしなければならない. こうすることで, EOPNOTSUPで返ることがなくなる. 上位にとって嬉しいのは, 小さなゼロ埋めバッファしか用意せずに, 仮想デバイス上の指定領域をゼロ埋め出来るということである. もしここでEOPNOTSUPPが返ってしまうと, 上位レイヤーは, ゼロ埋め努力をしなければならなくなる(注1).
他にあるだろうか. RAMバッファに書くところが若干怪しいがたぶん大丈夫だろう.

以上, WRITE SAMEへのサポートについて検討した. コードを見てみると, discardへの対応がportable向けになっているためこれも修正しなければならない. 道は険しいが, たぶん出来る.

(注1)について. dm-lcはキャッシュブロックが4KBサイズである. 従って, WRITE SAMEをサポートする理由は, 巨大な領域をzeroingするために, 上位レイヤーに対して, 以下のいずれの戦略もとらせずに済むことである.

  1. 領域分のゼロ埋めバッファを用意して, sync writeさせる. もっともお馬鹿な戦略.
  2. 例えば4KB長のゼロ埋めバッファを用意して, それを同期で繰り返し発行する. 遅すぎて発狂.
  3. 2において, 発行は非同期で行い, 全体の同期を自ら行う. パフォーマンス的には最善だが実装が穢れる.

device-mapperモジュールがWRITE SAMEをサポートすると, 3の同期処理を仮想デバイス内に隠蔽出来る. また, dm-lcはキャッシュブロックが4KBなので恩恵はないが, RAIDやlinearのように, 分割領域が非常に大きい場合, 4KB分のbioを発行してその大きな領域のゼロ埋めをデバイス側に任せられるというのは, さらなる恩恵がある.