🚄

Zig言語でLinuxのパイプの読み書きの効率を上げる実装例(その2)

2023/05/21に公開

さきほどの記事の続きです。

パイプへの書き込みにwrtite(2)でなくvmsplice(2)を使う

この記事によって私はvmsplice(2)の存在をしりました。
https://mazzo.li/posts/fast-pipes.html

パイプに対してwriteするときにはカーネルはカーネル内部にメモリを確保して、そこにコピーを行います。そのため、write(2)はそのメモリコピーが終わった段階でリターンすることができます。この記事によると、カーネル内部にメモリを確保するために空きを探すことが性能のボトルネックになっているということでした。
vmsplice(2)を使うと、ユーザーのバッファをそのままカーネルに渡すことになるので、ユーザー空間からカーネル空間のメモリコピーを省略することができるそうです。
なお、読み込みのときにはカーネル空間からユーザー空間へのメモリコピーは避けることができないため、read(2)を大きく上回る代替手段はないそうです。

https://man7.org/linux/man-pages/man2/vmsplice.2.html

Zig言語での実装

vmsplice(2) は今はまだZigの標準ライブラリには入っていませんでした。なので標準Cライブラリのものを呼び出します。

vmsplice.zig
const std = @import("std");
const fs = std.fs;
const os = std.os;
const c = @cImport({
    @cDefine("_GNU_SOURCE", "");
    @cInclude("fcntl.h");
    @cInclude("sys/uio.h");
    @cInclude("errno.h");
});

fn getErrno() c_int {
    return c.__errno_location().*;
}

pub fn vmspliceSingleBuffer(buf: []const u8, fd: os.fd_t) !void {
    var iov: c.struct_iovec = .{
        .iov_base = @ptrCast(?*anyopaque, @constCast(buf.ptr)),
        .iov_len = buf.len,
    };
    while (true) {
        const n = c.vmsplice(fd, &iov, 1, @bitCast(c_uint, c.SPLICE_F_GIFT));
        if (n < 0) {
            const errno = getErrno();
            switch (errno) {
                c.EINTR => continue,
                c.EAGAIN => unreachable,
                c.EPIPE => return error.BrokenPipe,
                c.EBADF => return error.InvalidFileDescriptor,
                c.EINVAL => return error.InvalidArgument,
                c.ENOMEM => return error.SystemResources,
                else => std.log.err("vmsplice: errno={d}", .{errno}),
            }
        } else if (@bitCast(usize, n) == iov.iov_len) {
            return;
        } else if (n != 0) {
            //std.log.info("vmsplice: return value mismatch: n={d}, iov_len={d}", .{ n, iov.iov_len });
            const un = @bitCast(usize, n);
            iov.iov_len -= un;
            iov.iov_base = @intToPtr(?*anyopaque, @ptrToInt(iov.iov_base) + un);
            continue;
        }
        return error.Vmsplice;
    }
    unreachable;
}

書き込みのところ。
最初に一度だけ、fdがパイプであるかどうかを調べて、isPipeというブーリアンの変数に入れておきます。
それがtrueの場合にはwriter.writeAllの代わりに上で定義したvmspliceSingleBufferを使います。
書き込みのたびに毎回このような判断をするのでなく、書き込みのハンドラ関数そのものを関数ポインタにして差し替えてしまうという最適化ができそうですが、とりあえず今はこれで。

const vms = @import("vmsplice.zig");

        ...
	
    if (isPipe) {
        vms.vmspliceSingleBuffer(buf, outFile.?.handle) catch |err| {
            switch (err) {
                error.BrokenPipe => {},
                else => {
                    log.err("frameHandle: {s}", .{@errorName(err)});
                },
            }
            running = false;
        };
    } else if (outFile) |f| {
        f.writeAll(buf) catch |err| {
            log.err("frameHandle: {s}", .{@errorName(err)});
            running = false;
        };
    } 
    ...

vmsplice(2)に渡すバッファはmmap(2)で得たページサイズにアラインされているメモリである必要がありますが、これはZigの場合では自分で``mmap(2)を使わなくてもアロケータにstd.heap.page_allocatorを選んでalloc`すればその条件をクリアできます。

以下の2つのリポジトリにこの変更はマージしています。
https://github.com/tetsu-koba/v4l2capture
https://github.com/tetsu-koba/convert2i420

効果の確認

とりあえず、この変更で外から見える動作としては等価なものになりました。

性能測定によって効果を確認するべきですが、とりあえず今はvmsplice(2)のシステムコールが使われていることをstraceのログで確認しました。

$ grep vmsplice vmsplicelog/st_write.log 
3256552 vmsplice(3, [{iov_base="\345\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177"..., iov_len=460800}], 1, SPLICE_F_GIFT) = 460800
3256552 vmsplice(3, [{iov_base="\344\200\346\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177"..., iov_len=460800}], 1, SPLICE_F_GIFT) = 460800
3256552 vmsplice(3, [{iov_base="\343\200\346\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177"..., iov_len=460800}], 1, SPLICE_F_GIFT) = 460800
3256552 vmsplice(3, [{iov_base="\343\200\345\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177"..., iov_len=460800}], 1, SPLICE_F_GIFT) = 460800
3256552 vmsplice(3, [{iov_base="\343\200\346\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177"..., iov_len=460800}], 1, SPLICE_F_GIFT) = 460800
3256552 vmsplice(3, [{iov_base="\342\200\345\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177\347\200\347\177"..., iov_len=460800}], 1, SPLICE_F_GIFT) = 460800

...

ToDo

Linuxカーネルのソースコードを読んで、vmsplice(2) のしくみを理解する。
性能評価を行なって有意な差になるかどうかを確認する。

関連

https://zenn.dev/tetsu_koba/articles/e81842ca212d03
https://zenn.dev/tetsu_koba/articles/ee478433b2c410
https://zenn.dev/tetsu_koba/articles/f9c4d250a497c9

Discussion