
Zig Optimization #5: buffer เดียวเปลี่ยน 1,173 ms เป็น 3 ms และของที่แก้แล้วไม่ต่าง
สี่บทที่ผ่านมาเราวัดเรื่องหน่วยความจำและโครงสร้างข้อมูล บทสุดท้ายว่าด้วยเรื่องที่ให้ผลมากที่สุดต่อความพยายามที่ใช้ — และปิดท้ายด้วยสิ่งที่ผมคิดว่ามีค่าที่สุดในชุดนี้ คือรายการของสิ่งที่ วัดแล้วไม่ดีขึ้น
เงื่อนไขการวัด: 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% เป็นการแลกที่แย่มากในเกือบทุกกรณี
ลำดับสิ่งที่ควรทำจริง
รวมผลทั้งห้าบท เรียงตามผลลัพธ์ที่วัดได้ต่อความพยายามที่ใช้
| อันดับ | ทำอะไร | ผลที่วัดได้ | ความพยายาม |
|---|---|---|---|
| 1 | build ด้วยโหมด release ไม่ใช่ Debug | 9-113 เท่า | สิบวินาที |
| 2 | ให้ output มี buffer (อย่าใช้ 1 ไบต์) | 375 เท่า | หนึ่งบรรทัด |
| 3 | เลิก pointer chasing ใช้ array + index | 247 เท่า | ปานกลางถึงมาก |
| 4 | อย่าขอหน่วยความจำในลูปร้อน | 3-4 เท่า | น้อย |
| 5 | getOrPut แทนการค้นสามรอบ | 3 เท่า | หนึ่งบรรทัด |
| 6 | intern key เป็น u32 | 8-20 เท่า | ปานกลาง |
| 7 | จองความจุล่วงหน้า | 1.5 เท่า | หนึ่งบรรทัด |
| 8 | AoS → SoA | 9% | ปานกลาง |
สามอันดับแรกให้ผลมากกว่าอันดับที่เหลือรวมกัน และสองในสามข้อนั้นใช้เวลาไม่ถึงนาที
กฎที่อยากให้ติดตัวไป
- วัดก่อนแก้เสมอ — ในชุดนี้มีห้าเคสที่คำแนะนำยอดนิยมวัดแล้วไม่จริง
- ประกาศ build mode และ allocator ทุกครั้งที่รายงานตัวเลข ไม่งั้นตัวเลขตีความไม่ได้
- รายงานเคสที่ไม่ดีขึ้นด้วย มันบอกคนอ่านว่าอย่าเสียเวลาไปกับอะไร
- ระวัง benchmark ที่โกหกโดยไม่ตั้งใจ — linked list ที่ node เรียงต่อกันพอดี, งานที่ถูกคอมไพเลอร์ลบทิ้ง, ไฟล์ทดสอบที่เล็กกว่า buffer
- แรมกับเวลาเป็นคนละเรื่อง บางการเปลี่ยนแปลงชนะเรื่องหนึ่งแต่แพ้อีกเรื่อง ต้องรายงานทั้งคู่
- เผยแพร่โค้ดที่ใช้วัด ตัวเลขที่ 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 ของตัวเองด้วยเครื่องมือที่ตั้งค่าถูกต้อง
Navigation
- ก่อนหน้า: Optimization #4: การจัดวางข้อมูล
- โค้ด benchmark ทั้งหมด: showkhun-zig-workshop/bench
- ดูทั้งชุด: บทความแท็ก zig