
Zig Workshop #6: 1 ล้านบรรทัดใน 62 มิลลิวินาที และส่งขึ้นทุกแพลตฟอร์ม
บทสุดท้ายของ 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 ทำให้ช้าลงแค่ไหน แก้แล้วดีขึ้นเท่าไหร่ และเคสไหนที่ แก้แล้วไม่ต่างเลย
Navigation
- ก่อนหน้า: Workshop #5: ให้ comptime สร้าง CLI ให้
- ถัดไป: Optimization #1: ก่อนจะปรับให้เร็ว ต้องวัดให้ถูกก่อน
- โค้ดบทนี้: step-06-ship
- โค้ดฉบับสมบูรณ์: showkhun-zig-workshop
- ดูทั้งชุด: บทความแท็ก zig