
Zig Optimization #2: การขอหน่วยความจำสองแสนครั้ง เหลือสองครั้ง
บทที่แล้วเราได้เครื่องมือวัด บทนี้เอามาใช้กับนิสัยที่ทุกคนติดมาจากภาษาที่มี GC
ในภาษาอย่าง Java, C# หรือ Go การสร้าง object ในลูปเป็นเรื่องปกติ เพราะ runtime จัดการให้หมด — ตัวปรับโค้ดวิเคราะห์ว่า object ไม่หลุดออกจาก scope ก็ยกไปไว้บน stack ให้ ส่วน GC รุ่นใหม่เก็บของอายุสั้นได้แทบฟรี
Zig ไม่มี runtime มาช่วย ทุกครั้งที่ขอหน่วยความจำคือการเรียกฟังก์ชันผ่าน vtable แล้วไปทำงานจริงกับ allocator
เงื่อนไขการวัดทั้งบท: Apple M5 Pro, macOS, Zig 0.16.0, -O ReleaseFast, allocator จาก init.gpa, N = 200,000, median จาก 15 รอบ โค้ดอยู่ที่ bench/alloc.zig
ผลรวมทั้งหมดก่อน
วิธี median alloc remap resize free
append ไม่จองล่วงหน้า 0.153 ms 1 21 0 1
ensureTotalCapacity + assumeCapacity 0.102 ms 1 0 0 1
alloc ครั้งเดียว 0.102 ms 1 0 0 1
create ทีละชิ้นในลูป 2.929 ms 200001 0 0 200001
create ทีละชิ้นผ่าน arena 0.950 ms 2 0 523 2
create ทีละชิ้นผ่าน MemoryPool 1.007 ms 19 0 4746 19
สามข้อสังเกตก่อนลงรายละเอียด
- ขอทีละชิ้นในลูปช้ากว่าที่เหลือ 20-29 เท่า — ข้อนี้คือของจริงที่ควรแก้
- arena ลดการคุยกับ allocator ข้างล่างจาก 200,001 ครั้งเหลือ 2 ครั้ง และเร็วขึ้น 3.1 เท่า
- MemoryPool ไม่ได้ชนะ arena สำหรับงานนี้ ทั้งที่ทฤษฎีบอกว่าควรชนะ
เรื่องที่หนึ่ง: จองล่วงหน้าถ้ารู้จำนวน
ArrayList ขยายตัวเองแบบทวีคูณ ไม่ได้ขยายทีละชิ้น การ append สองแสนครั้งจึงไม่ได้แปลว่าขอหน่วยความจำสองแสนครั้ง
const std = @import("std");
test "growCapacity โตแบบทวีคูณ ไม่ใช่ทีละชิ้น" {
const gpa = std.testing.allocator;
var list: std.ArrayList(u64) = .empty;
defer list.deinit(gpa);
var growths: usize = 0;
var last_cap: usize = 0;
for (0..200_000) |i| {
try list.append(gpa, i);
if (list.capacity != last_cap) {
growths += 1;
last_cap = list.capacity;
}
}
// สองแสนชิ้น ใช้การขยายราวยี่สิบกว่ารอบ ไม่ใช่สองแสนรอบ
try std.testing.expect(growths > 15 and growths < 40);
}
ผลที่วัดได้คือ append ธรรมดาเสีย 0.153 ms ส่วนจองล่วงหน้าเสีย 0.102 ms — เร็วขึ้น 1.5 เท่า ไม่ใช่ร้อยเท่าอย่างที่หลายคนคาด เพราะการขยายเกิดแค่ยี่สิบกว่าครั้ง
แต่ 1.5 เท่ายังคุ้มเมื่อรู้จำนวนอยู่แล้ว และที่สำคัญกว่านั้นคือมัน ลดการย้ายข้อมูล ซึ่งแปลว่า pointer หรือ slice ที่ชี้เข้าไปในลิสต์จะไม่ถูกทำให้ใช้ไม่ได้กลางทาง
const std = @import("std");
const N = 100_000;
fn naiveAppend(gpa: std.mem.Allocator) !void {
var list: std.ArrayList(u64) = .empty;
defer list.deinit(gpa);
for (0..N) |i| try list.append(gpa, i);
}
fn reservedAppend(gpa: std.mem.Allocator) !void {
var list: std.ArrayList(u64) = .empty;
defer list.deinit(gpa);
try list.ensureTotalCapacityPrecise(gpa, N); // จองครั้งเดียว
for (0..N) |i| list.appendAssumeCapacity(i); // ไม่ต้องเช็คความจุอีก
}
/// allocator ที่นับทุกการดำเนินการ ไม่ใช่แค่ alloc
const Counting = struct {
child: std.mem.Allocator,
allocs: usize = 0,
remaps: usize = 0,
frees: usize = 0,
fn allocator(self: *Counting) std.mem.Allocator {
return .{ .ptr = self, .vtable = &.{
.alloc = alloc,
.resize = resize,
.remap = remap,
.free = free,
} };
}
fn alloc(ctx: *anyopaque, len: usize, a: std.mem.Alignment, ra: usize) ?[*]u8 {
const self: *Counting = @ptrCast(@alignCast(ctx));
const p = self.child.rawAlloc(len, a, ra) orelse return null;
self.allocs += 1;
return p;
}
fn resize(ctx: *anyopaque, m: []u8, a: std.mem.Alignment, n: usize, ra: usize) bool {
const self: *Counting = @ptrCast(@alignCast(ctx));
return self.child.rawResize(m, a, n, ra);
}
fn remap(ctx: *anyopaque, m: []u8, a: std.mem.Alignment, n: usize, ra: usize) ?[*]u8 {
const self: *Counting = @ptrCast(@alignCast(ctx));
const p = self.child.rawRemap(m, a, n, ra);
if (p != null) self.remaps += 1;
return p;
}
fn free(ctx: *anyopaque, m: []u8, a: std.mem.Alignment, ra: usize) void {
const self: *Counting = @ptrCast(@alignCast(ctx));
self.frees += 1;
self.child.rawFree(m, a, ra);
}
};
test "จองล่วงหน้าแล้วไม่ต้องขยายอีกเลย" {
const gpa = std.testing.allocator;
var a: Counting = .{ .child = gpa };
try naiveAppend(a.allocator());
var b: Counting = .{ .child = gpa };
try reservedAppend(b.allocator());
// จองล่วงหน้า: ขอครั้งเดียว ไม่ขยายอีกเลย
try std.testing.expectEqual(@as(usize, 1), b.allocs);
try std.testing.expectEqual(@as(usize, 0), b.remaps);
// ไม่จอง: ต้องขยายหลายรอบ (จะนับเป็น remap หรือ alloc ใหม่ ขึ้นกับว่า
// allocator ข้างล่างขยายที่เดิมได้หรือเปล่า)
try std.testing.expect(a.allocs + a.remaps > 10);
}
บทเรียนที่โผล่มาระหว่างเขียนเทสนี้: ตอนแรกผมเขียนเทสว่า “ไม่จอง = 1 alloc + 21 remap” ตามที่ benchmark วัดได้ แต่เทสล้มเหลว เพราะได้ 19 alloc
สาเหตุคือเทสใช้ std.testing.allocator ซึ่งเป็น DebugAllocator ที่ ตั้งใจไม่นำที่อยู่เดิมกลับมาใช้ซ้ำ การขยายที่เดิมจึงมักไม่สำเร็จ ต้องขอก้อนใหม่แล้ว copy ส่วน allocator ในโหมด release ขยายที่เดิมได้ เลยถูกนับเป็น remap
โค้ดชุดเดียวกัน มีพฤติกรรมการขอหน่วยความจำต่างกันตาม allocator ที่ใช้ — เป็นเหตุผลข้อที่สองของบทที่แล้วที่ว่าทำไมต้องประกาศ allocator ทุกครั้งที่รายงานตัวเลข
หมายเหตุ: std.testing.FailingAllocator นับให้เฉพาะ alloc ถ้าอยากเห็นการขยายด้วยต้องเขียน wrapper เองแบบข้างบน
เรื่องที่สอง: อย่าขอทีละชิ้นในลูป
create ทีละชิ้นในลูป 2.929 ms (200,001 alloc + 200,001 free)
create ทีละชิ้นผ่าน arena 0.950 ms (2 alloc)
เร็วขึ้น 3.1 เท่า และจำนวนครั้งที่คุยกับ allocator ข้างล่างลดจากสองแสนเหลือสอง
arena ทำงานง่ายมาก — ขอก้อนใหญ่มาก้อนหนึ่ง แล้วแจกย่อยด้วยการเลื่อนตัวชี้ การ “ขอ” หนึ่งครั้งจึงเหลือแค่บวกเลขกับเช็คขอบเขต ส่วนการคืนคือไม่ทำอะไรเลย
จุดสำคัญคือ ฟังก์ชันที่รับหน่วยความจำไม่ต้องรู้เลยว่ามันได้ allocator แบบไหนมา
const std = @import("std");
/// ฟังก์ชันนี้ไม่รู้ว่า allocator ที่ได้มาเป็นแบบไหน และไม่จำเป็นต้องรู้
fn buildNodes(a: std.mem.Allocator, n: usize) ![]*u64 {
const slots = try a.alloc(*u64, n);
for (slots, 0..) |*slot, i| {
const p = try a.create(u64);
p.* = i;
slot.* = p;
}
return slots;
}
test "ฟังก์ชันเดิม ใช้ได้ทั้ง gpa และ arena" {
const gpa = std.testing.allocator;
// แบบที่ 1: ต้องคืนเองทีละชิ้น
{
const slots = try buildNodes(gpa, 100);
defer gpa.free(slots);
defer for (slots) |p| gpa.destroy(p);
try std.testing.expectEqual(@as(u64, 99), slots[99].*);
}
// แบบที่ 2: arena คืนให้ทีเดียว โค้ดคืนหน่วยความจำหายไปหมด
{
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
const slots = try buildNodes(arena.allocator(), 100);
try std.testing.expectEqual(@as(u64, 99), slots[99].*);
}
}
เทียบกับภาษาอื่น:
| ภาษา | วิธีที่เทียบเท่า |
|---|---|
| Java | object pooling — แต่ทุกวันนี้มักแย่กว่าปล่อยให้ GC จัดการ เพราะ escape analysis กับ young-gen GC เร็วมากอยู่แล้ว |
| C# | ArrayPool<T>.Shared, stackalloc, ใช้ struct แทน class |
| Go | sync.Pool, ตรวจว่าอะไรหลุดไป heap ด้วย go build -gcflags=-m |
| C++ | std::pmr::monotonic_buffer_resource |
| Rust | bumpalo หรือ typed-arena |
ความต่างคือใน Zig มันอยู่ใน standard library อยู่แล้ว และเป็นแค่การเปลี่ยนค่าที่ส่งเข้าพารามิเตอร์ ไม่ต้องแก้ฟังก์ชันปลายทางเลยสักบรรทัด
เรื่องที่สาม: เคสที่ทฤษฎีบอกอย่าง ผลวัดบอกอีกอย่าง
MemoryPool ถูกออกแบบมาสำหรับงานแบบนี้พอดี เอกสารในซอร์สเขียนเองว่า “ใช้ตัวนี้เมื่อต้องสร้าง object ชนิดเดียวกันจำนวนมาก เพราะมันเร็วกว่า allocator ทั่วไป”
แต่ผลที่วัดได้คือ
create ทีละชิ้นผ่าน arena 0.950 ms (2 alloc, 523 resize)
create ทีละชิ้นผ่าน MemoryPool 1.007 ms (19 alloc, 4,746 resize)
MemoryPool ช้ากว่า arena เล็กน้อย สำหรับงานนี้
เหตุผลคืองานนี้เป็นการ “สร้างอย่างเดียว ไม่เคยคืน” ซึ่ง arena เก่งที่สุด ส่วนจุดแข็งจริงของ MemoryPool คือ การนำที่เดิมกลับมาใช้ซ้ำ ในงานที่สร้าง ๆ ทิ้ง ๆ สลับกันไปเรื่อย ๆ เช่น node ของต้นไม้ที่ถูกลบและเพิ่มตลอดเวลา — ซึ่ง benchmark นี้ไม่ได้วัด
บทเรียนไม่ใช่ “MemoryPool ไม่ดี” แต่คือ เครื่องมือที่ออกแบบมาเพื่อ workload หนึ่ง ไม่ได้แปลว่าจะชนะใน workload อื่น และวิธีเดียวที่จะรู้คือวัด workload ของตัวเอง
แล้วบรรทัดไหนควรแก้จริง
เรียงตามความคุ้มค่าจากผลที่วัดได้
- เอาการขอหน่วยความจำออกจากลูปร้อน — ถ้าทำได้อย่างเดียวให้ทำข้อนี้ (3 เท่าขึ้นไป)
- ใช้ arena เมื่องานมีจุดจบชัดเจน — ได้ทั้งความเร็วและโค้ดที่สั้นลง
- จองล่วงหน้าเมื่อรู้จำนวน — 1.5 เท่า และข้อมูลไม่ถูกย้ายที่
- อย่าเปลี่ยนไปใช้ MemoryPool เพราะได้ยินว่าเร็ว — วัดก่อน
และข้อที่ศูนย์ซึ่งสำคัญกว่าทุกข้อ: ตรวจว่า build ด้วยโหมดอะไรอยู่ บทที่แล้ววัดได้ว่า Debug ช้ากว่า ReleaseFast หลักสิบเท่าในงานเดียวกัน การเปลี่ยนโหมด build ใช้เวลาสิบวินาที ส่วนการปรับโค้ดใช้เวลาเป็นวัน
สรุปบทนี้
ArrayListโตแบบทวีคูณ — append สองแสนครั้ง = ขยายราวยี่สิบกว่ารอบ ไม่ใช่สองแสนรอบ- จองล่วงหน้าด้วย
ensureTotalCapacityPrecise+appendAssumeCapacityเร็วขึ้น 1.5 เท่า - ขอทีละชิ้นในลูปช้ากว่าผ่าน arena 3.1 เท่า และคุยกับ allocator ข้างล่าง 200,001 ครั้งเทียบกับ 2 ครั้ง
- allocator ที่ต่างกันทำให้โค้ดเดียวกันมีพฤติกรรมการขยายต่างกัน —
DebugAllocatorแทบไม่เคยขยายที่เดิมได้ FailingAllocatorนับเฉพาะallocถ้าอยากเห็นremap/resizeต้องเขียน wrapper เอง- MemoryPool ไม่ชนะ arena ใน workload นี้ ทั้งที่ทฤษฎีบอกว่าควรชนะ
บทหน้าเป็นเรื่องที่คนมาจากภาษา GC ทำผิดหนักที่สุด — การ copy ข้อความ และการใช้ hash map ผิดวิธี
Navigation
- ก่อนหน้า: Optimization #1: วัดให้ถูกก่อน
- ถัดไป: Optimization #3: ข้อความและ hash map
- โค้ด benchmark: bench/alloc.zig
- ดูทั้งชุด: บทความแท็ก zig