🐪

CからZigに書き直して激遅になったと思ったら最適化オプションの変更で改善した話

2023/06/15に公開

メモとして残します。

MJPEGを入力してJPEGに切り出すプログラム

ChatGPT-4 に作らせました。

USBカメラから入力された生のmjpegの映像データを標準入力から受けて、jpegの静止画に切り出してファイルに保存するプログラムをC言語で書いてください。書き出すファイル名はout%03d.jpg という感じでフレーム番号を含めてください。ソースコード内のコメントは英語で書いてください。

最初のものはffmpegやらたくさん外部のライブラリに依存するコードを出してきたので、もっとシンプルなやつにしてくれとお願いしました。できたものはこれ。[1]

a.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define BUF_SIZE 1024
#define OUTPUT_FILENAME_PATTERN "out%03d.jpg"
#define JPEG_START "\xFF\xD8"
#define JPEG_END "\xFF\xD9"

int main(void) {
    char buffer[BUF_SIZE];
    int frame_num = 0;
    int in_frame = 0;
    ssize_t n;
    FILE* output_file;

    while ((n = read(STDIN_FILENO, buffer, BUF_SIZE)) > 0) {
        for (ssize_t i = 0; i < n; i++) {
            if (!in_frame && i < n - 1 && buffer[i] == JPEG_START[0] && buffer[i + 1] == JPEG_START[1]) {
                char filename[32];
                sprintf(filename, OUTPUT_FILENAME_PATTERN, frame_num++);
                output_file = fopen(filename, "wb");
                if (!output_file) {
                    perror("Could not open output file");
                    return 1;
                }
                in_frame = 1;
            }

            if (in_frame) {
                fputc(buffer[i], output_file);
            }

            if (in_frame && i > 0 && buffer[i - 1] == JPEG_END[0] && buffer[i] == JPEG_END[1]) {
                fclose(output_file);
                in_frame = 0;
            }
        }
    }

    if (n < 0) {
        perror("Could not read from stdin");
        return 1;
    }

    return 0;
}

これを zig cc a.cでコンパイルして手元のmjegを食わせたときの時間。

$ time ./a.out < a.mjpeg 

real	0m1.267s
user	0m1.147s
sys	0m0.112s

入力データのa.mjpgはUSBカメラの出力したMJPGをファイルに保存したものです。

Zigで書き直した

実際にはChatGPT-4にZigで書いてと頼んだけど、そのままではビルドも通らないので自分でいろいろ手を加えて動くようにしたもの。

a2.zig
const std = @import("std");
const fs = std.fs;
const io = std.io;
const os = std.os;

const BUF_SIZE = 64 * 1024;
const OUTPUT_FILENAME_PATTERN = "out{d:0>4}.jpg";
const JPEG_START0 = 0xff;
const JPEG_START1 = 0xd8;
const JPEG_END0 = 0xff;
const JPEG_END1 = 0xd9;

pub fn main() !void {
    var allocator = std.heap.page_allocator;
    var buffer: [BUF_SIZE]u8 = undefined;
    var frame_num: usize = 0;
    var in_frame = false;
    var write_buffer = std.ArrayList(u8).init(allocator);
    defer write_buffer.deinit();

    while (true) {
        const n = try io.getStdIn().read(&buffer);
        if (n == 0) break;

        var i: usize = 0;
        while (i < n) : (i += 1) {
            //@setRuntimeSafety(false);
            if (!in_frame) {
                if (i < n - 1 and buffer[i] == JPEG_START0 and buffer[i + 1] == JPEG_START1) {
                    in_frame = true;
                    try write_buffer.append(buffer[i]);
                }
            } else {
                try write_buffer.append(buffer[i]);
                if (i > 0 and buffer[i - 1] == JPEG_END0 and buffer[i] == JPEG_END1) {
                    try writeFile(frame_num, write_buffer.items);
                    frame_num += 1;
                    write_buffer.clearRetainingCapacity();
                    in_frame = false;
                }
            }
        }
    }
}

fn writeFile(frame_num: usize, buf: []const u8) !void {
    var filename_buf: [32]u8 = undefined;
    const filename = try std.fmt.bufPrint(&filename_buf, OUTPUT_FILENAME_PATTERN, .{frame_num});
    const output_file = try fs.cwd().createFile(filename, .{});
    defer output_file.close();
    try output_file.writeAll(buf);
}

//@setRuntimeSafety(false); は後から追加したもので、とりあえずこれは無いものとして話をすすめます。

$ zig build-exe a2.zig
$ time ./a2 < a.mjpeg 

real	3m19.157s
user	3m18.935s
sys	0m0.084s

最初はハングアップしたのかと思って^Cで止めていたのだけど、どうやらとんでもなく遅くなっているということがわかりました。実行結果としては意図した通りであることは確認できました。
sysが少なくて、realとuserがほぼ同じなので、I/Oでブロックされているとかではなくて実際にユーザープロセスでCPUタイムを消費しているのでしょう。しかし、1秒くらいで終わると思ったものに200秒もかかる?!
試しに、-O ReleaseSafe をつけてみたけどほとんど変わりませんでした。

オプティマイズのオプションを変えてみる

-O ReleaseFast にしたら劇的に変わりました。

$ zig build-exe -O ReleaseFast a2.zig
$ time ./a2 < a.mjpeg 

real	0m0.175s
user	0m0.096s
sys	0m0.075s

ついでに -O ReleaseSmall も。

$ zig build-exe -O ReleaseSmall a2.zig
$ time ./a2 < a.mjpeg 

real	0m0.222s
user	0m0.140s
sys	0m0.076s

これだけで、速くなった!
何度か繰り返すとばらつきがあるので、実際には両者はあまり違いはありません。

明示的にランタイムチェックを外してみる

これで推測できるのは、Zigで遅くなったケースでは配列の範囲チェックなどのランタイムチェックがボトルネックになっているのではないかということ。C言語だったら配列の範囲チェックは最初からしないし。
このプログラムではbufferの配列を参照しているが、あらかじめ添字はチェックしているからOutOfBoundsのエラーになることは無いことは確信できます。そこで、ランタイムチェックを明示的に外す指定を追加してみました。これがさきほどコメントアウトしておいた @setRuntimeSafety(false);です。

これをアンコメントしてから、ReleaseSafeでビルドして時間を測ってみました。

$ zig build-exe -O ReleaseSafe a2.zig
$ time ./a2 < a.mjpeg 

real	0m0.214s
user	0m0.094s
sys	0m0.089s

ついでにDebugも。

$ zig build-exe -O Debug a2.zig
$ time ./a2 < a.mjpeg 

real	0m0.978s
user	0m0.866s
sys	0m0.104s

はっきりと @setRuntimeSafety(false); が効いていることが確かめられました。
最後に、使用したzigのバージョンは以下の通り。

$ zig version
0.11.0-dev.3395+1e7dcaa3a
$ zig cc --version
clang version 16.0.1 (https://github.com/ziglang/zig-bootstrap 710c5d12660235bc4eac103a8c6677c61f0a9ded)
Target: aarch64-unknown-linux-musl
Thread model: posix
InstalledDir: /usr/bin

続き

続きの記事があります。
https://zenn.dev/tetsu_koba/articles/ff873cd2ff1f5c

脚注
  1. ChatGPTによる注釈。ただし、この簡単なプログラムではバッファがJPEGのマーカーの途中で分割されると正しく動作しない可能性があります。それは、バッファのサイズ(この場合1024バイト)がJPEGのフレームよりも小さい場合、あるいはフレームがバッファの境界をまたぐように配置される場合に発生します。この問題を解決するには、より複雑なバッファリングやマーカーの探索が必要となります。 ↩︎

Discussion