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

Zig Optimization #5: buffer เดียวเปลี่ยน 1,173 ms เป็น 3 ms และของที่แก้แล้วไม่ต่าง

พี่โชว์Engineering

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

เงื่อนไขการวัด: Apple M5 Pro, macOS, Zig 0.16.0, -O ReleaseFast, init.gpa งานคือเขียน 200,000 บรรทัดที่ผ่านการ format ลงไฟล์ median จาก 5-9 รอบ · โค้ดอยู่ที่ bench/io.zig


กราฟที่ควรจำไว้ทั้งชีวิต

ขนาด buffer      median        เทียบกับ 64 KiB
1 byte         1172.957 ms          375x
64 bytes         94.694 ms           30x
4 KiB             5.126 ms          1.6x
64 KiB            3.127 ms          1.0x
1 MiB             2.898 ms         0.93x

1 ไบต์เทียบกับ 64 กิโลไบต์ ต่างกัน 375 เท่า

นี่คือความต่างของ “เรียกระบบปฏิบัติการหนึ่งครั้งต่อการเขียนหนึ่งครั้ง” กับ “เรียกครั้งเดียวต่อ 64 กิโลไบต์” ไม่มี optimization อื่นในชุดบทความนี้ที่ให้ผลเท่านี้ และมันคือการเปลี่ยนตัวเลขเดียวในโค้ด

แต่กราฟนี้มีอีกครึ่งที่สำคัญพอกัน: จาก 4 KiB ไป 64 KiB ได้แค่ 1.6 เท่า และจาก 64 KiB ไป 1 MiB ได้อีก 7%

แปลว่าประโยชน์เกือบทั้งหมดอยู่ในช่วงต้น ๆ ของกราฟ การเพิ่ม buffer เป็นเมกะไบต์ไม่ได้ทำให้เร็วขึ้นอย่างมีนัยสำคัญ แต่กินแรมเพิ่มจริง 64 KiB คือจุดที่คุ้มที่สุดสำหรับงานทั่วไป และเป็นค่าที่ zlog ใช้

ใน Zig 0.16 คุณเลือกค่านี้เองเสมอ เพราะ writer บังคับให้ส่ง buffer เข้ามา

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

fn writeLines(io: Io, file: Io.File, buffer: []u8, n: usize) !void {
    var fw: Io.File.Writer = .init(file, io, buffer);
    const w = &fw.interface;
    for (0..n) |i| try w.print("line {d}\n", .{i});
    try w.flush();
}

test "buffer เป็นพารามิเตอร์ เปลี่ยนขนาดได้โดยไม่แตะโค้ดที่เขียน" {
    const io = std.testing.io;
    const gpa = std.testing.allocator;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var big: [64 * 1024]u8 = undefined;
    const file = try tmp.dir.createFile(io, "a.txt", .{});
    try writeLines(io, file, &big, 100);
    file.close(io);

    const content = try tmp.dir.readFileAlloc(io, "a.txt", gpa, .limited(4096));
    defer gpa.free(content);

    try std.testing.expect(std.mem.startsWith(u8, content, "line 0\n"));
    try std.testing.expect(std.mem.endsWith(u8, content, "line 99\n"));
}

แยกต้นทุน format ออกจากต้นทุน syscall

พอ buffer เป็น 64 KiB แล้ว ต้นทุนที่เหลืออยู่ที่ไหน ผมวัดโดยตรึง buffer ไว้แล้วเปลี่ยนวิธี format

วิธี (buffer 64 KiB คงที่)      median      เทียบพื้น
w.print ต่อบรรทัด               3.327 ms      3.3x
bufPrint + writeAll             3.549 ms      3.5x
allocPrint + writeAll          11.768 ms     11.6x
writeAll ข้อความคงที่            1.011 ms      1.0x  ← พื้นของงานนี้

สามข้อสรุป

หนึ่ง — allocPrint ในลูปแพงกว่าทุกอย่าง 3.5 เท่า เพราะขอหน่วยความจำหนึ่งครั้งต่อบรรทัด สองแสนบรรทัดคือสองแสนครั้ง ตรงกับผลของบทที่ 2 ทุกประการ

สอง — การ format คิดเป็นประมาณสองในสามของเวลาที่เหลือ (3.327 เทียบกับพื้น 1.011) ถ้างานของคุณเขียนข้อความคงที่ได้ก็จะเร็วขึ้นอีกสามเท่า แต่นั่นมักไม่ใช่งานจริง

สาม — และนี่คือผลที่ผมไม่ได้คาดไว้ bufPrint + writeAll ที่หลายคน (รวมถึงผม) เชื่อว่าจะเร็วกว่า กลับช้ากว่า w.print เล็กน้อย (3.549 เทียบกับ 3.327)

เหตุผลที่น่าจะเป็นคือ w.print เขียนลงใน buffer ของ writer โดยตรง ส่วน bufPrint เขียนลง buffer ชั่วคราวก่อนแล้วค่อย copy เข้า writer อีกที — คือทำงานเพิ่มหนึ่งขั้น ผมไม่ได้ไล่ดู assembly เพื่อยืนยัน แต่ตัวเลขบอกชัดว่าคำแนะนำที่ว่า “หลีกเลี่ยง print ในลูปร้อน” ใช้ไม่ได้กับ Zig 0.16 ในรูปแบบนี้


ฝั่งอ่าน: เรื่องของแรม ไม่ใช่เรื่องของเวลา

จาก workshop บทที่ 2 ผมวัดการนับบรรทัดในไฟล์ 94 MB สองแบบไว้แล้ว

วิธีอ่าน                       เวลา          แรมสูงสุด
streaming (buffer 64 KiB)     0.01-0.02 s     1.8 MB
readFileAlloc (ทั้งไฟล์)       0.03-0.04 s     100 MB

จุดสำคัญไม่ใช่ที่ streaming เร็วกว่า (ซึ่งก็จริงในการวัดนี้) แต่คือ แรมของ streaming ไม่ขึ้นกับขนาดไฟล์เลย ไฟล์ 10 GB ก็ยังใช้ 1.8 MB เท่าเดิม ส่วนอีกวิธีจะพยายามขอ 10 GB แล้วล้มเหลว

นี่คือ optimization ที่ไม่ได้วัดด้วยนาฬิกา แต่วัดด้วย “โปรแกรมทำงานเสร็จหรือตาย”


ของที่วัดแล้ว ไม่ ดีขึ้น

หัวข้อนี้คือส่วนที่ผมคิดว่ามีค่าที่สุดของชุดนี้ทั้งชุด เพราะบทความ optimization ส่วนใหญ่รายงานแต่เคสที่สำเร็จ

สิ่งที่ลองคาดว่าจะผลที่วัดได้จริง
usize → u32 สำหรับ indexเร็วขึ้น0.483 vs 0.485 ms — ไม่ต่าง (ประหยัดแรมได้จริง แต่ไม่เร็วขึ้น)
MemoryPool แทน arenaเร็วขึ้น1.007 vs 0.950 ms — ช้าลงเล็กน้อย ใน workload ที่สร้างอย่างเดียว
bufPrint + writeAll แทน printเร็วขึ้น3.549 vs 3.327 ms — ช้าลงเล็กน้อย
buffer 1 MiB แทน 64 KiBเร็วขึ้นชัดดีขึ้น 7% แต่กินแรมเพิ่ม 16 เท่า
SoA แทน AoSเร็วขึ้นหลายเท่า9% ที่ struct 16 ไบต์
ReleaseFast แทน ReleaseSafeเร็วขึ้นคุ้มกับความเสี่ยงแค่ 6% (จาก workshop) — ไม่คุ้มกับการปิดการตรวจความปลอดภัย

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

ข้อที่ต้องเน้น: ReleaseSafe ช้ากว่า ReleaseFast แค่ 6% ในงานจริงของ zlog การปิดการตรวจ index เกินขอบเขต ตัวเลขล้น และการแกะ optional ว่าง เพื่อแลกกับ 6% เป็นการแลกที่แย่มากในเกือบทุกกรณี


ลำดับสิ่งที่ควรทำจริง

รวมผลทั้งห้าบท เรียงตามผลลัพธ์ที่วัดได้ต่อความพยายามที่ใช้

อันดับทำอะไรผลที่วัดได้ความพยายาม
1build ด้วยโหมด release ไม่ใช่ Debug9-113 เท่าสิบวินาที
2ให้ output มี buffer (อย่าใช้ 1 ไบต์)375 เท่าหนึ่งบรรทัด
3เลิก pointer chasing ใช้ array + index247 เท่าปานกลางถึงมาก
4อย่าขอหน่วยความจำในลูปร้อน3-4 เท่าน้อย
5getOrPut แทนการค้นสามรอบ3 เท่าหนึ่งบรรทัด
6intern key เป็น u328-20 เท่าปานกลาง
7จองความจุล่วงหน้า1.5 เท่าหนึ่งบรรทัด
8AoS → SoA9%ปานกลาง

สามอันดับแรกให้ผลมากกว่าอันดับที่เหลือรวมกัน และสองในสามข้อนั้นใช้เวลาไม่ถึงนาที


กฎที่อยากให้ติดตัวไป

  1. วัดก่อนแก้เสมอ — ในชุดนี้มีห้าเคสที่คำแนะนำยอดนิยมวัดแล้วไม่จริง
  2. ประกาศ build mode และ allocator ทุกครั้งที่รายงานตัวเลข ไม่งั้นตัวเลขตีความไม่ได้
  3. รายงานเคสที่ไม่ดีขึ้นด้วย มันบอกคนอ่านว่าอย่าเสียเวลาไปกับอะไร
  4. ระวัง benchmark ที่โกหกโดยไม่ตั้งใจ — linked list ที่ node เรียงต่อกันพอดี, งานที่ถูกคอมไพเลอร์ลบทิ้ง, ไฟล์ทดสอบที่เล็กกว่า buffer
  5. แรมกับเวลาเป็นคนละเรื่อง บางการเปลี่ยนแปลงชนะเรื่องหนึ่งแต่แพ้อีกเรื่อง ต้องรายงานทั้งคู่
  6. เผยแพร่โค้ดที่ใช้วัด ตัวเลขที่ reproduce ไม่ได้คือความเห็น

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

ห้าบทที่ผ่านมาเราวัดกันจริงทั้งหมด ไม่มีตัวเลขไหนที่ลอกมา สรุปสิ่งที่ได้:

  • บทที่ 1 — std.time.Timer หายไปจาก 0.16 และการเทียบ Debug กับ Release ตรง ๆ ให้ตัวเลขเกินจริงหกเท่า เพราะ allocator เปลี่ยนไปด้วย
  • บทที่ 2 — arena ลดการขอหน่วยความจำจาก 200,001 ครั้งเหลือ 2 ครั้ง และเร็วขึ้น 3.1 เท่า
  • บทที่ 3 — intern key เป็น u32 เร็วกว่าการใช้ข้อความเป็น key 8-20 เท่า
  • บทที่ 4 — pointer chasing ช้ากว่า array 247 เท่า ส่วน SoA ชนะแค่ 9%
  • บทที่ 5 — buffer ของ output คือ optimization ที่คุ้มที่สุดต่อบรรทัดโค้ดที่แก้

และบทเรียนที่ครอบทุกอย่าง: สิ่งที่คนเชื่อกันเรื่อง performance ผิดได้บ่อยกว่าที่คิด ทางเดียวที่จะรู้คือวัด workload ของตัวเองด้วยเครื่องมือที่ตั้งค่าถูกต้อง