ในบทเรื่อง memory ของ C คุณเรียนไปแล้วว่าค่าทุกตัวอยู่ที่ไหนสักแห่งเสมอ: บน stack (เร็ว อัตโนมัติ หายไปทันทีที่ฟังก์ชัน return) หรือบน heap (อยู่ได้นานเท่าที่คุณต้องการ แต่คุณต้องขอมันจากระบบปฏิบัติการด้วยอะไรทำนอง malloc และต้องคืนมันเองด้วย) คุณยังเรียนไปแล้วด้วยว่า pointer ก็แค่ตัวเลขตัวหนึ่ง — ที่อยู่ของ byte หนึ่งในหน่วยความจำ บทนี้จะเอาสองไอเดียนั้นมาใช้แก้ปัญหาที่ปฏิบัติจริงมาก: การเรียก malloc กับ new ตรง ๆ นับพันครั้งต่อวินาที ตลอดทั้ง session ที่เล่นเกม คือสิ่งที่เกมที่จะ ship จริงทำไม่ได้เด็ดขาด คุณกำลังจะสร้างเครื่องมือที่ engine จริงใช้แทน: allocator (โค้ดชิ้นเล็ก ๆ ที่จัดการ block หน่วยความจำให้คุณ) ที่ขอหน่วยความจำจาก OS แค่ครั้งเดียว แล้วแจกจ่ายมันเองต่อจากนั้น เร็วและคาดเดาได้
malloc กับ new เป็นเครื่องมืออเนกประสงค์ มันต้องรองรับทุกขนาด เรียกจาก thread ไหนก็ได้ ปล่อย (free) ในลำดับไหนก็ได้ ตลอดอายุของโปรแกรม ความอเนกประสงค์นั้นมีราคาที่ต้องจ่าย และมันเสียค่าใช้จ่ายกับคุณสามทาง:
malloc พร้อมกันแล้วทำข้อมูลภายในพังมาดูค่าใช้จ่ายด้านความเร็วกันตรง ๆ นี่คือโปรแกรมเล็ก ๆ ที่รันได้จริง ทำแค่ allocate แล้ว free ซ้ำ ๆ แล้ววัดเวลาที่ใช้:
#include <chrono>
#include <cstdio>
int main() {
const int COUNT = 2000000;
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < COUNT; i++) {
int* p = new int[16]; // 64 bytes each
p[0] = i; // touch it, so the compiler can't optimize the alloc away
delete[] p;
}
auto end = std::chrono::high_resolution_clock::now();
double ms = std::chrono::duration<double, std::milli>(end - start).count();
printf("2,000,000 new/delete pairs: %.2f ms\n", ms);
return 0;
}
รันบนโน้ตบุ๊กทั่ว ๆ ไปโดยปกติจะปริ้นท์ประมาณนี้:
2,000,000 new/delete pairs: 58.31 ms
ตัวเลขที่แน่นอนขึ้นอยู่กับ CPU, allocator ของ OS ที่คุณใช้ และโปรแกรมอื่นที่รันอยู่ — นั่นแหละคือประเด็น มันคาดเดาไม่ได้ แต่รูปแบบโดยรวมจะเหมือนกันทุกที่: ไม่กี่สิบนาโนวินาทีต่อการเรียกหนึ่งครั้งบวกกันไปเรื่อย ๆ เร็วมาก และภายใต้ load จริง — หลาย thread แย่ง lock ของ heap เดียวกัน กับ fragmentation ที่ทำให้การค้นหาชิ้นว่างช้าลง — ตัวเลขนั้นจะแย่ลงไปอีก และแย่ลงในแบบที่คุณคาดเดาล่วงหน้าได้ยาก ความคาดเดาไม่ได้นี่แหละคือศัตรูตัวจริงของโค้ดที่ต้องทำเวลาให้ทันงบเฟรมคงที่ทุก ๆ เฟรม
นี่คือส่วนที่แย่ลงตามระยะเวลาที่เกมรัน ไม่ใช่ตามจำนวน allocation ต่อเฟรม ลองนึกภาพ heap หลังเล่นเกมมาสักพักใหญ่ ๆ: object หลายขนาดถูก allocate แล้ว free ไปในลำดับที่ไม่เหมือนกับลำดับที่ขอมาเลย
"ว่าง" ไม่ได้แปลว่า "ใช้ได้" allocator แจกจ่ายได้แค่ช่วง byte ที่ต่อเนื่อง (contiguous) ไม่ขาดตอนเท่านั้น — มันเอาช่องว่าง 20 byte สามช่องที่แยกกันมาต่อกันเป็น 60 byte ไม่ได้ ทุกครั้งที่เกม allocate แล้ว free object หลายขนาดในลำดับที่ต่างจากลำดับที่มันเข้ามา มันจะทิ้งช่องว่างแบบนี้ไว้ ตลอด session สองชั่วโมง ด้วย object ชั่วคราวนับพันทุกขนาดที่ผ่านเข้าออก heap เดียวกัน ช่องว่างพวกนี้จะสะสมจนกระทั่งแม้แต่ allocation เล็ก ๆ ก็ล้มเหลวได้ — ไม่ใช่เพราะหน่วยความจำหมด แต่เพราะหน่วยความจำที่ต่อเนื่องหมด
ทางแก้ที่ game engine แทบทุกตัวใช้พูดง่าย ๆ คือ: เลิกเรียก malloc/new ระหว่างเล่นเกมไปเลย แทนที่จะทำแบบนั้น ตอน startup ให้ขอหน่วยความจำก้อนใหญ่ก้อนเดียวจาก OS ด้วยการเรียกครั้งเดียว — มากกว่าที่ต้องใช้จริงสำหรับสิ่งเดียวมาก แต่เป็นจำนวนคงที่ที่รู้แน่นอน จากนั้นเป็นต้นไป ทุกระบบในเกม (physics, audio, rendering, AI) จะขอหน่วยความจำจากโค้ดของคุณเอง และโค้ดของคุณก็แค่ทำเลขคณิตกับ pointer ง่าย ๆ เพื่อแจกจ่ายชิ้นส่วนของ block นั้น ไม่มีการเรียก OS อีกเลย ไม่มี lock อีกเลย ไม่มีเซอร์ไพรส์อีกเลย ตลอดอายุที่เหลือของโปรแกรม
#include <cstdlib>
#include <cstdio>
class MemoryArena {
public:
void Init(size_t sizeBytes) {
base = static_cast<unsigned char*>(std::malloc(sizeBytes));
capacity = sizeBytes;
used = 0;
}
void Shutdown() {
std::free(base);
base = nullptr;
}
unsigned char* base = nullptr;
size_t capacity = 0;
size_t used = 0;
};
int main() {
MemoryArena arena;
arena.Init(64 * 1024 * 1024); // 64 MB, ONE malloc call for the whole game
printf("Reserved %zu bytes from the OS in a single call.\n", arena.capacity);
printf("From here on, every game system asks THIS object for memory,\n");
printf("never malloc/new directly.\n");
arena.Shutdown();
return 0;
}
Reserved 67108864 bytes from the OS in a single call.
From here on, every game system asks THIS object for memory,
never malloc/new directly.
ทุกอย่างที่เหลือในบทนี้คือกลยุทธ์ที่ต่างกันในการสับ block ก้อนนั้น: วิธีตัดสินใจว่าจะแจก byte ไหนต่อไป และวิธีรู้ว่าเมื่อไหร่จะเอามันกลับมาใช้ซ้ำได้ ข้อมูลของเกมแต่ละแบบมีอายุ (lifetime) ต่างกัน (ต่อเฟรม, ต่อด่าน, ต่อ object, ตลอดกาล) และแต่ละกลยุทธ์ด้านล่างนี้ถูกออกแบบมาให้เข้ากับ lifetime แบบใดแบบหนึ่งโดยเฉพาะ
ก่อนจะสร้าง allocator ตัวไหนก็ตาม มีเลขคณิต pointer อยู่ชิ้นหนึ่งที่ทุกตัวต้องใช้: alignment (การจัดแนว) alignment หมายความว่าที่อยู่นั้นเป็นจำนวนเท่าของตัวเลขบางตัว ปกติจะเป็นเลขยกกำลังสอง (4, 8, 16...) CPU ไม่ได้อ่านหน่วยความจำทีละ byte มันอ่านเป็นก้อนขนาดคงที่ (word) และคำสั่งฮาร์ดแวร์ (โดยเฉพาะคำสั่ง SIMD ที่ทำงานกับ 16 byte พร้อมกัน) มักจะบังคับเลยว่าข้อมูลต้องเริ่มที่ที่อยู่ที่ align แล้วเท่านั้น
#include <cstdint>
#include <cstdio>
uintptr_t AlignUp(uintptr_t address, size_t align) {
// align MUST be a power of two (4, 8, 16, ...)
return (address + align - 1) & ~(align - 1);
}
int main() {
printf("alignof(int) = %zu\n", alignof(int));
printf("alignof(double) = %zu\n", alignof(double));
uintptr_t addr = 1001; // pretend this is a raw byte address
printf("1001 aligned to 4 -> %llu\n", (unsigned long long)AlignUp(addr, 4));
printf("1001 aligned to 16 -> %llu\n", (unsigned long long)AlignUp(addr, 16));
return 0;
}
alignof(int) = 4
alignof(double) = 8
1001 aligned to 4 -> 1004
1001 aligned to 16 -> 1008
AlignUp เป็นเทคนิคที่ควรเข้าใจ ไม่ใช่แค่ท่องจำ: align - 1 คือชุดของบิต 1 ต่อกัน (สำหรับ align = 16 คือ 0b1111) การบวกมันเข้ากับ address ดันค่าให้เลยขอบเขตถัดไป แล้วการทำ AND กับ ~(align - 1) (บิตต่ำเหล่านั้นถูกกลับด้านและ invert) จะเคลียร์บิตต่ำลงมา ปัดผลลัพธ์ลงมาเป็นจำนวนเท่าที่ใกล้ที่สุดของ align ที่เท่ากับหรือมากกว่าที่อยู่เดิม allocator ทุกตัวในบทนี้ใช้สูตรนี้เป๊ะ ๆ
alignof(T) สำหรับ type T หรือ 16 สำหรับข้อมูล vector แบบ SIMD ไม่ต้องมากกว่านั้นallocator ที่ง่ายที่สุดเท่าที่จะเป็นไปได้เก็บตัวเลขแค่ตัวเดียว: offset ภายใน block เริ่มที่ 0 Alloc(size) จะ align offset ตรวจว่าที่ว่างพอไหม แล้วส่งคืน pointer ที่ offset ปัจจุบัน จากนั้นขยับ offset ไปข้างหน้าอีก size เท่านั้นเอง — ไม่มีการค้นหา ไม่มี bookkeeping ต่อ allocation นี่เรียกว่า linear allocator หรือ arena และการกระทำนี้มักถูกเรียกว่า "bump pointer" (กระแทก pointer)
#include <cstdint>
#include <cstddef>
class LinearAllocator {
public:
void Init(void* memory, size_t sizeBytes) {
base = static_cast<unsigned char*>(memory);
capacity = sizeBytes;
offset = 0;
}
void* Alloc(size_t sizeBytes, size_t align = 8) {
uintptr_t current = reinterpret_cast<uintptr_t>(base) + offset;
uintptr_t aligned = (current + align - 1) & ~(align - 1);
size_t padding = aligned - current;
if (offset + padding + sizeBytes > capacity) {
return nullptr; // out of space in this arena
}
offset += padding + sizeBytes;
return reinterpret_cast<void*>(aligned);
}
void Reset() {
offset = 0; // "free" EVERYTHING in one step -- no per-object work at all
}
private:
unsigned char* base = nullptr;
size_t capacity = 0;
size_t offset = 0;
};
malloc จะส่งคืนหน่วยความจำที่ align อย่างน้อย alignof(std::max_align_t) เสมอ ซึ่งบนเครื่อง 64-bit ทั่ว ๆ ไปคือ 16 byte — ดังนั้นถ้าเราใช้ malloc เป็นฐานให้ arena นี้ offset 0 ก็จะเข้ากับ 8-byte alignment อยู่แล้ว นั่นทำให้เรา trace offset ที่แน่นอนซึ่งการเรียกเป็นลำดับจะคืนกลับมาได้:
#include <cstdlib>
#include <cstdio>
int main() {
unsigned char* memory = static_cast<unsigned char*>(std::malloc(256));
LinearAllocator arena;
arena.Init(memory, 256);
void* a = arena.Alloc(40);
void* b = arena.Alloc(30);
void* c = arena.Alloc(60);
printf("a offset = %td\n", (unsigned char*)a - memory);
printf("b offset = %td\n", (unsigned char*)b - memory);
printf("c offset = %td\n", (unsigned char*)c - memory);
arena.Reset();
void* d = arena.Alloc(10);
printf("d offset after Reset() = %td\n", (unsigned char*)d - memory);
std::free(memory);
return 0;
}
a offset = 0
b offset = 40
c offset = 72
d offset after Reset() = 0
ไล่ดูด้วยมือ: a เริ่มที่ offset 0 (align อยู่แล้ว) ใช้ไป 40 byte offset จึงกลายเป็น 40 b เริ่มที่ 40 (40 เป็นจำนวนเท่าของ 8 อยู่แล้ว ไม่ต้องมี padding) ใช้ไป 30 byte offset กลายเป็น 70 c เริ่มที่ 70 — แต่ 70 ไม่ใช่จำนวนเท่าของ 8 จึงต้องแทรก padding 2 byte เพื่อไปถึง 72 แล้วใช้ไปอีก 60 byte offset กลายเป็น 132 พอเรียก Reset() offset ก็สแนปกลับไป 0 ทันที ทำให้ d เริ่มที่ตำแหน่งเดียวกับที่ a เคยเริ่ม
นี่คือ allocator ที่ควรหยิบใช้เมื่อไหร่ก็ตามที่ข้อมูลแค่ต้องอยู่รอดในเฟรมเดียว: scratch transform สำหรับ render pass, buffer ชั่วคราวสำหรับการค้นหา pathfinding, การ format string สำหรับ debug overlay ขอ block ครั้งเดียวตอน startup Alloc() จากมันตลอดทั้งเฟรม แล้ว Reset() ตอนต้นเฟรมถัดไป fragmentation ไม่ใช่แค่ไม่น่าจะเกิดที่นี่ — มันเกิดขึ้นไม่ได้เลย เพราะไม่มีอะไรถูก free ทีละชิ้นเลย มันคือ all-or-nothing ซึ่งก็เป็นข้อจำกัดของมันด้วยเช่นกัน
Free(void* ptr) เลย — นั่นตั้งใจ ไม่ใช่ลืมใส่ ถ้าคุณพบว่าตัวเองอยากจะ free แค่ allocation เดียวออกจาก linear allocator แปลว่าคุณต้องการเครื่องมือคนละแบบ: stack allocator หรือ pool allocator ซึ่งกำลังจะมาถัดไปstack allocator คือ linear allocator ที่มีลูกเล่นเพิ่มมาอย่างหนึ่ง: คุณสามารถบันทึก offset ปัจจุบันไว้เป็น marker ยังคง allocate ต่อไปได้ แล้วภายหลังค่อยดึง offset กลับไปที่ marker นั้น — free ทุกอย่างที่ allocate หลังจากมัน ในขั้นตอนเดียว โดยไม่แตะต้องอะไรที่ allocate ไว้ก่อนหน้า ข้อแม้อยู่ในชื่อของมันเอง: มันทำงานได้แบบ LIFO เท่านั้น (Last In, First Out เข้าทีหลังออกก่อน กฎลำดับเดียวกับที่ call stack เองก็ใช้) คุณต้อง free marker ในลำดับย้อนกลับจากที่คุณเอามันมา
#include <cstdint>
#include <cstddef>
class StackAllocator {
public:
using Marker = size_t;
void Init(void* memory, size_t sizeBytes) {
base = static_cast<unsigned char*>(memory);
capacity = sizeBytes;
offset = 0;
}
void* Alloc(size_t sizeBytes, size_t align = 8) {
uintptr_t current = reinterpret_cast<uintptr_t>(base) + offset;
uintptr_t aligned = (current + align - 1) & ~(align - 1);
size_t padding = aligned - current;
if (offset + padding + sizeBytes > capacity) {
return nullptr;
}
offset += padding + sizeBytes;
return reinterpret_cast<void*>(aligned);
}
Marker GetMarker() const {
return offset;
}
void FreeToMarker(Marker marker) {
offset = marker; // everything allocated AFTER this marker is gone
}
private:
unsigned char* base = nullptr;
size_t capacity = 0;
size_t offset = 0;
};
#include <cstdlib>
#include <cstdio>
int main() {
unsigned char* memory = static_cast<unsigned char*>(std::malloc(256));
StackAllocator stack;
stack.Init(memory, 256);
void* levelData = stack.Alloc(64); // lives for the whole level
StackAllocator::Marker frameMark = stack.GetMarker();
void* pathBuffer = stack.Alloc(48); // this frame's scratch work
printf("offset with pathBuffer alive: %zu\n", stack.GetMarker());
stack.FreeToMarker(frameMark); // done with this frame's scratch
printf("offset after FreeToMarker: %zu\n", stack.GetMarker());
void* nextFrameBuffer = stack.Alloc(20); // reuses the bytes pathBuffer used
printf("nextFrameBuffer offset: %td\n", (unsigned char*)nextFrameBuffer - memory);
std::free(memory);
return 0;
}
offset with pathBuffer alive: 112
offset after FreeToMarker: 64
nextFrameBuffer offset: 64
levelData เอา 64 byte แรกไป marker จึงถูกบันทึกไว้ที่ offset 64 pathBuffer เอาไปอีก 48 byte ดัน offset ไปที่ 112 พอเรียก FreeToMarker(frameMark) offset ก็สแนปกลับไปที่ 64 ทันที — levelData ไม่ถูกแตะต้อง แต่ 48 byte ที่ pathBuffer ใช้อยู่ก็กลายเป็นว่างอีกครั้ง แน่นอนว่า allocation ถัดไป nextFrameBuffer เริ่มที่ offset 64 พอดี: byte ชุดเดียวกันเป๊ะกับที่ pathBuffer เพิ่งใช้ไป
FreeToMarker(A) ทั้ง ๆ ที่โค้ดที่อื่นยังถือ pointer เข้าไปในช่วงระหว่าง A กับ B อยู่ โดยคาดว่ามันจะยังใช้ได้ต่อ มันจะไม่ใช้ได้ — การดึงกลับไปเลย A จะ free ทุกอย่างหลังจากมันหมด รวมถึงทุกอย่างที่ B กำลังชี้อยู่ด้วย ไม่ว่าคุณจะตั้งใจเก็บมันไว้หรือไม่ก็ตาม ถ้าคุณต้องการ free อะไรโดยไม่เรียงลำดับ stack allocator คือเครื่องมือที่ผิด ให้ไปใช้ pool allocator ในหัวข้อถัดไปแทนallocator สองตัวข้างต้นไม่เหมาะกับกรณีที่พบบ่อยนี้เลย: object ขนาดเท่ากันจำนวนมาก — กระสุน, particle, ศัตรู — ที่ถูกสร้างและทำลายตลอดเวลา ในลำดับที่ไม่เกี่ยวอะไรเลยกับตอนที่มันถูกสร้าง pool allocator ถูกสร้างมาเพื่อเรื่องนี้โดยเฉพาะ มันสับ block ออกเป็น N slot ขนาดคงที่ไว้ล่วงหน้า และเก็บ free list ไว้: ห่วงโซ่ของ slot ที่ว่างอยู่ตอนนี้ เชื่อมกันเป็นสาย Alloc() จะ pop หัวของ list นั้นออกมา; Free() จะ push slot กลับไปที่หัว ทั้งสองอย่างเป็น O(1) — ไม่มีการค้นหาเลยแม้แต่น้อย
#include <cstddef>
class PoolAllocator {
public:
void Init(void* memory, size_t blockSize, size_t blockCount) {
// Each FREE block's first few bytes store a pointer to the NEXT
// free block. That's the whole trick: the free list lives INSIDE
// the free memory itself, at zero extra storage cost.
unsigned char* p = static_cast<unsigned char*>(memory);
this->blockSize = blockSize;
freeList = reinterpret_cast<FreeBlock*>(p);
for (size_t i = 0; i < blockCount - 1; i++) {
FreeBlock* block = reinterpret_cast<FreeBlock*>(p + i * blockSize);
block->next = reinterpret_cast<FreeBlock*>(p + (i + 1) * blockSize);
}
FreeBlock* last = reinterpret_cast<FreeBlock*>(p + (blockCount - 1) * blockSize);
last->next = nullptr;
}
void* Alloc() {
if (!freeList) {
return nullptr; // pool is full
}
FreeBlock* block = freeList;
freeList = freeList->next; // pop the head
return block;
}
void Free(void* ptr) {
FreeBlock* block = static_cast<FreeBlock*>(ptr);
block->next = freeList; // push back onto the head
freeList = block;
}
private:
struct FreeBlock {
FreeBlock* next;
};
FreeBlock* freeList = nullptr;
size_t blockSize = 0;
};
#include <cstdio>
struct Bullet {
float x, y, z;
float speed;
};
int main() {
unsigned char memory[sizeof(Bullet) * 4];
PoolAllocator bullets;
bullets.Init(memory, sizeof(Bullet), 4);
void* b0 = bullets.Alloc();
void* b1 = bullets.Alloc();
void* b2 = bullets.Alloc();
printf("allocated 3 of 4 slots\n");
bullets.Free(b1); // b1 dies, e.g. it hit something
printf("freed the middle slot (b1)\n");
void* b3 = bullets.Alloc(); // reuses b1's slot immediately
printf("b3 == b1? %s\n", (b3 == b1) ? "yes" : "no");
void* b4 = bullets.Alloc(); // the last remaining free slot
void* b5 = bullets.Alloc(); // pool is now full
printf("b5 == nullptr? %s\n", (b5 == nullptr) ? "yes" : "no");
return 0;
}
allocated 3 of 4 slots
freed the middle slot (b1)
b3 == b1? yes
b5 == nullptr? yes
comment ใน Init() คุ้มค่าที่จะอ่านซ้ำ: byte ของ slot ที่ว่างเองถูกเอามาใช้ซ้ำเป็นที่เก็บ pointer "next" ของ free list ไม่มีใครมองหน่วยความจำนั้นเป็น Bullet จริง ๆ ในตอนที่มันยังว่างอยู่ ดังนั้นการเขียน pointer ลงไปตรงนั้นจึงปลอดภัย — และมันหมายความว่า free list ไม่เสียหน่วยความจำเพิ่มเลยนอกเหนือจาก slot เอง นี่แหละคือเหตุผลที่ pool allocator เป็นเครื่องมือมาตรฐานสำหรับกระสุน, particle, และศัตรู: สร้างและทำลายมันในลำดับไหนก็ได้ เร็วเท่ากับ push/pop ของ linked-list ไม่มี fragmentation เลย เพราะ slot ที่ว่างจะมีขนาดพอดีเป๊ะสำหรับ object ชนิดเดียวกันตัวถัดไปเสมอ
บางครั้งคุณต้องการสิ่งที่ตรงข้ามกับ pool: object หลายขนาด free ในลำดับที่คาดเดาไม่ได้ ที่ไม่ใช่แค่ scratch data สำหรับเฟรมเดียว — เช่น การโหลดและปลดโหลด asset ตอนผู้เล่นเดินทางระหว่างด่าน สำหรับกรณีนี้คุณต้องการ general-purpose free-list allocator ซึ่งเก็บ list ของ block ที่ว่างไว้ แต่ละอันมี tag ขนาดของตัวเองกำกับ:
struct FreeBlockHeader {
size_t size;
FreeBlockHeader* next;
};
Alloc(size) จะไล่ list นี้หา block ที่ใหญ่พอ (กฎที่ง่ายที่สุดคือ first-fit เอาอันแรกที่พอดี; best-fit เอาอันเล็กที่สุดที่ยังพอดี แลกเวลาค้นหากับพื้นที่ที่เสียเปล่าน้อยลง) ถ้า block ที่เลือกมาใหญ่กว่าที่ขอมาก มันจะถูกแบ่งเป็นสอง ส่วนที่เหลือใส่กลับเข้า free list Free(ptr) เอา block กลับเข้า list — และที่สำคัญคือตรวจด้วยว่าเพื่อนบ้านที่อยู่ติดกันในหน่วยความจำว่างด้วยหรือเปล่า ถ้าใช่ก็รวมมันเข้าเป็น block ใหญ่ก้อนเดียว ขั้นตอนการรวมนี้เรียกว่า coalescing และมันคือแนวป้องกัน fragmentation หลักที่ general-purpose allocator มี
ถ้าฟังดูคล้าย ๆ กับคำอธิบายว่า malloc เองทำงานภายในยังไง — ก็ใช่แล้ว general-purpose free-list allocator เป็น general-purpose จริง ๆ ซึ่งนั่นก็คือเหตุผลว่าทำไมปัญหาความเร็วและ fragmentation ในหัวข้อ 1 ถึงเกิดกับมันด้วยเหมือนกัน แค่ถูกบรรเทาลงบ้างด้วย coalescing engine ก็ใช้ pattern นี้เหมือนกัน แต่จะกันมันออกจาก path ที่ร้อนแรงและทำงานทุกเฟรม สงวนไว้ใช้กับสิ่งที่เปลี่ยนแปลงไม่บ่อยนัก: โหลด asset ทั้งด่าน ไม่ใช่ spawn กระสุนนัดหนึ่ง
engine สมัยใหม่ทำงานแบบ pipelined (เป็นสาย pipeline): ในขณะที่ GPU ยังวาดเฟรม N อยู่ (อ่าน vertex buffer, constant, และข้อมูลต่อเฟรมอื่น ๆ ที่คุณส่งให้มัน) CPU มักจะเดินหน้าไปสร้างเฟรม N+1 แล้ว ถ้าคุณ reset arena ตัวเดียวตอนต้นทุกเฟรม คุณจะเขียนทับหน่วยความจำที่ GPU ยังอ่านไม่เสร็จ — race condition ที่แสดงออกมาเป็นภาพกระพริบหรือ geometry เพี้ยนบนจอ ทางแก้คือ: เก็บ arena ไว้สองอัน แล้วสลับว่าอันไหน "live" อยู่ในแต่ละเฟรม
#include <cstdlib>
class FrameAllocator {
public:
void Init(size_t sizeBytes) {
bufferA.Init(std::malloc(sizeBytes), sizeBytes);
bufferB.Init(std::malloc(sizeBytes), sizeBytes);
}
void BeginFrame() {
usingA = !usingA; // swap which buffer is "live"
Current().Reset(); // safe: the OTHER buffer is what the GPU
// might still be reading from
}
void* Alloc(size_t sizeBytes, size_t align = 8) {
return Current().Alloc(sizeBytes, align);
}
int CurrentIndex() const { return usingA ? 0 : 1; } // for debugging only
private:
LinearAllocator& Current() { return usingA ? bufferA : bufferB; }
LinearAllocator bufferA;
LinearAllocator bufferB;
bool usingA = false;
};
#include <cstdio>
int main() {
FrameAllocator frameAlloc;
frameAlloc.Init(1024);
for (int frame = 0; frame < 4; frame++) {
frameAlloc.BeginFrame();
frameAlloc.Alloc(64); // e.g. this frame's transform matrices
printf("frame %d writes into buffer %d\n", frame, frameAlloc.CurrentIndex());
}
return 0;
}
frame 0 writes into buffer 0
frame 1 writes into buffer 1
frame 2 writes into buffer 0
frame 3 writes into buffer 1
รูปแบบนี้ขยายต่อได้: บาง engine ใช้ buffer สามอันหรือมากกว่า ("triple buffering") ถ้า CPU สามารถวิ่งนำหน้า GPU ได้มากกว่าหนึ่งเฟรม แนวคิดหลักเหมือนเดิม — อย่า reset buffer ที่ผู้อ่าน (GPU, render thread, network thread หรืออะไรก็ตาม) อาจยังอ่านไม่เสร็จ
เมื่อคุณมี allocator กำหนดเองหลายตัวทำงานพร้อมกัน — physics arena หนึ่งตัว, audio pool หนึ่งตัว, rendering frame allocator หนึ่งตัว, AI scratch buffer หนึ่งตัว — มันเริ่มยากที่จะตอบคำถามง่าย ๆ ว่า: เมื่อการใช้หน่วยความจำรวมค่อย ๆ เพิ่มขึ้นตลอด session สองชั่วโมง ใครกันแน่ที่ใช้มันอยู่? ทางแก้คือ tag ทุก allocation ด้วย subsystem เจ้าของ แล้วเก็บผลรวมเล็ก ๆ ต่อ tag ไว้
#include <cstdio>
enum class MemoryTag {
Physics,
Audio,
Rendering,
AI,
Count // not a real tag -- just gives the array a size
};
struct MemoryTracker {
size_t usedBytes[(int)MemoryTag::Count] = {};
void Track(MemoryTag tag, size_t bytes) {
usedBytes[(int)tag] += bytes;
}
void Untrack(MemoryTag tag, size_t bytes) {
usedBytes[(int)tag] -= bytes;
}
void PrintReport() const {
const char* names[] = { "Physics", "Audio", "Rendering", "AI" };
for (int i = 0; i < (int)MemoryTag::Count; i++) {
printf("%s: %zu bytes\n", names[i], usedBytes[i]);
}
}
};
int main() {
MemoryTracker tracker;
tracker.Track(MemoryTag::Physics, 4096); // a rigid body pool
tracker.Track(MemoryTag::Audio, 65536); // a decoded music buffer
tracker.Track(MemoryTag::Rendering, 1048576); // this frame's arena
tracker.Track(MemoryTag::AI, 2048); // pathfinding scratch
tracker.PrintReport();
return 0;
}
Physics: 4096 bytes
Audio: 65536 bytes
Rendering: 1048576 bytes
AI: 2048 bytes
ใน engine จริง ขั้นตอนนี้ไม่ใช่ขั้นตอนแยกที่ต้องทำมือ — allocator แต่ละตัว (arena, pool, frame allocator) จะพก debug name และ tag ของตัวเองติดตัวไปด้วย รายงาน high-water mark และการใช้งานปัจจุบันของตัวเอง แล้ว memory profiler (เครื่องมือใน editor หรือในเกมสำหรับดูข้อมูลนี้ ซึ่งคุณจะเห็นตัวอย่างที่เป็นรูปธรรมในหัวข้อ 11) จะอ่านข้อมูลนั้นแบบ live ระหว่างเกมรันอยู่ เวอร์ชันข้างบนคือไอเดียเดียวกันแค่ย่อส่วนลงมา: รู้ว่าใครเป็นเจ้าของทุก byte ไม่ใช่แค่รู้ว่ามี byte เท่าไหร่
allocator ที่เขียนเองให้โอกาสคุณจับบั๊กหน่วยความจำคลาสสิกสองแบบได้แบบถูก ๆ — บั๊กที่บทเรื่อง C แนะนำให้คุณรู้จักผ่าน AddressSanitizer ไปแล้ว แต่ตอนนี้คุณจับมันได้ด้วย byte เพิ่มไม่กี่ byte จาก bookkeeping ของคุณเอง อยู่ภายใน allocator ของคุณเอง โดยไม่ต้องใช้เครื่องมือภายนอกเลย
buffer overrun คือโค้ดเขียนเลยขอบเขตหน่วยความจำที่มันได้รับ ทำให้สิ่งที่อยู่ถัดจากมันเสียหาย ทางแก้: วาง pattern คงที่เล็ก ๆ ของ byte เพิ่ม (guard บางทีเรียกว่า canary) ไว้ทั้งก่อนและหลังพื้นที่ใช้งานของทุก allocation ทันที ถ้าโค้ดเขียนเลยขอบเขต allocation ของตัวเองเมื่อไหร่ มันจะทำ guard เสียหายก่อน — และการตรวจ guard ก็บอกคุณได้ทันทีว่าเกิดเรื่องนี้ขึ้น
#include <cstring>
const unsigned char GUARD_PATTERN = 0xFE;
const size_t GUARD_SIZE = 4;
void* GuardedAlloc(LinearAllocator& arena, size_t sizeBytes) {
unsigned char* mem = static_cast<unsigned char*>(
arena.Alloc(GUARD_SIZE + sizeBytes + GUARD_SIZE));
if (!mem) return nullptr;
std::memset(mem, GUARD_PATTERN, GUARD_SIZE); // front guard
std::memset(mem + GUARD_SIZE + sizeBytes, GUARD_PATTERN, GUARD_SIZE); // back guard
return mem + GUARD_SIZE; // hand back a pointer to the USABLE region only
}
bool CheckGuards(void* userPtr, size_t sizeBytes) {
unsigned char* mem = static_cast<unsigned char*>(userPtr) - GUARD_SIZE;
for (size_t i = 0; i < GUARD_SIZE; i++) {
if (mem[i] != GUARD_PATTERN || mem[GUARD_SIZE + sizeBytes + i] != GUARD_PATTERN) {
return false; // something wrote past the edge of its allocation
}
}
return true;
}
#include <cstdio>
int main() {
unsigned char memory[256];
LinearAllocator arena;
arena.Init(memory, sizeof(memory));
int* healthArray = static_cast<int*>(GuardedAlloc(arena, 3 * sizeof(int)));
healthArray[0] = 100;
healthArray[1] = 80;
healthArray[2] = 50;
printf("guards OK before overrun? %s\n", CheckGuards(healthArray, 3 * sizeof(int)) ? "yes" : "no");
healthArray[3] = 999; // BUG: one element past the end -- into the back guard
printf("guards OK after overrun? %s\n", CheckGuards(healthArray, 3 * sizeof(int)) ? "yes" : "no");
return 0;
}
guards OK before overrun? yes
guards OK after overrun? no
healthArray มีแค่ 3 int ดังนั้น healthArray[3] จึงเขียน 4 byte เข้าไปตรงจุดเริ่มต้นของ back guard พอดี ทำให้ pattern 0xFE 0xFE 0xFE 0xFE บางส่วนถูกเขียนทับด้วย byte จาก 999 CheckGuards สังเกตเห็นความไม่ตรงกันและคืนค่า false — จับ overrun ที่ถ้าไม่มี guard byte จะทำให้สิ่งที่ allocator แจกให้ไปตัวถัดไปเสียหายแบบเงียบ ๆ
บั๊กคลาสสิกอีกแบบคือ use-after-free: อ่านหรือเขียนผ่าน pointer หลังจากหน่วยความจำที่มันชี้ไปถูก free ไปแล้ว (หรือสำหรับ arena คือหลังจาก Reset() ยกหน่วยความจำนั้นให้คนอื่นไปแล้ว) ทางแก้: ทันทีที่หน่วยความจำถูก free ให้เขียนทับทุก byte ของมันด้วย poison pattern ที่ชัดเจนและจำได้ทันที — นิยมใช้ 0xDD
void PoisonFree(unsigned char* mem, size_t sizeBytes) {
std::memset(mem, 0xDD, sizeBytes); // 0xDD 0xDD 0xDD ... everywhere
}
0xDD ถูกเลือกมาโดยเฉพาะเพราะมันไม่เกิดขึ้นเองตามธรรมชาติในข้อมูลเกมจริง: ในฐานะ int 32-bit 0xDDDDDDDD คือ -573785174, ในฐานะ float มันคือขยะที่ไม่มีความหมาย และในฐานะ pointer มันก็ชัดเจนว่าไม่ใช่ที่อยู่ที่ใช้ได้ ทันทีที่ debugger แสดง 0xDDDDDDDD อยู่ในตัวแปรที่ควรจะเป็น เช่น ค่า health คุณจะรู้ทันที: นี่คือหน่วยความจำที่ free ไปแล้ว และมีบางอย่างอ่านมันหลังจากที่มันควรจะหยุดอ่านไปแล้ว
#ifdef DEBUG_MEMORY) เพื่อให้มันไม่เสียค่าใช้จ่ายอะไรเลยใน final build ที่ optimize แล้ว ประเด็นทั้งหมดของเทคนิคพวกนี้คือมันเป็นตาข่ายนิรภัยช่วงพัฒนา — byte เพิ่มและการตรวจเพิ่มที่คุณยินดีจ่ายตอนกำลังทำเกม แต่ build ที่ ship จริงไม่มีเหตุผลต้องแบกมันไปด้วยคุณแทบจะไม่ต้องเขียน class อย่าง LinearAllocator เองใน gameplay code ปกติของ Unity เลย — managed heap กับ garbage collector จากบทเรื่อง C# จัดการ object ทั่วไปให้คุณอยู่แล้ว และนั่นก็คือเหตุผลว่าทำไม C# ถึงรู้สึกง่ายกว่า C++ ในการใช้งานประจำวัน แต่มีที่หนึ่งที่สำคัญมากใน Unity สมัยใหม่ที่ไอเดียทั้งบทนี้โผล่มาตรง ๆ เรียกชื่อตรง ๆ เลย: Unity.Collections (native container อย่าง NativeArray) กับ Job System constructor ของ native container ทุกตัวจะให้คุณเลือก Allocator และตัวเลือกทั่วไปสามแบบก็ตรงกับ allocator ที่คุณเพิ่งสร้างมาเป๊ะ ๆ
using Unity.Collections;
using UnityEngine;
public class NativeArrayDemo : MonoBehaviour
{
void Update()
{
// Allocator.Temp: behaves like a StackAllocator marker taken and
// freed right here, in this one call -- it never crosses a frame.
NativeArray<float> scratch = new NativeArray<float>(64, Allocator.Temp);
for (int i = 0; i < scratch.Length; i++)
{
scratch[i] = i * 2f;
}
Debug.Log("scratch[10] = " + scratch[10]);
scratch.Dispose(); // must happen before this frame ends
}
}
scratch[10] = 20
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
public struct DoubleJob : IJob
{
public NativeArray<float> data;
public void Execute()
{
for (int i = 0; i < data.Length; i++)
{
data[i] *= 2f;
}
}
}
public class TempJobDemo : MonoBehaviour
{
void Start()
{
// Allocator.TempJob: safe to hand to a job that might not finish
// this exact frame -- Allocator.Temp is NOT allowed to cross into
// a job at all.
NativeArray<float> data = new NativeArray<float>(4, Allocator.TempJob);
data[0] = 1f; data[1] = 2f; data[2] = 3f; data[3] = 4f;
DoubleJob job = new DoubleJob { data = data };
JobHandle handle = job.Schedule();
handle.Complete();
Debug.Log("data[2] = " + data[2]);
data.Dispose(); // we allocated it, so we must dispose it
}
}
data[2] = 6
NativeArray ด้วย Allocator.Temp แล้วส่งมันไปให้ Job.Schedule() ใน editor ระบบ safety ของ Unity จะ throw exception ทันที — Temp ไม่มีวันได้รับอนุญาตให้ข้ามผ่าน job boundary เลย ด้วยเหตุผลเดียวกันเป๊ะ ๆ กับที่คุณจะไม่มีวันถือ pointer เข้าไปในพื้นที่ StackAllocator หลังจากดึง marker กลับผ่านมันไปแล้ว: หน่วยความจำสามารถถูกเอาคืนได้ทันทีที่ฟังก์ชันปัจจุบัน return และ job ที่ schedule ไว้อาจยังไม่เริ่มรันจนกว่าจะถึงทีหลังทั้งหมดนี้ไม่ได้แปลว่าคุณควรจะไปเขียน arena มือเองใส่ไว้ใน MonoBehaviour ธรรมดา managed heap กับ GC คือเครื่องมือที่ถูกต้องสำหรับ gameplay object ทั่วไป และการไปสู้กับมันด้วย allocator ของคุณเองข้างใต้จะไม่ช่วยอะไรเลย ไอเดียพวกนี้จะสำคัญขึ้นมาตอนที่คุณเรียกโค้ด Unity.Collections หรือ Job System ตรง ๆ — หรือถ้าสุดท้ายคุณไปเขียน engine code แทนที่จะเป็น game code ด้วย C++
malloc/new ตรง ๆ ทุกครั้งที่ต้องการชิ้นหนึ่งAlloc และ free ทุกอย่างพร้อมกันด้วย Reset ครั้งเดียว(a) byte ว่างทั้งหมด: 40 + 90 + 30 = 160 byte
(b) ช่วงว่างต่อเนื่องเดี่ยวที่ใหญ่ที่สุด: 90 byte (ช่องว่างระหว่าง block "used 70" ตัวที่สอง กับ block "used 20")
(c) request 85 byte ต่อเนื่องสำเร็จ: 85 น้อยกว่าหรือเท่ากับช่องว่างเดี่ยวที่ใหญ่ที่สุด (90 byte) ดังนั้น allocator จึงตัด 85 byte นั้นออกจากช่องว่างนั้นได้ เหลือ 5 byte ของช่องว่างนั้นว่างต่อไปหลังจากนั้น มันจะล้มเหลวถ้า request เป็น เช่น 95 byte แม้ว่าจะมี 160 byte ว่างรวมทั้งหมดก็ตาม — เพราะไม่มีช่องว่างเดี่ยวไหนใหญ่ขนาดนั้น
LinearAllocator เริ่มที่ offset 0 (สมมติว่า base pointer เองคือที่อยู่ 0 align กับทุกอย่างอยู่แล้ว เพื่อให้ตัวเลขง่าย) ไล่การเรียกสามครั้งตามลำดับ: Alloc(13, align=4), Alloc(6, align=8), Alloc(20, align=4) สำหรับแต่ละครั้ง ให้บอก offset ที่มันคืนกลับมา และค่าใหม่ของ offset ภายในหลังจากนั้น จำนวน byte ที่ใช้ไปทั้งหมดตอนจบ รวม padding ด้วย คือเท่าไหร่?ครั้งที่ 1: Alloc(13, align=4) — offset ปัจจุบันคือ 0 ซึ่งเป็นจำนวนเท่าของ 4 อยู่แล้ว padding จึงเป็น 0 คืน offset 0 offset ใหม่: 0 + 13 = 13
ครั้งที่ 2: Alloc(6, align=8) — offset ปัจจุบันคือ 13; จำนวนเท่าของ 8 ถัดไปที่เท่ากับหรือมากกว่า 13 คือ 16 padding จึงเป็น 16 - 13 = 3 คืน offset 16 offset ใหม่: 16 + 6 = 22
ครั้งที่ 3: Alloc(20, align=4) — offset ปัจจุบันคือ 22; จำนวนเท่าของ 4 ถัดไปที่เท่ากับหรือมากกว่า 22 คือ 24 padding จึงเป็น 24 - 22 = 2 คืน offset 24 offset ใหม่: 24 + 20 = 44
byte ที่ใช้ไปทั้งหมดตอนจบ: 44 (13 + padding 3 + 6 + padding 2 + 20 = 44) จาก 44 byte นั้น 5 byte เป็น padding ล้วน ๆ ที่เสียไปกับ alignment (3 + 2)
PoolAllocator จากหัวข้อ 6 (4 slot เชื่อมกัน slot0 -> slot1 -> slot2 -> slot3 โดย Init()) ไล่ลำดับนี้:
void* a = bullets.Alloc();
void* b = bullets.Alloc();
bullets.Free(a);
bullets.Free(a); // BUG: freeing the same pointer twice
void* c = bullets.Alloc();
void* d = bullets.Alloc();
void* e = bullets.Alloc();
c, d, และ e สุดท้ายชี้ไปที่ที่อยู่ไหน และเพราะอะไร? มีอะไรผิดพลาดกับ free list เจาะจงเนื่องจากการเรียก Free(a) สองครั้ง และทำไมมันถึงอันตราย?ไล่ free list ทีละขั้น หลัง Init(): freeList = slot0 -> slot1 -> slot2 -> slot3 -> null
a = Alloc() // pops slot0. freeList = slot1 -> slot2 -> slot3 -> null. a = slot0
b = Alloc() // pops slot1. freeList = slot2 -> slot3 -> null. b = slot1
Free(a) // pushes slot0 onto the front:
// slot0->next = (old freeList) = slot2
// freeList = slot0 -> slot2 -> slot3 -> null
Free(a) // BUG, again // pushes slot0 onto the front AGAIN:
// slot0->next = (old freeList) = slot0 <-- itself!
// freeList = slot0 -> slot0 -> slot0 -> ... (a CYCLE)
// slot2 and slot3 are now UNREACHABLE -- lost
Free(a) ครั้งที่สองเขียนทับ pointer next ของ slot0 เป็นครั้งที่สอง และครั้งนี้ freeList เป็น slot0 อยู่แล้ว — ดังนั้น slot0->next จึงถูกตั้งให้เป็น slot0 เอง free list ตอนนี้กลายเป็นวงวน node เดียวที่ชี้กลับไปหาตัวเองตลอดกาล แย่ไปกว่านั้น link เดิม (slot0->next = slot2) หายไปแล้ว ดังนั้น slot2 กับ slot3 — ซึ่งว่างและไม่ได้ใช้อย่างถูกต้อง — จะเข้าถึงไม่ได้อีกเลยตลอดไป: memory leak ของ slot สองอันเต็ม ๆ
c = Alloc() pop หัว คือ slot0 ออกมา แล้วตั้ง freeList = slot0->next ซึ่งก็คือ slot0 อีกครั้ง — ดังนั้น freeList จึงไม่เปลี่ยนแปลงจริง ๆ c == slot0 เรื่องเดียวกันเกิดกับ d = Alloc() และ e = Alloc(): ทุกการเรียก pop slot0 ออกมาแล้ว list ก็วนกลับไปที่ slot0 ทันที ดังนั้น c, d, และ e จึงเป็นที่อยู่เดียวกันเป๊ะทั้งหมด
นี่คือเหตุผลที่ double-free อันตรายมากโดยเฉพาะกับ free list แบบ singly-linked ที่ intrusive: โค้ดของเกมคิดว่าเพิ่งได้ Bullet object อิสระสามตัว แต่การเขียนลง c->x กลับเขียนทับหน่วยความจำเดียวกันกับที่ d และ e ก็ใช้อยู่แบบเงียบ ๆ (กระสุนสามตัว alias slot เดียวกัน) ในขณะที่ slot ดี ๆ สองอันถูกทิ้งไว้แบบกำพร้าตลอดไป และไม่มีวันถูก allocate ได้อีกเลยตลอดที่เหลือของโปรแกรม
นั่นคือชุดเครื่องมือทั้งหมด: ทำไมการเรียก malloc/new ทุกที่ถึงช้าและคาดเดาไม่ได้เกินไปสำหรับเกมแบบ real-time และ fragmentation กัดกิน heap อย่างเงียบ ๆ ยังไงแม้ว่าจะมี byte "ว่าง" อยู่เยอะก็ตาม จากนั้นก็มา allocator สี่แบบที่เป็นรูปธรรม แต่ละแบบถูกออกแบบรอบ lifetime แบบหนึ่ง — arena ที่ free ทุกอย่างพร้อมกันสำหรับข้อมูลต่อเฟรม, stack allocator ที่เพิ่มการดึงกลับแบบ LIFO ด้วย marker, pool allocator ที่ให้ alloc/free แบบ O(1) สำหรับ object ขนาดเท่ากันอย่างกระสุนกับ particle, และ general-purpose free-list allocator สำหรับทุกอย่างที่เหลือ ใช้แต่น้อย alignment, double buffering, การติดตามแยกตาม subsystem, และ guard bytes/poison pattern เติมเต็มชุดเครื่องมือที่ engine จริง ๆ ใช้ ship เกม และใน Unity คุณจะเจอไอเดียพวกนี้อีกครั้งพอดีเป๊ะตอนที่คุณแตะ NativeArray กับ Job System — Allocator.Temp, TempJob, และ Persistent ไม่ใช่แนวคิดใหม่เลย แค่เป็นชื่อใหม่ของ allocator ที่คุณเพิ่งสร้างด้วยมือตัวเองนั่นเอง