Chapter tentang cache locality menutup dengan janji: pisahkan field yang panas dari yang dingin. Janji itu punya nama — data-oriented design (DOD) — dan berbanding terbalik dengan kebiasaan OOP klasik. Pemrograman berorientasi objek menata kode berdasarkan “benda”: satu class Partikel memuat semua attribute milik satu partikel, lalu kita membuat array of struct (AoS). DOD menata data berdasarkan pola pemakaiannya: field-field yang dibaca loop yang sama diletakkan berdekatan dalam struct of array (SoA). Tidak ada yang salah dengan AoS untuk kode harian; DOD masuk ketika profiler menunjuk loop masif yang hanya menyentuh sebagian kecil field. Chapter ini membangun kedua tata letak untuk tugas identik dan menghitung biayanya.

AoS: Nyaman Ditulis, Mahal Diakses #

std::vector<Partikel> dengan struct berisi x, y, z, vx, vy, vz, pemilik adalah bentuk paling natural di C++. Kelebihannya nyata: satu objek = satu unit (mudah disalin, dipindah, dikirim ke fungsi), locality bagus ketika loop memakai SEMUA field (update posisi menyentuh enam float sekaligus, semuanya satu cache line), dan mental modelnya lurus. Kelemahannya muncul ketika hot loop hanya butuh satu-dua field: menyapu vx saja tetap menarik 28 byte per elemen (seluruh struct) sementara 24 byte di antaranya tidak dibaca. Cache line dibayar penuh, tapi hanya seperempat isinya yang bekerja.

SoA: Menata Data Mengikuti Pola Akses #

SoA membalik susunannya: bukan “array berisi partikel”, melainkan “struct berisi array per field”. Loop yang menyapu satu field kini menyapu memori yang benar-benar kontinu. Program berikut menjumlahkan vx dari satu juta partikel dalam dua tata letak, lalu menghitung biaya cache-nya:

cpp
// AoS (array of struct) vs SoA (struct of array) untuk satu tugas yang
// sama: menjumlahkan field vx dari satu juta partikel.
// Hasil hitungan identik; biaya cache-nya dihitung di bagian akhir.
#include <iomanip>
#include <iostream>
#include <vector>

// AoS: satu objek menyimpan semua field — nyaman untuk operasi per objek.
struct Partikel {
    float x, y, z;
    float vx, vy, vz;
    int pemilik;
};

// SoA: satu field untuk semua objek — hot loop hanya menyentuh field yang dipakai.
struct PartikelSoA {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<int> pemilik;
};

int main() {
    const int N = 1000000;

    std::vector<Partikel> aos(N);
    for (int i = 0; i < N; ++i) aos[i].vx = (i % 1000) * 0.25f;

    PartikelSoA soa;
    soa.vx.resize(N);
    for (int i = 0; i < N; ++i) soa.vx[i] = (i % 1000) * 0.25f;

    // Hot loop: hanya field vx yang dibaca.
    double totalAos = 0.0;
    for (const Partikel& p : aos) totalAos += p.vx;
    double totalSoa = 0.0;
    for (float v : soa.vx) totalSoa += v;

    std::cout << std::fixed << std::setprecision(1);
    std::cout << "sizeof(Partikel) = " << sizeof(Partikel) << " byte\n";
    std::cout << "total vx (AoS)   = " << totalAos << '\n';
    std::cout << "total vx (SoA)   = " << totalSoa << '\n';

    // Biaya cache untuk memindai field vx saja (cache line 64 byte):
    std::cout << "AoS: memindai vx menyentuh "
              << 1LL * N * sizeof(Partikel) / 64
              << " cache line (28 byte per elemen, mayoritas terbuang)\n";
    std::cout << "SoA: memindai vx menyentuh "
              << 1LL * N * sizeof(float) / 64
              << " cache line (4 byte per elemen, semua terpakai)\n";
    std::cout << "rasio pengambilan line: "
              << sizeof(Partikel) / sizeof(float) << " : 1\n";
    return 0;
}

Output yang Dihasilkan:

text
sizeof(Partikel) = 28 byte
total vx (AoS)   = 124875000.0
total vx (SoA)   = 124875000.0
AoS: memindai vx menyentuh 437500 cache line (28 byte per elemen, mayoritas terbuang)
SoA: memindai vx menyentuh 62500 cache line (4 byte per elemen, semua terpakai)
rasio pengambilan line: 7 : 1

Bedah baris penting:

  1. struct Partikel: tujuh field berukuran 4 byte, total 28 byte per objek tanpa padding (alignment float dan int adalah 4).
  2. PartikelSoA: tujuh std::vector terpisah; perhatikan pemiliknya bukan pointer individual — tetap satu blok kontinu per field, sehingga tetap satu alokasi per field.
  3. Hasil identik 124875000.0: kedua tata letak menghitung angka yang persis sama — SoA mengubah tata letak memori, bukan hasil komputasi.
  4. Hitungan cache line: memindai vx pada AoS menarik 437.500 line (28 MB dibagi 64 byte) karena tiap elemen menyeret 24 byte tetangga yang tidak dipakai; pada SoA hanya 62.500 line (4 MB dibagi 64 byte) dengan semua isi terpakai. Rasio 7 : 1 untuk pekerjaan yang sama.
  5. Bonus vektorisasi: loop SoA menyapu std::vector<float> murni, bentuk favorit auto-vectorization compiler (chapter SIMD membahasnya tuntas) — keuntungan kedua yang ikut datang gratis.

Angka 7 : 1 adalah biaya pengambilan line, bukan stopwatch; dalam pengukuran nyata, selisihnya terasa paling besar ketika data jauh melebihi kapasitas cache dan loop benar-benar masif. Untuk data kecil, selisihnya bisa tak terlihat — dan di titik itu AoS tetap pilihan yang lebih waras.

Kapan DOD Menguntungkan — dan Kapan Tidak #

DOD memberi hasil terbaik ketika tiga syarat hadir sekaligus: jumlah entitas besar (ratusan ribu ke atas), loop pemrosesan batch yang menyentuh subset field, dan pola akses yang bisa diprediksi (berurutan atau langkah tetap). Wilayah klasiknya adalah sistem partikel, fisika game, simulasi, dan pemrosesan sinyal — konteks yang memang menganut pola ini. Sebaliknya, DOD justru membebani ketika: data kecil (biaya cache bisa diabaikan), operasi per objek yang menyentuh semua field (AoS lebih hemat line di kasus itu), atau logika kepemilikan yang rumit (tujuh std::vector terpisah harus selalu sinkron panjangnya — inilah harga disiplin SoA).

Perhatikan juga bahwa SoA mengubah gaya penulisan kode: fungsi yang tadinya menerima const Partikel& kini menerima beberapa array atau slice dari struct SoA. Abstraksi tipis (misalnya method PartikelSoA::ambilKecepatan(i)) bisa menjaga keterbacaan tanpa menyentuh tata letak.

Menata data yang tepat mengurangi biaya membaca field. Tapi masih ada biaya lain yang sering muncul di loop yang sama: biaya memindahkan dan menyalin objek itu sendiri — copy yang tak disengaja, realokasi vector yang tumbuh, dan string yang dibangun terus-menerus. Alat untuk memangkasnya adalah move semantics, topik chapter berikutnya.

Rujukan resmi: std::vector dan isocpp FAQ .