ภาพวาดโทนมิ้นต์สว่าง อิกัวนาตัวเล็กผูกป้ายปลายทางอันสุดท้ายเข้ากับพัสดุที่ห่อเสร็จแล้วห้าชิ้นที่เรียงกันบนโต๊ะแพ็กของ ป้ายแต่ละอันรูปทรงต่างกัน อันที่กำลังผูกเป็นสีอำพัน

Zig Workshop #6: 1 ล้านบรรทัดใน 62 มิลลิวินาที และส่งขึ้นทุกแพลตฟอร์ม

พี่โชว์Engineering

บทสุดท้ายของ workshop เราจะเติมสิ่งที่ทำให้ zlog เป็นเครื่องมือที่ใช้จริงได้ — สถิติที่มีความหมาย ความเร็วที่วัดได้ และ binary ที่ส่งให้คนอื่นได้ทุกระบบปฏิบัติการ

ตัวเลขทุกตัวในบทนี้วัดบน Apple M5 Pro, macOS, Zig 0.16.0 กับไฟล์ log 94 MB (1,000,000 บรรทัด) ที่สร้างจาก generator ใน repo ด้วย seed คงที่ — ใครก็สร้างไฟล์เดียวกันแล้ววัดซ้ำได้

โค้ดบทนี้อยู่ที่ step-06-ship


เก็บข้อมูลแบบแยกคอลัมน์

เราต้องการเปอร์เซ็นไทล์ที่ 95 ของขนาด response ซึ่งต้องเก็บทุกค่าไว้ก่อนแล้วค่อยเรียง

แบบปกติคือ ArrayList(Record) — เก็บทีละแถว แต่ละแถวมีทั้ง status และ bytes ติดกัน แต่ตอนคำนวณ percentile เราอ่านแค่ bytes อย่างเดียว ทุกครั้งที่ CPU ดึงข้อมูลขึ้นมาหนึ่ง cache line มันจะลาก status ที่ไม่ได้ใช้ขึ้นมาด้วย

MultiArrayList เก็บแยกเป็นคอลัมน์ ทำให้การวนอ่านคอลัมน์เดียวได้ข้อมูลที่ใช้จริงเต็มทุก cache line

const std = @import("std");

pub const Record = struct {
    status: u16,
    bytes: u64,
};

pub const Records = struct {
    rows: std.MultiArrayList(Record) = .empty,

    pub fn deinit(self: *Records, gpa: std.mem.Allocator) void {
        self.rows.deinit(gpa);
    }

    pub fn append(self: *Records, gpa: std.mem.Allocator, status: u16, bytes: u64) !void {
        try self.rows.append(gpa, .{ .status = status, .bytes = bytes });
    }

    /// เรียงบนสำเนา เพื่อไม่ให้ลำดับเดิมของผู้เรียกเปลี่ยน
    pub fn bytesPercentile(self: Records, gpa: std.mem.Allocator, p: u8) !u64 {
        std.debug.assert(p <= 100);
        const n = self.rows.len;
        if (n == 0) return 0;

        const copy = try gpa.dupe(u64, self.rows.items(.bytes)); // ขอเฉพาะคอลัมน์ที่ใช้
        defer gpa.free(copy);

        std.sort.pdq(u64, copy, {}, std.sort.asc(u64));
        return copy[(n - 1) * p / 100];
    }

    pub fn errorRate(self: Records) f64 {
        const n = self.rows.len;
        if (n == 0) return 0;

        var bad: usize = 0;
        for (self.rows.items(.status)) |s| { // วนคอลัมน์เดียว
            if (s >= 400) bad += 1;
        }
        return @as(f64, @floatFromInt(bad)) / @as(f64, @floatFromInt(n));
    }
};

test "percentile และ error rate" {
    const gpa = std.testing.allocator;
    var r: Records = .{};
    defer r.deinit(gpa);

    const sizes = [_]u64{ 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 };
    for (sizes, 0..) |b, i| {
        try r.append(gpa, if (i < 8) 200 else 500, b);
    }

    try std.testing.expectEqual(@as(u64, 50), try r.bytesPercentile(gpa, 50));
    try std.testing.expectEqual(@as(u64, 100), try r.bytesPercentile(gpa, 100));
    try std.testing.expectEqual(@as(u64, 10), try r.bytesPercentile(gpa, 0));
    try std.testing.expectApproxEqAbs(@as(f64, 0.2), r.errorRate(), 0.0001);

    // ข้อมูลเดิมยังเรียงตามลำดับที่ใส่เข้ามา ไม่ถูกเรียงทับ
    try std.testing.expectEqual(@as(u64, 10), r.rows.items(.bytes)[0]);
}

test "SoA ประหยัดที่จากการที่ไม่ต้องมี padding" {
    // Record แบบเรียงต่อกันต้อง align ตาม u64 จึงมีที่ว่างแทรก
    try std.testing.expectEqual(@as(usize, 16), @sizeOf(Record));
    // เก็บแยกคอลัมน์ใช้จริง 2 + 8 = 10 ไบต์ต่อแถว
    try std.testing.expect(@sizeOf(u16) + @sizeOf(u64) < @sizeOf(Record));
}

rows.items(.bytes) คืน slice ของ u64 ล้วน ๆ ที่ต่อเนื่องกันจริง ส่งเข้า sort.pdq ได้ตรง ๆ

เทสสุดท้ายชี้ให้เห็นตัวเลข: Record แบบเรียงต่อกันกิน 16 ไบต์ต่อแถว เพราะต้องเติมที่ว่างให้ u64 ตรงตำแหน่ง ส่วนเก็บแยกคอลัมน์ใช้ 10 ไบต์ ที่หนึ่งล้านแถวคือต่างกัน 6 MB

ข้อควรระวังที่ต้องพูดให้ครบ: SoA ดีเมื่ออ่านไม่ครบทุกฟิลด์ ถ้างานของคุณอ่านทุกฟิลด์ของทุกแถวเสมอ หรือเข้าถึงแบบสุ่มทีละแถว มันอาจแย่กว่า เพราะต้องไปเก็บของจากหลายคอลัมน์มาประกอบกัน


Debug กับ Release ต่างกัน 14 เท่า

ก่อนจะอวดตัวเลขความเร็ว ต้องพูดเรื่องนี้ก่อน — ผมวัดคำสั่งเดียวกันบนไฟล์เดียวกัน ต่างแค่ build mode

zlog top access.log --by=path -n 5   (1,000,000 บรรทัด, median จาก 5 รอบ)

Debug         917 ms
ReleaseSafe    67 ms
ReleaseFast    63 ms

Debug ช้ากว่า ReleaseFast 14.6 เท่า ถ้าใครเอาตัวเลข benchmark ของ Zig มาให้ดูโดยไม่บอก build mode ตัวเลขนั้นแทบไม่มีความหมาย

ที่สำคัญกว่านั้น: ReleaseSafe ช้ากว่า ReleaseFast แค่ 6% ทั้งที่ยังเปิดการตรวจ index เกินขอบเขต ตัวเลขล้น และ optional ว่างไว้ครบ

นั่นคือเหตุผลที่ผมส่ง production ด้วย ReleaseSafe เสมอ — แลก 6% กับการที่บั๊กหยุดโปรแกรมทันทีแทนที่จะให้ตัวเลขผิดเงียบ ๆ ถือว่าคุ้มมาก

zig build -Doptimize=ReleaseSafe
zig build -Doptimize=ReleaseFast

benchmark เทียบกับเครื่องมือที่คนใช้จริง

โจทย์เดียวกันทั้งหมด: อ่าน log 94 MB นับ path แล้วแสดง 5 อันดับแรก วิธีวัด — อุ่นเครื่องหนึ่งรอบ แล้ววัด 7 รอบ รายงาน median กับ min

เครื่องมือ      median      min      เทียบกับ zlog
zlog             62 ms     61 ms     1.0x
python3         342 ms    335 ms     5.5x
awk           2,328 ms  2,306 ms    37.5x

ข้อควรรู้เพื่อความเป็นธรรม: zlog ทำงานมากกว่าสคริปต์อีกสองตัวด้วยซ้ำ เพราะมันแยก status กับ bytes ออกมาด้วย และรวมยอดไบต์ทั้งหมด ส่วนสคริปต์ Python และ awk ดึงแค่ path อย่างเดียว

และตัวเลขนี้คือ เครื่องนี้ ไฟล์นี้ วิธีวัดนี้ ไม่ใช่การจัดอันดับภาษา สคริปต์ awk กับ Python ที่เขียนต่างออกไปย่อมได้ตัวเลขต่างออกไป ทุกอย่างที่ใช้วัดอยู่ใน repo ให้ตรวจสอบและรันซ้ำได้

เรื่องแรม: zlog ใช้แรมสูงสุด 36 MB ตอนประมวลผลไฟล์ 94 MB ซึ่งเกือบทั้งหมดคือตารางบันทึกหนึ่งล้านแถวที่เก็บไว้คำนวณ percentile ถ้าไม่ต้องการ percentile (zlog count) ตัวเลขนี้จะลดลงมาก เพราะไม่ต้องเก็บอะไรเลยนอกจากตัวนับ


ส่งขึ้นทุกแพลตฟอร์มจากเครื่องเดียว

โค้ดบางส่วนของ build.zig — รายการเป้าหมายที่จะปล่อย binary และขั้นตอน release

// โค้ดบางส่วนของ build.zig — รายการเป้าหมายที่จะปล่อย binary
const release_targets = [_]std.Target.Query{
    .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl },
    .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl },
    .{ .cpu_arch = .x86_64, .os_tag = .windows, .abi = .gnu },
    .{ .cpu_arch = .x86_64, .os_tag = .macos },
    .{ .cpu_arch = .aarch64, .os_tag = .macos },
};

const release_step = b.step("release", "สร้าง binary ให้ทุกแพลตฟอร์ม");
for (release_targets) |query| {
    const resolved = b.resolveTargetQuery(query);
    const triple = query.zigTriple(b.allocator) catch @panic("OOM");
    // ... สร้าง module + executable ด้วย .optimize = .ReleaseSafe และ .strip = true ...
    const install = b.addInstallArtifact(rel_exe, .{
        .dest_dir = .{ .override = .{ .custom = b.fmt("release/{s}", .{triple}) } },
    });
    release_step.dependOn(&install.step);
}
zig build release

ผลจริงที่ผมรันจาก MacBook เครื่องเดียว โดยไม่ได้ติดตั้ง cross toolchain อะไรเพิ่มเลย

x86_64-linux-musl/zlog        370,384 bytes
aarch64-linux-musl/zlog       276,944 bytes
x86_64-windows-gnu/zlog.exe   627,712 bytes
x86_64-macos/zlog             284,954 bytes
aarch64-macos/zlog            271,848 bytes

ฝั่ง Linux เป็น static binary ที่ไม่ต้องพึ่ง glibc ของเครื่องปลายทาง — copy ไปวางบน Alpine, Ubuntu หรือ container scratch ก็รันได้เหมือนกัน

ผมคิดว่านี่คือจุดที่ Zig ชนะขาดที่สุดในทางปฏิบัติ ลองนึกภาพว่าต้องทำแบบนี้ด้วย C หรือ C++ ดู


ยืนยันว่ารันได้จริงบน Windows และ Linux

ผมทำงานบน macOS ฉะนั้นสิ่งที่ผมทดสอบด้วยมือคือ macOS เท่านั้น การบอกว่า “รองรับ Windows” โดยไม่มีหลักฐานคือการเดา

repo เลยมี CI ที่รัน zig build test จริงทั้งสามระบบทุกครั้งที่ push

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: mlugg/setup-zig@v2
        with:
          version: 0.16.0
      - run: zig build test
      - run: zig build

สิ่งที่ CI ตรวจให้และผมตรวจเองไม่ได้: การจัดการ \r\n บนระบบไฟล์จริงของ Windows, path separator และพฤติกรรมของ console


ผลลัพธ์สุดท้าย

zig build gen -- 1000000 42 > access.log   # สร้างไฟล์ทดสอบแบบ seed คงที่
zlog top access.log --by=path -n 5 --p95
lines=1000000 skipped=0 bytes=32764526771
p95 bytes=62270 error_rate=0.2493

top 5 by path (10 distinct)
  100835  /health                      ##############################
  100225  /img/logo.png                #############################
  100143  /api/v1/users                #############################
  100124  /index.html                  #############################
  100066  /login                       #############################

แถบกราฟใช้ # เป็นค่าเริ่มต้นและมี --unicode ให้เลือกใช้อักขระบล็อกเต็ม เพราะ console รุ่นเก่าบน Windows ยังมีปัญหากับ code page อยู่ — รายละเอียดเล็ก ๆ แบบนี้แหละที่แยกเครื่องมือที่ใช้ได้จริงออกจากเครื่องมือสาธิต


สรุป workshop ทั้งชุด

หกบทที่ผ่านมาเราสร้างเครื่องมือที่:

  • อ่านไฟล์ขนาดเท่าไหร่ก็ได้ด้วยแรมคงที่ (streaming + buffer ที่เราคุมเอง)
  • แยกวิเคราะห์โดยไม่ copy แม้แต่ไบต์เดียว และรู้ว่าข้อมูลที่ยืมมามีอายุแค่ไหน
  • เก็บสถิติด้วย arena interning ที่ไม่มี leak (มีเทสยืนยัน)
  • สร้างตัวเลือกของ CLI จาก struct ด้วย comptime
  • จัดวางข้อมูลแบบแยกคอลัมน์เพื่อคำนวณ percentile
  • ส่งเป็น binary เดี่ยวให้ห้าแพลตฟอร์มจากเครื่องเดียว

และทุกอย่างนี้ใช้ API ที่นิ่งของ 0.16 ล้วน ไม่แตะของ experimental เลยสักตัว

ชุดถัดไป เราจะเอา zlog ตัวนี้มาเป็นสนามทดลองเรื่อง optimization — วัดกันจริงว่า pattern ที่ติดมาจาก Java, C#, Go หรือ Python ทำให้ช้าลงแค่ไหน แก้แล้วดีขึ้นเท่าไหร่ และเคสไหนที่ แก้แล้วไม่ต่างเลย