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

Zig Optimization #1: ก่อนจะปรับให้เร็ว ต้องวัดให้ถูกก่อน

พี่โชว์Engineering

ชุดบทความนี้ห้าบทว่าด้วย optimization ใน Zig โดยยึดกฎข้อเดียว: ทุกตัวเลขต้องวัดเอง ไม่มีการอ้างตัวเลขจากบทความอื่น ไม่มีคำว่า “น่าจะเร็วกว่า”

และก่อนจะวัดอะไรได้ ต้องมีเครื่องมือวัดที่เชื่อถือได้ก่อน ซึ่งใน Zig 0.16 ไม่ใช่เรื่องที่ลอกโค้ดเก่ามาใช้ได้

เครื่องที่ใช้วัดตลอดทั้งชุด: Apple M5 Pro (aarch64), macOS, Zig 0.16.0 จาก Homebrew, cache line 128 ไบต์ · โค้ด benchmark ทั้งหมดอยู่ที่ showkhun-zig-workshop/bench รันซ้ำได้ด้วย zig build bench -Doptimize=ReleaseFast


std.time.Timer ไม่มีแล้ว

ถ้าคุณ Google หา “zig benchmark” จะเจอโค้ดบางส่วนหน้าตาแบบนี้เต็มไปหมด

// โค้ดบางส่วน — ใช้กับ 0.16 ไม่ได้แล้ว
var timer = try std.time.Timer.start();
doWork();
const elapsed = timer.read();

std.time ของ 0.16 เหลือแค่ค่าคงที่หน่วยเวลา (ns_per_ms และเพื่อน ๆ) กับเรื่อง epoch — Timer และ Instant ถูกย้ายเข้าไปอยู่ในระบบ I/O ตามหลักคิดเดียวกับที่บทที่ 4 ของชุดพื้นฐานอธิบายไว้ คือ “อะไรก็ตามที่ไม่แน่นอนหรืออาจต้องรอ ควรผ่าน Io”

ของใหม่คือ std.Io.Clock ซึ่งมีห้าแบบ และการเลือกผิดทำให้ตัวเลขเพี้ยน

clockคืออะไรใช้ทำ benchmark ได้ไหม
.realเวลานาฬิกาจริง ปรับตาม NTP ได้ไม่ได้ เดินถอยหลังได้
.awakeนาฬิกาเดินหน้าอย่างเดียว ไม่นับเวลาที่เครื่องหลับใช้ตัวนี้
.bootเหมือน .awake แต่นับเวลาที่เครื่องหลับด้วยได้ แต่ไม่เหมาะ
.cpu_processเวลา CPU ของโปรเซสใช้เสริมเมื่อมีหลายเธรด
.cpu_threadเวลา CPU ของเธรดใช้เฉพาะกรณี

.awake คือตัวที่ benchmark ใน standard library เองใช้ เอกสารระบุว่ามันรับประกันว่าเวลาจะไม่เดินถอยหลัง แต่สองครั้งติดกันอาจได้ค่าเท่ากันได้ — อย่าเอาไปหารโดยไม่เช็คศูนย์


harness ที่ใช้ตลอดทั้งชุด

standard library ของ Zig ไม่มี benchmark framework ให้ (ผมตรวจแล้ว ไม่มีทั้งใน std.testing และใน build system) มีแต่ตัวอย่างที่เขียนมือไว้ใน std/hash/benchmark.zig ซึ่งใช้เป็นแบบได้ดี

นี่คือ harness ที่ผมใช้ทั้งชุด — สั้นแต่ครบสิ่งที่จำเป็น

const std = @import("std");
const Io = std.Io;

pub const Result = struct {
    median_ns: i96,
    min_ns: i96,

    pub fn medianMs(self: Result) f64 {
        return @as(f64, @floatFromInt(self.median_ns)) / std.time.ns_per_ms;
    }
};

fn now(io: Io) i96 {
    return Io.Clock.awake.now(io).nanoseconds; // std.time.Timer ไม่มีแล้วใน 0.16
}

pub fn measure(
    gpa: std.mem.Allocator,
    io: Io,
    comptime warmup: usize,
    comptime runs: usize,
    ctx: anytype,
    comptime func: anytype,
) !Result {
    var samples: [runs]i96 = undefined;

    // อุ่นเครื่องก่อน ทิ้งผลรอบแรกที่โดน page fault และ CPU ยังไม่เร่ง
    for (0..warmup) |_| std.mem.doNotOptimizeAway(try func(ctx));

    for (&samples) |*slot| {
        const start = now(io);
        const out = try func(ctx);
        std.mem.doNotOptimizeAway(out); // กันคอมไพเลอร์ลบงานทิ้ง
        slot.* = now(io) - start;
    }

    const sorted = try gpa.dupe(i96, &samples);
    defer gpa.free(sorted);
    std.sort.pdq(i96, sorted, {}, std.sort.asc(i96));

    return .{ .median_ns = sorted[runs / 2], .min_ns = sorted[0] };
}

const Ctx = struct { gpa: std.mem.Allocator, n: usize };

fn buildList(c: Ctx) !u64 {
    var list: std.ArrayList(u64) = .empty;
    defer list.deinit(c.gpa);
    for (0..c.n) |i| try list.append(c.gpa, i);
    return list.items[c.n - 1];
}

test "harness วัดงานจริงและเรียงผลได้" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    const r = try measure(gpa, io, 1, 5, Ctx{ .gpa = gpa, .n = 10_000 }, buildList);

    try std.testing.expect(r.min_ns <= r.median_ns);
    try std.testing.expect(r.median_ns >= 0);
}

สังเกตว่าเวลาเก็บเป็น i96 เพราะ Io.Timestamp.nanoseconds เป็นชนิดนั้น

รายงาน median กับ min ไม่ใช่ค่าเฉลี่ย — ค่าเฉลี่ยถูกค่าผิดปกติหนึ่งค่าดึงจนเพี้ยนได้ง่าย min คือ “เครื่องนี้ทำได้เร็วสุดเท่าไหร่ถ้าไม่มีอะไรมากวน” ส่วน median คือ “ปกติแล้วผู้ใช้จะเจออะไร”


doNotOptimizeAway: บรรทัดที่ขาดไม่ได้

ถ้าไม่มีบรรทัดนี้ คอมไพเลอร์ในโหมด ReleaseFast จะเห็นว่าผลลัพธ์ไม่ได้ถูกใช้ แล้วลบงานทั้งก้อนทิ้ง ได้ตัวเลข 0.000 ms ที่ดูน่าตื่นเต้นแต่ไม่มีความหมาย

ผมเจอกับตัวเองระหว่างทำชุดนี้ — benchmark ตัวหนึ่งที่ขอหน่วยความจำสองแสนครั้งขึ้นว่า 0.000 ms เพราะผมคืนหน่วยความจำทันทีในลูป คอมไพเลอร์เลยลบทั้งลูปทิ้งได้ ต้องแก้ให้เก็บ pointer ไว้จริงถึงจะวัดได้

std.mem.doNotOptimizeAway มีอยู่จริงใน 0.16 (ผมเช็คในซอร์สแล้ว) และมีข้อจำกัดที่ต้องรู้

const std = @import("std");

test "doNotOptimizeAway ไม่ทำงานตอน comptime" {
    comptime {
        var x: u64 = 7;
        std.mem.doNotOptimizeAway(x); // เป็น no-op ที่ comptime
        x += 1;
        std.debug.assert(x == 8);
    }
}

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


กับดักที่ทำให้ตัวเลขที่คนแชร์กันเกินจริง

ทีนี้ถึงส่วนสำคัญที่สุดของบทนี้

ผมวัดงานเดียวกัน (สร้าง list 200,000 ตัว) ในสี่ build mode ได้แบบนี้

build mode      median      เทียบ ReleaseFast
Debug            5.975 ms        113x
ReleaseSafe      0.236 ms        4.5x
ReleaseSmall     0.195 ms        3.7x
ReleaseFast      0.053 ms        1.0x

113 เท่า ดูน่าตกใจ และเป็นตัวเลขแบบที่คนชอบเอาไปโพสต์

แต่มันไม่จริงทั้งหมด เพราะ init.gpa ที่ Zig 0.16 ส่งให้ main เปลี่ยน allocator ตาม build mode — โหมด Debug ได้ DebugAllocator ที่เก็บ stack trace ทุกการขอและไม่นำที่อยู่เดิมกลับมาใช้ซ้ำ ส่วนโหมด Release ได้ allocator ที่เร็วกว่ามาก

แปลว่าการเทียบ Debug กับ ReleaseFast เปลี่ยนสองตัวแปรพร้อมกัน

ผมเลยเพิ่มตัวเลือกให้ benchmark ตรึง allocator เป็น DebugAllocator ทั้งสองฝั่ง แล้ววัดใหม่

                              init.gpa      DebugAllocator (ตรึง)
Debug                          6.029 ms            6.675 ms
ReleaseFast                    0.100 ms            0.725 ms

อัตราส่วน Debug/ReleaseFast     60x                  9.2x

พอตรึง allocator แล้ว ความต่างเหลือ 9.2 เท่า ที่เหลืออีกหกเท่ากว่าคือผลของการสลับ allocator ล้วน ๆ

หรือมองอีกมุม: ในโหมด ReleaseFast เดียวกัน แค่เปลี่ยนจาก allocator ปกติไปเป็น DebugAllocator ก็ช้าลง 7.3 เท่า แล้ว ซึ่งตรงกับที่เอกสารในซอร์สของมันเขียนไว้เองว่าออกแบบมาให้ช้าและเปลืองเพื่อแลกกับการตรวจจับข้อผิดพลาด

บทเรียน: ทุก benchmark ต้องประกาศ ทั้ง build mode และ allocator ถ้าขาดข้อใดข้อหนึ่ง ตัวเลขนั้นตีความไม่ได้


กฎที่ใช้ตลอดชุดนี้

  1. อุ่นเครื่องอย่างน้อยหนึ่งรอบ ทิ้งผลรอบแรกเสมอ
  2. วัดอย่างน้อย 9-15 รอบ รายงาน median กับ min ห้ามรายงานค่าเฉลี่ยเดี่ยว ๆ
  3. input เหมือนเดิมทุกรอบ และ seed คงที่ ถ้ามีการสุ่ม
  4. พิมพ์ build mode และชื่อ allocator ออกมาในผลลัพธ์ — harness ของผมพิมพ์ให้อัตโนมัติ
  5. ประกาศเครื่องที่วัด ชิป, OS, เวอร์ชัน Zig
  6. ป้อนผลลัพธ์เข้า doNotOptimizeAway และตรวจว่าตัวเลขไม่ใช่ 0
  7. เผยแพร่โค้ดที่ใช้วัด ตัวเลขที่ reproduce ไม่ได้คือความเห็น ไม่ใช่ข้อมูล
  8. รายงานเคสที่วัดแล้วไม่ดีขึ้นด้วย — บทที่ 5 ของชุดนี้ทั้งบทเป็นเรื่องนั้น

สิ่งที่ยังวัดไม่ได้ด้วย harness นี้

หน่วยความจำสูงสุด (peak RSS) วัดจากในโปรแกรมตัวเองไม่ได้ตรง ๆ ต้องวัดจากข้างนอกหรือวัดโปรเซสลูก

# macOS — หน่วยเป็น byte ไม่ใช่ KB
/usr/bin/time -l ./zig-out/bin/zlog top access.log 2>&1 | grep "maximum resident"

# Linux — หน่วยเป็น KB
/usr/bin/time -v ./zlog top access.log 2>&1 | grep "Maximum resident"

ถ้าอยากได้จากในโปรแกรม Zig เอง มี std.process.Child ที่ตั้ง request_resource_usage_statistics = true แล้วอ่าน getMaxRss() ซึ่งจัดการเรื่องหน่วยต่างกันของแต่ละ OS ให้เรียบร้อย — เป็นวิธีเดียวกับที่ std.Build ใช้รายงาน peak RSS ของแต่ละขั้นตอน

จำนวนครั้งที่ขอหน่วยความจำ นับได้จากในโปรแกรม และเป็นตัวเลขที่มีประโยชน์มากกว่าเวลาในหลายกรณี เพราะมันไม่แกว่งตามภาระเครื่อง — บทหน้าจะใช้หนัก


สรุปบทนี้

  • std.time.Timer ถูกลบใน 0.16 ใช้ std.Io.Clock.awake.now(io).nanoseconds แทน และเวลาเป็น i96
  • std ไม่มี benchmark framework — เขียน harness เองราวร้อยบรรทัด
  • std.mem.doNotOptimizeAway จำเป็นเสมอ และ ไม่ทำงานตอน comptime หรือกับค่าคงที่
  • init.gpa เปลี่ยน allocator ตาม build mode ทำให้การเทียบ Debug กับ Release เป็นการเปลี่ยนสองตัวแปรพร้อมกัน
  • ตัวเลขจริง: naive 113 เท่า → พอตรึง allocator เหลือ 9.2 เท่า และ allocator อย่างเดียวคิดเป็น 7.3 เท่า
  • benchmark ที่ไม่บอก build mode + allocator + เครื่อง + โค้ด คือความเห็น ไม่ใช่ข้อมูล

บทหน้าเริ่มของจริง — วัดว่านิสัยการขอหน่วยความจำที่ติดมาจากภาษาอื่นทำให้ช้าลงแค่ไหน และแก้แล้วดีขึ้นเท่าไหร่