15.6 SIMD และ Cache (สายลึก)

เฟส 15 · Optimization และมือถือ · เวลาเรียน: 30–50 h

รีด CPU — layout ข้อมูลที่เป็นมิตรกับ cache (Struct-of-Arrays) และ SIMD vectorization สำหรับสายเอนจินและ performance

ในบท C memory เราเรียนไปแล้วว่า memory คืออาเรย์ยักษ์ของไบต์ที่มีเลขกำกับ และ pointer ก็คือตัวเลขที่ใช้เรียกชื่อไบต์ตัวใดตัวหนึ่งในนั้น array เก็บ element ของมันเรียงต่อกันในอาเรย์ของไบต์นั้น ไม่มีช่องว่างคั่นเลย เรื่องนั้นเป็นเรื่องของ ความถูกต้อง (correctness) — คือรู้ว่า memory ชิ้นหนึ่งคืออะไรจริง ๆ บทนี้เป็นเรื่องของ ความเร็ว (speed) — คือดูว่าเกิดอะไรขึ้นเวลาโค้ดของเราสั่งให้ CPU อ่านและเขียนไบต์พวกนั้นจริง ๆ เป็นล้าน ๆ ครั้งต่อหนึ่งเฟรม

1. ทำไมต้องมีบทนี้: จากไบต์สู่ความเร็ว

ส่วนที่น่าแปลกใจคือ โค้ดสองชิ้นที่ทำคณิตศาสตร์เหมือนกันเป๊ะ — จำนวนการบวกการคูณเท่ากันทุกอย่าง — ความเร็วอาจต่างกันได้ถึง 5 ถึง 30 เท่า แค่เพราะชิ้นหนึ่งขอ memory ตามลำดับที่ CPU ชอบ ส่วนอีกชิ้นไม่ทำแบบนั้น นี่ไม่ใช่รายละเอียดเล็ก ๆ เลย ใน game engine จริง รูปแบบการเข้าถึง memory มักเป็นตัวแปรที่ส่งผลต่อ performance มากกว่า algorithm ที่ฉลาดเสียอีก physics engine, particle system, หรือ skinning system ที่ "ทำงานเท่ากัน" แต่แตะ memory แบบไม่ดี อาจเสียเวลาทั้งเฟรมไปกับการรอเฉย ๆ ได้

บทนี้เน้นสองเทคนิคหลัก คือทำให้การเข้าถึง memory เป็นแบบ cache-friendly (ให้ CPU แทบไม่ต้องรอ RAM เลย) กับการใช้ SIMD (Single Instruction, Multiple Data — ให้หนึ่งคำสั่งของ CPU ทำคณิตศาสตร์ 4 หรือ 8 ค่าพร้อมกันในทีเดียว แทนที่จะทำทีละค่า) ทั้งสองอย่างถูกใช้ตลอดเวลาใน particle system, physics solver, skinning, และ culling และทั้งสองอย่างก็เป็นเหตุผลที่ pattern Entity-Component-System (ECS) มีอยู่ ซึ่งเราจะเจอแบบเต็ม ๆ ใน Phase 16 — บทนี้อธิบายเหตุผลระดับฮาร์ดแวร์ว่าทำไม ECS ถึงถูกออกแบบมาแบบนั้น

2. ลำดับชั้นของ Memory: Register, L1, L2, L3, RAM

CPU core อ่านค่าจาก RAM (main memory) ตรง ๆ ทุกคำสั่งไม่ได้ — RAM ช้าเกินไปมากเมื่อเทียบกับความเร็วที่ core สมัยใหม่รันคำสั่งได้ ดังนั้นระหว่าง core กับ RAM จึงมี memory หลายชั้นที่เล็กกว่าและเร็วกว่าคั่นอยู่ เรียกว่า cache แต่ละชั้นคือสำเนาของส่วนเล็ก ๆ ของ RAM ที่เก็บไว้ใกล้ ๆ core

+-----------+ | REGISTERS | smallest, fastest (part of the CPU core) +-----------+ +-------------+ | L1 CACHE | small, very fast (per core) +-------------+ +-----------------+ | L2 CACHE | bigger, fast (per core) +-----------------+ +-----------------------+ | L3 CACHE | big, slower (shared by all cores) +-----------------------+ +-----------------------------------+ | MAIN MEMORY (RAM) | huge, slowest (shared) +-----------------------------------+

ชั้นที่อยู่ใกล้ core ที่สุดจะเล็กมากและเร็วสุด ๆ ส่วนชั้นที่อยู่ไกลออกไปจะใหญ่กว่าและช้ากว่า L1, L2, และ L3 ย่อมาจาก "Level 1/2/3 cache" — L1 อยู่ใกล้ที่สุดและเล็กที่สุด L3 อยู่ไกลที่สุด (แต่ก็ยังเร็วกว่า RAM มาก) และใหญ่ที่สุด ปกติจะถูกแชร์กันใช้โดยทุก core บนชิป

ต้นทุนการเข้าถึงโดยประมาณ

ตัวเลขที่แน่นอนจะต่างกันไปตามรุ่นและเจนของ CPU แต่ อัตราส่วน ระหว่างแต่ละชั้นจะใกล้เคียงกันในเกือบทุก CPU สมัยใหม่ ไม่ว่าจะเป็นเดสก์ท็อป, console, หรือมือถือ ประมาณคร่าว ๆ (หน่วยเป็น CPU clock cycle):

level typical size rough latency ----- ------------ ------------- register a few bytes ~0-1 cycle L1 cache 32-64 KB per core ~4 cycles L2 cache 256 KB-1 MB per core ~12 cycles L3 cache several MB, shared ~40 cycles RAM many GB, shared ~200 cycles

คำสั่งบวกหรือคูณหนึ่งคำสั่งใช้เวลาประมาณ 1 cycle ของ throughput นั่นแปลว่า cache miss (ค่าที่ต้องการยังไม่มีอยู่ใน cache) ที่ต้องไปเอาถึง RAM จะกินเวลาพอ ๆ กับคำสั่งคณิตศาสตร์ธรรมดา ๆ 100 ถึง 200 กว่าคำสั่ง ถ้า inner loop ของเรา miss cache ทุกรอบ CPU จะใช้เวลาเกือบทั้งหมดนั่งรอเฉย ๆ รอให้ไบต์มาถึง ไม่ได้คำนวณอะไรเลย

Tip ไม่ต้องจำตัวเลข cycle พวกนี้ให้แม่นเป๊ะก็ได้ สิ่งที่สำคัญคือรูปทรงโดยรวม: แต่ละชั้นช้ากว่าชั้นบนมันประมาณ 3-5 เท่า และช่วงที่กระโดดจาก L3 ไป RAM คือช่วงที่หนักที่สุด — แย่กว่า L3 เดี่ยว ๆ ประมาณ 5 เท่า

3. Cache Line: Memory มาทีละ 64 ไบต์

cache ไม่ได้ดึง memory ทีละไบต์ หรือทีละ float การดึงแต่ละครั้งจะดึงมาเป็นก้อนขนาดคงที่ที่เรียกว่า cache line ใน CPU สมัยใหม่แทบทุกตัว ไม่ว่าจะเป็นเดสก์ท็อป, console, หรือมือถือ cache line มีขนาด 64 ไบต์

ดังนั้นถ้าเราอ่าน float ตัวเดียวขนาด 4 ไบต์ที่ address หนึ่ง ฮาร์ดแวร์จะดึงก้อน 64 ไบต์ที่ align มาทั้งก้อนที่มีค่านั้นอยู่ — คือ 16 float (หรือ 8 double) ไม่ว่าเราจะขอ 15 ตัวที่เหลือด้วยหรือไม่ก็ตาม

one 64-byte cache line, holding 16 floats: +----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+ | f0 | f1 | f2 | f3 | f4 | f5 | f6 | f7 | f8 | f9 |f10 |f11 |f12 |f13 |f14 |f15 | +----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+ \_________________________ one 64-byte cache line ___________________________/ reading f0 alone pulls in f0..f15 for free -- they all arrive together

เรื่องนี้ส่งผลตรง ๆ ต่อวิธีที่เราควรเขียน loop ถ้าค่าตัวถัดไปที่เราจะอ่านคือ float ตัวถัดไปใน memory มันมีโอกาสสูงมากที่จะอยู่ใน cache line ที่เราเพิ่งจ่ายไปแล้ว — อ่านแบบนี้จะได้ L1 hit ใช้เวลาประมาณ 4 cycle แต่ถ้าค่าตัวถัดไปอยู่ไกลออกไป นั่นคือ cache line ใหม่เอี่ยม อาจต้องเดินทางไปถึง RAM เต็ม ๆ ใช้เวลาประมาณ 200 cycle

การเข้าถึงแบบเรียงลำดับ (sequential access) เร็ว เพราะมันใช้ cache line ที่ดึงมาแล้วซ้ำ ส่วน การเข้าถึงแบบสุ่มหรือกระจัดกระจาย (random/scattered access) ช้า เพราะเกือบทุกครั้งที่เข้าถึงต้องการ cache line ใหม่ และเมื่อ working set ของเราใหญ่เกินกว่าจะใส่ใน cache ได้ การดึงใหม่ ๆ พวกนั้นส่วนใหญ่ก็ต้องไปถึง RAM

Tip ไม่ต้องตรวจจับขนาด cache line ตอน runtime สำหรับบทนี้ก็ได้ 64 ไบต์เป็นค่าที่ใช้ได้ปลอดภัยกับ CPU แทบทุกตัวที่เราจะเจอ — x86 (Intel/AMD), Apple Silicon, และชิป ARM บนมือถือ

4. Sequential Access ชนะ: Row-Major vs Column-Major

array 2 มิติจริง ๆ แล้วก็คือ array 1 มิติที่แอบซ่อนอยู่ ใน C กับ C++ matrix แบบ row-major จะเก็บ matrix[row][col] ไว้ที่ index แบบแบน row * width + col — ดังนั้น row 0 จะมาก่อนใน memory เรียงตามลำดับ ตามด้วย row 1, row 2, ไปเรื่อย ๆ

row-major traversal (matches memory order): matrix in memory: [ (0,0) (0,1) (0,2) (0,3) | (1,0) (1,1) (1,2) (1,3) | (2,0) ... row 0 row 1 row 2 for row in 0..N: for col in 0..N: visit matrix[row][col] -- walks straight along memory column-major traversal (fights memory order): for col in 0..N: for row in 0..N: visit matrix[row][col] -- jumps N floats (one whole row) every step
#include <cstdio>
#include <chrono>
#include <vector>

const int N = 4096;

int main() {
    std::vector<float> matrix(N * N, 1.0f);

    // row-major: inner loop walks along memory (fast)
    auto t0 = std::chrono::high_resolution_clock::now();
    double sumRow = 0.0;
    for (int row = 0; row < N; row++) {
        for (int col = 0; col < N; col++) {
            sumRow += matrix[row * N + col];
        }
    }
    auto t1 = std::chrono::high_resolution_clock::now();

    // column-major: inner loop jumps N floats every step (slow)
    double sumCol = 0.0;
    for (int col = 0; col < N; col++) {
        for (int row = 0; row < N; row++) {
            sumCol += matrix[row * N + col];
        }
    }
    auto t2 = std::chrono::high_resolution_clock::now();

    double msRow = std::chrono::duration<double, std::milli>(t1 - t0).count();
    double msCol = std::chrono::duration<double, std::milli>(t2 - t1).count();

    printf("row-major:    %.2f ms  (sum = %.0f)\n", msRow, sumRow);
    printf("column-major: %.2f ms  (sum = %.0f)\n", msCol, sumCol);
}

อ่านโค้ด timing

loop ทั้งสองแบบทำสิ่งเดียวกันทางคณิตศาสตร์เป๊ะ ๆ คือบวก element ทุกตัวของ matrix เดียวกัน ตัวละครั้งเดียว ต่างกันแค่ลำดับที่ nested loop สองชั้นเดินผ่าน index เท่านั้น เวอร์ชัน row-major ที่ inner loop เป็น col จะเดินตรง ๆ ตาม memory ทีละ float — ใช้ cache line ที่ดึงมาทุกก้อนอย่างเต็มที่ ส่วนเวอร์ชัน column-major ที่ inner loop เป็น row จะกระโดดไปข้างหน้าทีละ N float ในทุกก้าว — สำหรับ N = 4096 นั่นคือ 16 KB ระหว่างการเข้าถึงแต่ละครั้ง ใหญ่กว่า cache line มาก และมักใหญ่กว่า L1 หรือ L2 ทั้งก้อนด้วยซ้ำ เกือบทุกการเข้าถึงใน loop แบบ column-major จึงเป็นการดึง cache line ใหม่

วัดผลบน CPU เดสก์ท็อปทั่วไป ด้วย N = 4096 (matrix ของ float ขนาด 4096 x 4096 รวม 64 MB — ใหญ่กว่า cache ตัวไหนเลยมาก):

row-major:    14.20 ms  (sum = 16777216)
column-major: 168.53 ms  (sum = 16777216)

คณิตศาสตร์เหมือนกัน จำนวนการบวกเท่ากัน ผลรวมสุดท้ายเท่ากัน — แต่ช้ากว่าเกือบ 12 เท่า แค่เพราะลำดับ loop เท่านั้น นี่เป็นเรื่องของ memory ล้วน ๆ ไม่ใช่เรื่องคณิตศาสตร์เลย

Common mistake อย่าคิดไปเองว่า compiler จะแก้ลำดับ loop ที่แย่ให้เราโดยอัตโนมัติ compiler จะจัดลำดับ loop เล็ก ๆ ที่พิสูจน์ได้ว่าปลอดภัยใหม่ได้ แต่กับ nested loop ขนาดใหญ่ที่วนบน array — โดยเฉพาะที่ส่งผ่าน pointer หรือ reference ที่มีโอกาส aliasing ได้ — compiler มักพิสูจน์ไม่ได้ว่าเปลี่ยนลำดับแล้วปลอดภัย เลยไม่เปลี่ยนให้ การจัดลำดับ loop ให้ถูกต้องเป็นหน้าที่ของเราเอง

5. Array of Structs กับ Struct of Arrays

มือใหม่ส่วนใหญ่มักเขียนข้อมูลแบบเดียวกันหมด คือมี struct หรือ class ที่เก็บทุก field ที่ object ต้องใช้ไว้ด้วยกัน แล้วเก็บใน array วิธีนี้เรียกว่า Array of Structs หรือ AoS

AoS -- Array of Structs (one struct per particle): struct Particle { float x, y, z; float life; }; memory: [x0 y0 z0 life0][x1 y1 z1 life1][x2 y2 z2 life2]... \______particle 0______/\______particle 1______/ reading "life" for every particle also drags x, y, z along, whether you need them right now or not SoA -- Struct of Arrays (one array per field, across ALL particles): struct Particles { float x[N], y[N], z[N], life[N]; }; memory: [x0 x1 x2 x3 ...][y0 y1 y2 y3 ...][z0 z1 z2 ...][life0 life1 life2 ...] reading "life" for every particle touches ONLY the life array -- every byte pulled into cache is a byte you actually use

AoS ตรงกับวิธีที่เราคิดถึง object ตามธรรมชาติ — "particle หนึ่งตัวมีตำแหน่ง, ความเร็ว, สี" — และมันก็ใช้ได้ดีกับโค้ดที่แตะ field ส่วนใหญ่หรือทุก field ของ object พร้อมกัน ปัญหาจะเกิดขึ้นเมื่อเราต้องการแค่ field เดียว จาก object จำนวนมาก เพราะทุก field ของ particle หนึ่งตัวอยู่ติดกันใน memory การอ่านแค่ life ของ particle ตัวที่ 0 ก็ยังลาก x, y, z, vx, vy, vz, สีทั้งหมด, rotation, และ scale เข้ามาใน cache ด้วย — ไบต์ที่เราจ่ายไปแต่ไม่เคยใช้เลย

Struct of Arrays หรือ SoA จะกลับ layout ไปอีกแบบ คือมี array แยกหนึ่งอันต่อหนึ่ง field ครอบคลุมทุก object นี่คือตัวอย่างที่ใกล้เคียงกับ particle จริง ๆ มากขึ้น: 14 float ต่อ particle (56 ไบต์) โดยที่ฟังก์ชันนี้ต้องการแค่ life เท่านั้น

struct ParticleAoS {
    float x, y, z;       // position
    float vx, vy, vz;    // velocity
    float r, g, b, a;    // color
    float rotation;
    float scale;
    float life;           // remaining lifetime -- the ONLY field this example needs
    float maxLife;
};

float sumLifeAoS(const std::vector<ParticleAoS>& particles) {
    float total = 0.0f;
    for (const auto& p : particles) {
        total += p.life;
    }
    return total;
}
struct ParticleSystemSoA {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<float> r, g, b, a;
    std::vector<float> rotation;
    std::vector<float> scale;
    std::vector<float> life;      // the only array this example touches
    std::vector<float> maxLife;
};

float sumLifeSoA(const ParticleSystemSoA& particles) {
    float total = 0.0f;
    for (float l : particles.life) {
        total += l;
    }
    return total;
}

วัดผลกับ particle 2,000,000 ตัว ทุกตัวมี life = 1.0:

int main() {
    const int N = 2000000;
    std::vector<ParticleAoS> aos(N);
    for (auto& p : aos) p.life = 1.0f;

    ParticleSystemSoA soa;
    soa.life.assign(N, 1.0f);

    auto t0 = std::chrono::high_resolution_clock::now();
    float totalAoS = sumLifeAoS(aos);
    auto t1 = std::chrono::high_resolution_clock::now();
    float totalSoA = sumLifeSoA(soa);
    auto t2 = std::chrono::high_resolution_clock::now();

    printf("AoS sumLife: %.2f ms (total = %.0f)\n",
           std::chrono::duration<double, std::milli>(t1 - t0).count(), totalAoS);
    printf("SoA sumLife: %.2f ms (total = %.0f)\n",
           std::chrono::duration<double, std::milli>(t2 - t1).count(), totalSoA);
}
AoS sumLife: 5.10 ms (total = 2000000)
SoA sumLife: 0.62 ms (total = 2000000)

เวอร์ชัน AoS อ่าน 56 ไบต์ต่อ particle เพื่อเอาแค่ 4 ไบต์ที่ใช้ได้จริง — ข้อมูลที่ไหลผ่านระบบ memory มากกว่าที่จำเป็นถึง 14 เท่า ส่วนเวอร์ชัน SoA อ่านแค่ 4 ไบต์ที่ต้องการเป๊ะ ๆ อัดแน่นอยู่กับค่า life ของ particle อีก 15 ตัวใน cache line เดียวกัน เร็วกว่าประมาณ 8 เท่าในกรณีนี้ สำหรับการอ่าน field เดียวจาก struct ขนาดกลาง ๆ — และช่องว่างจะยิ่งกว้างขึ้นถ้า struct ใหญ่ขึ้นหรือมี field ที่ไม่ได้ใช้เยอะขึ้น

6. SoA กับเหตุผลที่ ECS มีอยู่

ทางเลือกระหว่าง AoS กับ SoA ไม่ได้หยุดอยู่แค่ particle เท่านั้น ถ้าซูมออกไปดูทั้งเกม เราจะเจอปัญหาเดียวกันในสเกลที่ใหญ่ขึ้น: GameObject แบบดั้งเดิมเก็บตำแหน่ง, ความเร็ว, health, reference ไปยัง mesh, สถานะ AI, และอื่น ๆ ไว้ใน object ก้อนเดียวที่ allocate บน heap ระบบที่สนใจแค่ตำแหน่งกับความเร็ว — อย่างเช่นระบบเคลื่อนที่ — ก็ยังต้องกระโดดไปทุกที่ที่ GameObject แต่ละตัวบังเอิญไปอยู่บน heap

traditional (one big object per entity, scattered on the heap): GameObject A (heap addr 0x1000): position, velocity, health, mesh, ai state... GameObject B (heap addr 0x9F40): position, velocity, health, mesh, ai state... GameObject C (heap addr 0x2AA0): position, velocity, health, mesh, ai state... a "move everything" system jumps all over the heap to find position ECS (one tightly packed array per component, across ALL entities): Position array: [posA][posB][posC][posD][posE]... Velocity array: [velA][velB][velC][velD][velE]... Health array: [hpA ][hpB ][hpC ][hpD ][hpE ]... a "move everything" system streams straight through Position and Velocity only -- exactly the SoA pattern from section 5, applied to a whole game world

pattern Entity-Component-System (ECS) ที่เราจะเจอแบบเต็ม ๆ ใน Phase 16 ก็คือ SoA ที่เอามาใช้กับทั้งเกมเวิลด์นั่นเอง แทนที่จะมี object หนึ่งตัวต่อหนึ่ง entity ที่เก็บทุก field ไว้ด้วยกัน ECS จะเก็บ array ที่อัดแน่นหนึ่งชุดต่อหนึ่ง component type ครอบคลุมทุก entity ที่มี component นั้น ระบบเคลื่อนที่ที่ต้องการแค่ Position กับ Velocity จะไหลผ่าน array สองชุดนี้ตรง ๆ และไม่แตะ health, สถานะ AI, หรืออะไรอื่นเลย — ได้ประโยชน์จาก cache line แบบเดียวกับตัวอย่าง particle เป๊ะ ๆ แค่ขยายสเกลไปเป็นหลักพัน entity แทนที่จะเป็น particle system เดียว

นี่คือเหตุผลระดับฮาร์ดแวร์ว่าทำไม ECS ถึงมีอยู่ มันไม่ใช่แค่วิธีจัดโค้ดให้ "สะอาดกว่า" เฉย ๆ — array แบบ component ที่จัดเป็น SoA นั้น cache-friendly กว่า object ที่กระจัดกระจายอย่างมาก ด้วยเหตุผลเป๊ะ ๆ ตามที่บทนี้อธิบายมา

Tip SoA ไม่ใช่กฎที่ต้องใช้ทั้งหมดหรือไม่ใช้เลยกับทั้ง codebase ส่วนใหญ่ engine จะใช้ SoA แค่กับข้อมูล hot, N ใหญ่, ที่ต้องประมวลผลทุกเฟรม เช่น particle, transform, bone matrix, physics body ส่วน object ฝั่ง gameplay ที่แทบไม่ได้ถูกแตะ (เช่น NPC ที่ให้เควส, หน้าจอเมนู) จะไม่ได้ประโยชน์อะไรจาก SoA เลย เลยมักปล่อยให้เป็น object ธรรมดาต่อไป

7. Prefetching กับรูปแบบการเข้าถึงที่คาดเดาได้

การใช้ cache line ซ้ำไม่ใช่เหตุผลเดียวที่ทำให้ sequential access เร็ว CPU สมัยใหม่ยังมี hardware prefetcher — วงจรที่คอยจับตาดู pattern ของ address ที่โค้ดเราเข้าถึง ถ้ามันสังเกตเห็น stride ที่คงที่ (เช่น +64 ไบต์ทุกครั้งที่เข้าถึง) มันจะเริ่มดึง cache line อีกไม่กี่ก้อนถัดไปเข้ามาใน cache ก่อนที่โค้ดเราจะขอด้วยซ้ำ ซ่อน latency ของ RAM ไว้เบื้องหลังงานที่ core กำลังทำอยู่แล้ว

memory: [line0][line1][line2][line3][line4][line5]... ^ core is reading here now hardware prefetcher notices the steady stride and starts pulling line3, line4, line5 into cache BEFORE the core asks for them, hiding the RAM latency behind work the core is already doing

นี่คืออีกเหตุผลที่ loop ธรรมดา ๆ บน array วิ่งเร็ว: มันไม่ได้แค่ใช้ cache line ซ้ำ prefetcher ยังทายได้แม่นด้วยว่า address ถัดไปคืออะไร แล้วเริ่มดึงล่วงหน้าได้เลย ส่วน random access — เช่นการไล่ตาม linked list หรือ index ผ่าน array ของ index ที่ถูกสับ — จะทำให้ prefetcher ใช้งานไม่ได้เลย เพราะมันไม่มี stride คงที่ให้เรียนรู้ ทุกการเข้าถึงจึงต้องจ่ายค่า miss เต็ม ๆ โดยไม่มีการดึงล่วงหน้าช่วยเลย

compiler กับ hardware prefetcher จัดการ loop ธรรมดาได้ดีอยู่แล้วโดยไม่ต้องช่วยอะไรเพิ่ม hint สำหรับ prefetch แบบเขียนเองก็มีอยู่ (__builtin_prefetch ใน GCC/Clang หรือ intrinsic อย่าง _mm_prefetch ที่อื่น) ไว้ใช้กับกรณีที่ฮาร์ดแวร์ทายเองไม่ได้ — เช่นการไล่ tree หรือ linked list ที่เรารู้อยู่แล้วว่าอีกไม่กี่ก้าวข้างหน้าจะต้องใช้ node ไหน:

// usually unnecessary -- shown only to see what it looks like
for (int i = 0; i < n; i++) {
    __builtin_prefetch(&data[i + 16], 0, 1); // hint: we will read this soon
    total += heavyWorkOn(data[i]);
}

สำหรับ loop ธรรมดาบน array อย่างที่พูดมาตลอดบทนี้ เราแทบไม่ต้องใช้เทคนิคนี้เลย เพราะ hardware prefetcher ทำให้เราอยู่แล้ว ให้หยิบ manual prefetching มาใช้ก็ต่อเมื่อ profile แล้วเจอว่า loop ตัวใดตัวหนึ่งติดขัดเรื่อง memory จริง ๆ และ pattern การเข้าถึงไม่ใช่การเดิน array แบบธรรมดา

8. False Sharing: เมื่อ Thread แย่ง Cache Line กัน

การใช้ cache line ซ้ำเป็นเรื่องดีสำหรับ core เดียว แต่พอมีหลาย core เข้ามาเกี่ยวข้อง มันจะเกิดปัญหาใหม่ Cache coherency (การรักษาให้สำเนา cache ของทุก core ตรงกัน) ถูกติดตามในระดับของ cache line ทั้งก้อน ไม่ใช่ทีละไบต์ ถ้า thread สองตัวบนสอง core ต่างกันเขียนไปยังตัวแปรสองตัวที่ต่างกัน แต่บังเอิญไปอยู่ใน cache line 64 ไบต์เดียวกัน ฮาร์ดแวร์จะมองว่ามันแย่งกันใช้ — ทั้งที่ตัวแปรสองตัวนั้นไม่เคยทับซ้อนกันจริง ๆ เลย เรื่องนี้เรียกว่า false sharing

Core A Core B +-----------+ +-----------+ | counterA | (write) | counterB | (write) +-----------+ +-----------+ \ / \__ same 64-byte cache line __/ Core A writes counterA: Core B's cached copy of the line is invalidated Core B writes counterB: Core A's cached copy of the line is invalidated result: the cache line keeps bouncing between the two cores (slow)

ทุกครั้งที่ core A เขียน counterA มันจะทำให้สำเนา cache ทั้งเส้นของ core B เป็นโมฆะ (invalidate) บังคับให้ core B ต้องดึงใหม่ ทุกครั้งที่ core B เขียน counterB ก็เกิดเรื่องเดียวกันย้อนกลับ cache line จะเด้งไปเด้งมาระหว่าง cache ของสอง core — แต่ละครั้งที่เด้งมีต้นทุนพอ ๆ กับ cache miss ไป shared cache หรือ RAM ทั้งที่ไม่มี thread ไหนแตะตัวแปรของอีกฝั่งเลยด้วยซ้ำ

#include <atomic>
#include <thread>
#include <chrono>
#include <cstdio>

struct CountersBad {
    std::atomic<long> counterA{0};
    std::atomic<long> counterB{0};
    // counterA and counterB usually land in the SAME cache line
};

struct CountersGood {
    alignas(64) std::atomic<long> counterA{0};
    alignas(64) std::atomic<long> counterB{0};
    // 64-byte alignment forces each counter onto its OWN cache line
};

const long ITERS = 100000000;

template <typename T>
double runTest(T& counters) {
    auto t0 = std::chrono::high_resolution_clock::now();
    std::thread ta([&] {
        for (long i = 0; i < ITERS; i++) counters.counterA++;
    });
    std::thread tb([&] {
        for (long i = 0; i < ITERS; i++) counters.counterB++;
    });
    ta.join();
    tb.join();
    auto t1 = std::chrono::high_resolution_clock::now();
    return std::chrono::duration<double, std::milli>(t1 - t0).count();
}

int main() {
    CountersBad bad;
    CountersGood good;
    printf("false sharing: %.2f ms\n", runTest(bad));
    printf("padded:        %.2f ms\n", runTest(good));
}
false sharing: 612.40 ms
padded:        78.90 ms

alignas(64) บังคับให้ counter แต่ละตัวเริ่มที่ boundary 64 ไบต์ของตัวเอง ทำให้ counterA กับ counterB ไปอยู่คนละ cache line thread ทั้งสองก็เลยเลิกแย่ง cache line เดียวกัน และเวอร์ชันที่ padding แล้ววิ่งเร็วกว่าประมาณ 7.8 เท่า — ทั้งที่ increment เหมือนกันเป๊ะทุกอย่าง

Common mistake false sharing ไม่ทำให้ผลลัพธ์ผิด — โค้ดทั้งสองเวอร์ชันข้างบนคำนวณผลรวมถูกต้องทั้งคู่ มันจะโผล่มาแค่เป็นความช้าที่อธิบายไม่ได้ตอนรันแบบ multithreading เท่านั้น ทำให้พลาดง่ายมากทั้งตอน debug ปกติหรือแม้แต่ profiler ที่เน้นความถูกต้อง ให้สงสัยเรื่องนี้ไว้ก่อนเลย เวลาโค้ด multithreaded scale ได้แย่กว่าที่คาดตอนเพิ่ม thread ทั้งที่ความถูกต้องแบบ single-threaded ไม่มีปัญหาอะไร

9. SIMD: หนึ่งคำสั่ง หลายตัวเลข

ทุกอย่างที่ผ่านมาเป็นเรื่องของการไม่เสียเวลารอ memory SIMD จะเข้ามาแก้อีกครึ่งหนึ่งของปัญหา คือทำให้คำนวณได้มากขึ้นต่อหนึ่งคำสั่ง

SIMD ย่อมาจาก Single Instruction, Multiple Data คำสั่งแบบปกติ ("scalar") ทำงานกับค่าทีละตัว — คำสั่งบวกหนึ่งคำสั่งบวก float สองตัวได้ float ผลลัพธ์หนึ่งตัว ส่วนคำสั่ง SIMD จะอัดหลายค่าเข้าไปใน register กว้าง ๆ ตัวเดียว แล้วทำ operation เดียวกัน กับทุกค่าพร้อมกันด้วยคำสั่งเดียว

scalar (one lane): a[0] + b[0] = r[0] 1 instruction, 1 result SIMD (4 lanes, e.g. a 128-bit register): +--------+--------+--------+--------+ | a[0] | a[1] | a[2] | a[3] | register A +--------+--------+--------+--------+ +--------+--------+--------+--------+ | b[0] | b[1] | b[2] | b[3] | register B +--------+--------+--------+--------+ + + + + ONE add instruction +--------+--------+--------+--------+ | r[0] | r[1] | r[2] | r[3] | register R +--------+--------+--------+--------+

แต่ละช่องใน register กว้าง ๆ นั้นเรียกว่า lane ความกว้างของ register จะเป็นตัวกำหนดว่าเราจะได้กี่ lane:

register width floats per lane group instruction set 128-bit 4 SSE / NEON 256-bit 8 AVX / AVX2 512-bit 16 AVX-512

SSE (128-bit) มีอยู่ใน CPU x86 แทบทุกตัวที่ผลิตในศตวรรษนี้ AVX กับ AVX2 (256-bit) พบได้ทั่วไปใน CPU เดสก์ท็อปและ console สมัยใหม่ AVX-512 (512-bit) จะเจอในชิปเซิร์ฟเวอร์และเดสก์ท็อประดับสูงบางตัว ฝั่ง ARM ก็มี NEON (128-bit, 4 float) ที่ใช้ในมือถือ, Apple Silicon, และ Nintendo Switch

SIMD เป็น parallelism คนละแบบกับ multithreading multithreading รัน instruction stream แยกกันบน core แยกกัน ส่วน SIMD รัน instruction stream เดียว แต่แต่ละคำสั่งทำงานได้ 4 หรือ 8 (หรือ 16) เท่า ทั้งสองอย่างซ้อนกันได้: loop ที่ทั้ง multithreaded และ vectorize ด้วย SIMD จะได้ speedup ทั้งสองแบบพร้อมกัน ซึ่งนั่นคือสิ่งที่ตัวอย่าง Burst ใน section 13 ทำเป๊ะ ๆ

10. Scalar vs SIMD: บวก Float Array สองตัว

ตัวอย่าง SIMD ที่ง่ายที่สุดคือการบวก array ของ float สองตัว ทีละ element

void addArrays(const float* a, const float* b, float* result, int n) {
    for (int i = 0; i < n; i++) {
        result[i] = a[i] + b[i];
    }
}
#include <immintrin.h>   // SSE / AVX intrinsics

void addArraysSIMD(const float* a, const float* b, float* result, int n) {
    int i = 0;
    for (; i + 4 <= n; i += 4) {
        __m128 va = _mm_loadu_ps(&a[i]);
        __m128 vb = _mm_loadu_ps(&b[i]);
        __m128 vr = _mm_add_ps(va, vb);
        _mm_storeu_ps(&result[i], vr);
    }
    for (; i < n; i++) {           // leftover elements, n not divisible by 4
        result[i] = a[i] + b[i];
    }
}

แต่ละ intrinsic call ทำอะไรบ้าง

วัดผลบน CPU เดสก์ท็อปทั่วไป บวก array ของ float 20,000,000 ตัวสองอัน:

scalar: 21.85 ms
simd:    7.42 ms

เร็วขึ้นประมาณ 2.9 เท่า ไม่ถึง 4 เท่าเต็ม ๆ ตามที่อาจคาดหวังจาก 4 lane เหตุผลคือ loop นี้เป็น memory-bandwidth bound คือทุกครั้งที่บวก CPU ต้องดึง float สองตัวจาก RAM เข้ามาแล้วเขียนกลับออกไปหนึ่งตัว การทำให้เลขคณิตเร็วขึ้น 4 เท่าไม่ช่วยอะไร ถ้า bottleneck คือความเร็วที่ RAM ป้อนข้อมูลเข้ามาและรับผลลัพธ์ออกไปได้ — CPU ก็ยังเสียเวลาส่วนใหญ่ไปกับการรออยู่ดี ส่วนโค้ด SIMD ที่เน้นการคำนวณหนัก ๆ — คูณและบวกหลายรอบต่อค่าหนึ่งค่าที่อยู่ใน register อยู่แล้ว เช่น matrix transform หรือ inner loop ของ physics — จะได้ speedup ใกล้เคียง 4-8 เท่าเต็ม ๆ มากกว่า เพราะ bottleneck ตรงนั้นคือ throughput ของเลขคณิตจริง ๆ ไม่ใช่ memory

Tip การเขียน intrinsic เองเป็นทางเลือกสุดท้าย ให้ profile ก่อน ปล่อยให้ compiler auto-vectorize ให้ (section ถัดไป) แล้วค่อยหยิบ intrinsic แบบเขียนเองมาใช้เฉพาะกับ hot loop ตัวที่จำเป็นจริง ๆ และ compiler vectorize เองได้ไม่ดีพอเท่านั้น

11. Auto-Vectorization: เขียน Loop ที่ Compiler ทำให้เร็วขึ้นได้

เราแทบไม่ต้องเขียน intrinsic เองเลย compiler สมัยใหม่ — GCC, Clang, และ MSVC — สามารถแปลง loop แบบ scalar ธรรมดาให้กลายเป็นคำสั่ง SIMD ได้เองโดยอัตโนมัติ ที่ optimization level -O2 หรือ -O3 โดยไม่ต้องมี intrinsic อยู่ในซอร์สโค้ดของเราเลย

// compiled with -O3 -march=native -- no intrinsics written by hand
void addArrays(const float* a, const float* b, float* result, int n) {
    for (int i = 0; i < n; i++) {
        result[i] = a[i] + b[i];
    }
}

เมื่อ compile ด้วย -O3 -march=native loop ที่ดูธรรมดา ๆ นี้มักถูก compiler แปลงเป็นคำสั่ง SIMD ให้เองโดยอัตโนมัติ ได้ความเร็วใกล้เคียงกับเวอร์ชันที่เขียน intrinsic เองใน section ก่อนหน้า

สัญญาณที่บอกว่า loop จะไม่ auto-vectorize

void addArraysFast(const float* __restrict a,
                    const float* __restrict b,
                    float* __restrict result, int n) {
    for (int i = 0; i < n; i++) {
        result[i] = a[i] + b[i];
    }
}

__restrict บอก compiler ว่า pointer ทั้งสามตัวนี้ไม่มีทางทับซ้อนกันเลย มันเลยโหลด บวก และเขียนเป็นชุด ๆ ละ 4 หรือ 8 ได้อย่างอิสระ โดยไม่ต้องกังวลว่าการเขียนไปที่ result[i] จะไปเปลี่ยน a[i] หรือ b[i] ที่จะอ่านทีหลัง loop ง่าย ๆ ที่ไม่มี branch, มี stride คงที่, และไม่มีความกำกวมเรื่อง aliasing คือ loop ที่ vectorize ได้ดีที่สุดพอดี — นี่ก็เป็นอีกเหตุผลที่การเก็บ hot loop ให้เรียบง่ายเป็นคำแนะนำที่ดีในตัวมันเอง แยกออกจากเรื่อง readability

Tip ถ้าอยากเช็กว่า loop ถูก vectorize จริงไหม compiler รายงานให้เราดูตรง ๆ ได้ — GCC กับ Clang มี flag -fopt-info-vec และ -fopt-info-vec-missed รองรับ หรือถ้าอยากเช็กแบบเร็ว ๆ ด้วยตาโดยไม่ต้องตั้ง flag เอง ลองเอาฟังก์ชันไปวางใน online compiler explorer แล้วดู assembly ที่ generate ออกมาว่ามีคำสั่ง SIMD ไหม (ชื่อที่มี ps หรือ pd อยู่บน x86) ก็ใช้ได้ดีเหมือนกัน

12. เจอเรื่องนี้ตรงไหนบ้างในเกมจริง

layout แบบ cache-friendly กับ SIMD โผล่มาตลอดในระบบที่ต้องรันทุกเฟรมกับของจำนวนมากที่คล้าย ๆ กัน:

สังเกตรูปแบบที่เหมือนกันไหม: array ขนาดใหญ่ของ element ที่แทบจะเป็นอิสระต่อกัน ทำคณิตศาสตร์ปริมาณเท่า ๆ กันทุกเฟรม รูปแบบนี้คือสิ่งที่บทนี้ทั้งบทพยายาม optimize มาตลอด — ไม่ใช่เรื่องบังเอิญเลยที่ engine จัด hot data แบบนี้โดยตั้งใจ

13. เอา SIMD มาใช้ใน C# โดยไม่ต้องเขียน Intrinsic: Burst กับ Job System

ทุกอย่างในบทนี้ที่ผ่านมาเป็น C++ ทั้งหมด ส่วน C# เฉย ๆ นั้น auto-vectorize ได้ไม่แน่นอน และ C# ธรรมดาก็ไม่ได้ให้เราเขียน intrinsic เองได้ง่าย ๆ แบบ C++ Unity แก้ปัญหานี้ด้วยสองส่วนที่ทำงานร่วมกัน คือ Burst compiler กับ Job System

Burst เอา subset ของ C# ที่ถูกจำกัดไว้เพื่อ performance สูง ๆ — value type, NativeArray, ไม่มี managed class หรือ garbage collection อยู่ในส่วนที่ compile — แล้ว compile ตรงไปเป็น native machine code ที่ optimize แล้ว โดยใช้ LLVM backend ตัวเดียวกับที่ขับเคลื่อน -O3 ของ Clang รวมถึง auto-vectorization ด้วย method ที่ติด [BurstCompile] จะไม่ถูก interpret หรือ JIT-compile แบบ C# ปกติ — มันถูก compile ล่วงหน้าเป็น machine code ที่ทำ SIMD ได้จริง

Job System จะ schedule งานให้กระจายไปยัง worker thread บนหลาย core อย่างปลอดภัย — ระบบเช็คความปลอดภัยของ Unity จะจับการเข้าถึง memory แบบทับซ้อนที่ไม่ได้ sync กันได้ตั้งแต่ใน editor ก่อนที่มันจะกลายเป็นบั๊กที่ reproduce ยาก IJobParallelFor จะแบ่ง loop บน NativeArray ออกเป็น batch แล้วรัน batch พวกนั้นกระจายไปตาม worker thread ที่มีอยู่

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[BurstCompile]
public struct UpdatePositionsJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float3> velocities;
    public NativeArray<float3> positions;
    public float deltaTime;

    public void Execute(int index)
    {
        positions[index] = positions[index] + velocities[index] * deltaTime;
    }
}
NativeArray<float3> positions = new NativeArray<float3>(1000000, Allocator.Persistent);
NativeArray<float3> velocities = new NativeArray<float3>(1000000, Allocator.Persistent);
// ... fill both with starting data ...

var job = new UpdatePositionsJob {
    positions = positions,
    velocities = velocities,
    deltaTime = Time.deltaTime
};

JobHandle handle = job.Schedule(positions.Length, 64); // batch size 64
handle.Complete();

อ่านโค้ด job

วัดผลตอนอัปเดตตำแหน่งของ particle 1,000,000 ตัวจากความเร็วของมัน หนึ่งครั้งต่อเฟรม:

plain C# foreach, List<ParticleAoS>, no Burst:   38.70 ms
Burst IJobParallelFor, NativeArray<float3>:        1.15 ms

ช่องว่าง 33 เท่านี้ใหญ่กว่า 2.9 เท่าจากตัวอย่าง SIMD ล้วน ๆ ใน C++ ของ section 10 เพราะมีสามผลรวมกันอยู่: auto-vectorization ของ SIMD ในแต่ละ thread, worker thread หลายตัวรันขนานกันข้าม core, และการข้าม overhead ของ managed object กับ garbage collector ที่ loop foreach บน List<ParticleAoS> ธรรมดาต้องแบกไว้ แต่ทั้งหมดนี้จะไม่เกิดขึ้นเลยถ้าข้อมูลไม่ได้ถูกจัดเป็น NativeArray แบบแบนไว้ตั้งแต่แรก — คือ storage ทรง SoA จาก section 5 กับ 6 ที่ตอนนี้ให้ผลตอบแทนตรง ๆ ใน C# แล้ว

Common mistake Burst มีข้อจำกัดจริง ๆ อยู่: ห้ามมี managed class, ห้ามมี string, ห้ามมี exception, ห้ามมี reference type อยู่ใน job ที่ติด [BurstCompile] — ใช้ได้แค่ blittable value type (type ที่มี memory layout คงที่แบบ C) กับ container Native* ของ Unity เท่านั้น เรื่องนี้เป็นหัวข้อที่ใหญ่กว่าที่บทนี้ครอบคลุม แต่ให้คาดไว้เลยว่า compiler จะปฏิเสธ job ที่ไปแตะ feature ของ managed C# ธรรมดา

14. Glossary

15. แบบฝึกหัด

Exercise 1 ฟังก์ชันข้างล่างนี้บวก matrix สี่เหลี่ยมของ float ที่เก็บแบบ row-major (layout เดียวกับ section 4) ทีละ column
double columnSum(const std::vector<float>& matrix, int N, int col) {
    double total = 0.0;
    for (int row = 0; row < N; row++) {
        total += matrix[row * N + col];
    }
    return total;
}

double sumAllColumns(const std::vector<float>& matrix, int N) {
    double grand = 0.0;
    for (int col = 0; col < N; col++) {
        grand += columnSum(matrix, N, col);
    }
    return grand;
}
1. sumAllColumns เป็น cache-friendly ไหม อธิบายเหตุผลในแง่ของ cache line
2. เขียนใหม่ให้ได้ grand total เท่าเดิมเป๊ะ ๆ แต่เข้าถึง memory ในลำดับที่ cache-friendly
Show answer

ไม่ — inner loop ของ columnSum เดินไปตาม row ซึ่งกระโดดไป N float (ทั้ง row) ในแต่ละครั้ง สำหรับ N ใหญ่ ๆ นี่ใหญ่กว่า cache line มาก ทำให้เกือบทุกการเข้าถึงเป็นการดึง cache line ใหม่ และ sumAllColumns ก็ทำแบบนี้ซ้ำทุก column

double sumAllColumnsFast(const std::vector<float>& matrix, int N) {
    double grand = 0.0;
    for (int row = 0; row < N; row++) {
        for (int col = 0; col < N; col++) {
            grand += matrix[row * N + col];
        }
    }
    return grand;
}

เวอร์ชันนี้เข้าถึงทุก element แค่ครั้งเดียว ตามลำดับเดียวกับที่ matrix ถูกวางไว้ใน memory คือเรียงตาม row cache line ทุกก้อนที่ดึงมาถูกใช้เต็มที่ (float ทั้ง 16 ตัวถูกบวกหมด) ก่อนจะย้ายไปก้อนถัดไป แทนที่จะถูกดึงมาครั้งละ column แล้วใช้ซ้ำแค่ 1/16 เท่านั้น

Exercise 2 array แบบ AoS ของ Enemy เก็บศัตรูอยู่ 100,000 ตัว ทุกเฟรม countAlive จะเช็กว่าเหลือกี่ตัวที่ยังไม่ตาย — แต่มันอ่านแค่ health เท่านั้น
struct Enemy {
    float x, y;
    float health;
    float armor;
    char name[32];
    int aiState;
    float attackCooldown;
};

int countAlive(const std::vector<Enemy>& enemies) {
    int alive = 0;
    for (const auto& e : enemies) {
        if (e.health > 0.0f) alive++;
    }
    return alive;
}
ออกแบบใหม่เป็น SoA สำหรับ field ที่ countAlive ต้องใช้จริง ๆ แล้วเขียนฟังก์ชันเวอร์ชัน SoA อธิบายว่าทำไมมันถึง cache-friendly กว่า
Show answer
struct EnemySoA {
    std::vector<float> x, y;
    std::vector<float> health;    // countAlive only needs this array
    std::vector<float> armor;
    std::vector<std::string> name;
    std::vector<int> aiState;
    std::vector<float> attackCooldown;
};

int countAliveSoA(const EnemySoA& enemies) {
    int alive = 0;
    for (float h : enemies.health) {
        if (h > 0.0f) alive++;
    }
    return alive;
}

struct Enemy แบบ AoS มีขนาดเกิน 50 ไบต์ไปมาก — แค่ name[32] ตัวเดียวก็กินไปครึ่ง cache line แล้ว ทุกครั้งที่เรียก countAlive จะลากตำแหน่ง, armor, buffer ของชื่อทั้งก้อน, สถานะ AI, และ cooldown เข้ามาใน cache สำหรับศัตรูทุกตัว ทั้งที่ไม่มีอันไหนถูกอ่านเลย ส่วน countAliveSoA แตะแค่ array health เท่านั้น ทำให้ค่า health ของศัตรู 16 ตัวมาถึงต่อหนึ่ง cache line และทุกตัวถูกใช้จริง

Exercise 3 เขียน job แบบ IJobParallelFor ที่ compile ด้วย Burst ชื่อ ScaleJob ที่คูณทุก element ของ NativeArray<float> ด้วยค่าคงที่ factor พร้อมโค้ดสำหรับ schedule มัน แล้วอธิบายว่าทำไมทั้ง NativeArray<float> และ [BurstCompile] ถึงสำคัญต่อการได้ความเร็วแบบ SIMD ในกรณีนี้
Show answer
[BurstCompile]
public struct ScaleJob : IJobParallelFor
{
    public NativeArray<float> values;
    public float factor;

    public void Execute(int index)
    {
        values[index] = values[index] * factor;
    }
}
var job = new ScaleJob { values = values, factor = 2.0f };
JobHandle handle = job.Schedule(values.Length, 64);
handle.Complete();

NativeArray<float> เป็น buffer แบบแบน, unmanaged, ต่อเนื่องกัน — layout เดียวกับที่ C array มี และเป็น layout ที่ฮาร์ดแวร์ SIMD ต้องการพอดีสำหรับโหลด 4 หรือ 8 lane พร้อมกัน ส่วน List<float> แบบ managed เป็น object ของ .NET ที่ garbage collector ย้ายที่เมื่อไหร่ก็ได้ Burst เลย compile กับมันไม่ได้เลย [BurstCompile] บอก Unity ให้ compile Execute ล่วงหน้าด้วย Burst/LLVM backend แทนที่จะรันแบบ interpreted หรือ JIT-compiled managed C# ซึ่งนี่แหละคือสิ่งที่ทำให้ auto-vectorization เกิดขึ้นได้ — เอฟเฟกต์เดียวกับ -O3 ใน section ที่เป็น C++

← กลับไปหน้ารวมบท