SoA vs AoS
Array of Structures (AoS) dan Structure of Arrays (SoA) adalah dua cara menyusun (me-layout) data di memori. Keduanya menyimpan informasi yang sama persis, tapi urutan byte-nya di RAM berbeda. Di dunia High Performance Computing, perbedaan "sepele" ini bisa berdampak besar ke performa — sering kali 2x sampai 8x lipat — karena berhubungan langsung dengan cara kerja cache, SIMD/vektorisasi, dan memory coalescing di GPU.<ref name="drepper" /><ref name="cuda" />
Prinsip utamanya satu kalimat: susun data mengikuti pola aksesnya, bukan mengikuti "kerapian" konsep OOP. Ini adalah ide inti dari data-oriented design.<ref name="fabian" /><ref name="acton" />
Analogi Sederhana
Bayangkan sekolah dengan 1.000 siswa, dan tiap siswa punya data: nama, nilai Matematika, nilai Fisika, nilai Kimia.
- AoS = satu map arsip per siswa. Map si Budi berisi lengkap: nama Budi, nilai Mat Budi, nilai Fisika Budi, nilai Kimia Budi.
- SoA = satu buku besar per jenis data. Ada buku khusus "Nilai Matematika seluruh siswa", buku "Nilai Fisika seluruh siswa", dst.
Sekarang tugasnya: hitung rata-rata nilai Matematika seluruh sekolah.
- Dengan AoS: kamu harus buka 1.000 map satu per satu, ambil selembar nilai Matematika, dan sisanya (nama, Fisika, Kimia) ikut terangkat dari rak padahal tidak dipakai. Boros tenaga.
- Dengan SoA: tinggal ambil satu buku "Nilai Matematika", baca dari atas ke bawah. Selesai.
CPU bekerja persis seperti ini. Setiap kali CPU mengambil data dari RAM, dia tidak mengambil 1 byte, melainkan satu boks penuh yang disebut cache line (umumnya 64 byte).<ref name="drepper" /> Kalau isi boks itu kebanyakan data yang tidak dipakai, bandwidth memori terbuang percuma.
Array of Structures (AoS)
Ini gaya yang paling natural kalau kita terbiasa OOP: definisikan satu struct untuk satu objek, lalu buat array dari struct itu.
struct Particle {
float x, y, z; // posisi
float mass; // massa
}; // total: 16 byte per partikel
Particle p[N]; // <-- Array of Structures
// Contoh: jumlahkan semua koordinat x
float sum = 0.0f;
for (int i = 0; i < N; ++i)
sum += p[i].x; // tiap iterasi "loncat" 16 byte
Layout di Memori
Semua field satu partikel berdempetan, lalu disusul partikel berikutnya:
Alamat memori bertambah ke kanan ---> +----+----+----+----+----+----+----+----+----+----+----+----+ | x0 | y0 | z0 | m0 | x1 | y1 | z1 | m1 | x2 | y2 | z2 | m2 | ... +----+----+----+----+----+----+----+----+----+----+----+----+ \_____Partikel 0______/ \_____Partikel 1______/ \__Partikel 2 ...
Structure of Arrays (SoA)
Kebalikannya: satu array terpisah untuk tiap field.
struct Particles {
float x[N];
float y[N];
float z[N];
float mass[N];
}; // <-- Structure of Arrays
Particles p;
// Contoh: jumlahkan semua koordinat x
float sum = 0.0f;
for (int i = 0; i < N; ++i)
sum += p.x[i]; // akses 100% berurutan (contiguous)
Layout di Memori
x[] : +----+----+----+----+----+----+
| x0 | x1 | x2 | x3 | x4 | x5 | ...
+----+----+----+----+----+----+
y[] : +----+----+----+----+----+----+
| y0 | y1 | y2 | y3 | y4 | y5 | ...
+----+----+----+----+----+----+
z[] : | z0 | z1 | z2 | ...
mass[] : | m0 | m1 | m2 | ...
Kenapa Ini Penting di HPC
1. Efisiensi Cache Line
Cache line modern = 64 byte = muat 16 buah float. Perhatikan apa yang terjadi kalau loop kita hanya butuh field x:
Kasus AoS -- 1 cache line (64 byte) berisi 4 partikel utuh: +----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+ | x0 | y0 | z0 | m0 | x1 | y1 | z1 | m1 | x2 | y2 | z2 | m2 | x3 | y3 | z3 | m3 | +----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+ ^^ ^^ ^^ ^^ dipakai dipakai dipakai dipakai Hanya 4 dari 16 float yang dipakai --> efisiensi bandwidth 25% (75% data yang diangkut dari RAM dibuang sia-sia) Kasus SoA -- 1 cache line berisi 16 nilai x sekaligus: +----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+ | x0 | x1 | x2 | x3 | x4 | x5 | x6 | x7 | x8 | x9 |x10 |x11 |x12 |x13 |x14 |x15 | +----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+ ^^^^ semua 16 float dipakai --> efisiensi bandwidth 100%
Karena mayoritas kernel HPC bersifat memory-bound (bottleneck-nya di bandwidth memori, bukan di ALU), efisiensi cache line ini langsung menentukan performa akhir.<ref name="drepper" /><ref name="hp" />
2. Vektorisasi (SIMD)
Instruksi SIMD (SSE = 4 float, AVX2 = 8 float, AVX-512 = 16 float) paling senang kalau datanya berurutan rapat di memori — cukup satu instruksi load untuk mengisi satu register penuh.<ref name="agner" /><ref name="intelopt" />
SoA + AVX2 (register 256-bit = 8 float):
memori x[] : | x0 | x1 | x2 | x3 | x4 | x5 | x6 | x7 |
| | | | | | | |
v v v v v v v v <- 1 instruksi
register : | x0 | x1 | x2 | x3 | x4 | x5 | x6 | x7 | (vmovups)
AoS + AVX2:
memori : | x0 | y0 | z0 | m0 | x1 | y1 | z1 | m1 | ...
| |
+--- perlu gather / banyak shuffle ---+
(lambat, boros instruksi)
Efek praktisnya: compiler (GCC/Clang/ICC) jauh lebih gampang melakukan auto-vectorization pada loop SoA. Loop AoS sering gagal divektorisasi, atau divektorisasi dengan instruksi gather yang mahal.<ref name="agner" />
3. GPU: Coalesced Memory Access
Di GPU (CUDA/OpenCL), 32 thread dalam satu warp (NVIDIA) atau wavefront (AMD) mengeksekusi instruksi load secara bersamaan. Kalau alamat yang diakses ke-32 thread itu berurutan, hardware menggabungkannya (coalesce) menjadi sedikit transaksi memori saja.<ref name="cuda" />
Warp = 32 thread. Thread ke-t membaca posisi x milik elemen ke-t.
SoA:
thread : t0 t1 t2 t3 ... t31
alamat : x[0] x[1] x[2] x[3] ... x[31]
\__________ 128 byte berurutan __________/
=> digabung jadi transaksi memori minimal (coalesced)
AoS (stride 16 byte):
thread : t0 t1 t2 ...
alamat : p[0].x p[1].x p[2].x ...
(loncat-loncat 16 byte antar thread)
=> butuh transaksi memori jauh lebih banyak,
bandwidth efektif anjlok
Karena itu, hampir semua kode GPU yang serius memakai layout SoA untuk data yang diproses per-elemen. Ini juga berlaku persis sama untuk kernel OpenCL.
Kapan AoS Justru Lebih Baik
SoA bukan jawaban universal. AoS unggul kalau pola aksesnya "semua field dari satu elemen sekaligus":
- Akses acak (random access) per objek — misalnya collision detection: begitu ketemu partikel ke-i, kamu butuh x, y, z, dan mass-nya sekaligus. Dengan AoS, satu cache line langsung membawa seluruh objek itu. Dengan SoA, kamu malah kena 4 cache miss di 4 array berbeda.
- Objek sering di-insert/delete/dipindah sebagai satu kesatuan.
- Data di-serialize atau dikirim per-objek (misalnya lewat jaringan atau MPI dengan datatype per-struct).
- Kode lebih mudah dibaca dan di-maintain — jangan remehkan ini untuk kode yang bukan hotspot.
Aturan praktisnya:
Loop menyentuh SATU field dari BANYAK elemen --> SoA Loop menyentuh SEMUA field dari SATU elemen --> AoS
AoSoA: Jalan Tengah
Array of Structures of Arrays (AoSoA), kadang disebut tiled SoA, menggabungkan keduanya: data dipecah jadi blok-blok kecil selebar register SIMD, dan di dalam blok disusun ala SoA.
constexpr int W = 8; // lebar SIMD (AVX2 = 8 float)
struct ParticleBlock {
float x[W], y[W], z[W], mass[W];
};
std::vector<ParticleBlock> blocks(N / W); // <-- AoSoA
// Akses partikel ke-i:
// blocks[i / W].x[i % W]
+-------------------------------+-------------------------------+ | Blok 0 | Blok 1 | | x0..x7 | y0..y7 | z0..z7 | m..| x8..x15| y8..y15| z8..z15| ...| +-------------------------------+-------------------------------+ ^ di dalam blok: SoA (ramah SIMD) ^ antar blok: AoS (lokalitas objek tetap lumayan)
Kelebihan: load SIMD tetap berurutan, tapi field-field satu elemen masih "berdekatan" di memori. Kekurangan: indexing jadi lebih ribet. Layout ini dipakai antara lain oleh compiler ISPC (tipe soa<N>)<ref name="ispc" /> dan banyak kode simulasi partikel/MD.
Contoh Praktis: Mengukur Sendiri
Benchmark mini untuk membuktikan efeknya (kompilasi dengan -O3 -march=native):
#include <chrono>
#include <cstdio>
#include <vector>
constexpr int N = 1 << 24; // ~16 juta elemen
struct ParticleAoS { float x, y, z, mass; };
int main() {
std::vector<ParticleAoS> aos(N, {1, 2, 3, 4});
std::vector<float> soa_x(N, 1.0f);
// --- AoS: jumlahkan x ---
auto t0 = std::chrono::steady_clock::now();
float s1 = 0;
for (int i = 0; i < N; ++i) s1 += aos[i].x;
auto t1 = std::chrono::steady_clock::now();
// --- SoA: jumlahkan x ---
float s2 = 0;
for (int i = 0; i < N; ++i) s2 += soa_x[i];
auto t2 = std::chrono::steady_clock::now();
auto ms = [](auto a, auto b) {
return std::chrono::duration<double, std::milli>(b - a).count();
};
std::printf("AoS: %.2f ms | SoA: %.2f ms (s1=%f s2=%f)\n",
ms(t0, t1), ms(t1, t2), s1, s2);
}
Yang perlu diamati: loop SoA membaca 64 MB, loop AoS harus "menyeret" 256 MB dari RAM untuk hasil yang sama — selisih waktunya biasanya mendekati rasio bandwidth itu (sekitar 3–4x) pada dataset yang jauh lebih besar dari cache. Cek juga laporan vektorisasi compiler dengan -fopt-info-vec (GCC) atau -Rpass=loop-vectorize (Clang).
Tabel Perbandingan
| Aspek | AoS | SoA | AoSoA |
|---|---|---|---|
| Pola akses ideal | Semua field dari 1 elemen | 1 field dari banyak elemen | Streaming + butuh beberapa field |
| Auto-vectorization (SIMD) | Susah (gather/shuffle) | Mudah (load berurutan) | Mudah |
| GPU memory coalescing | Buruk | Sangat baik | Baik |
| Efisiensi cache (akses parsial) | Rendah (bisa 25% atau kurang) | ~100% | Tinggi |
| Efisiensi cache (akses seluruh objek) | Tinggi | Rendah (miss di banyak array) | Sedang–tinggi |
| Keterbacaan / kenyamanan kode | Paling natural (OOP) | Agak repot | Paling repot (indexing 2 tingkat) |
| Insert/delete elemen | Mudah | Repot (semua array harus sinkron) | Repot |
Tips Praktis
- Profiling dulu. Ubah layout hanya untuk struktur data di hotspot. Mengubah layout itu mahal secara refactoring.
- Align array SoA ke 64 byte (
alignas(64),std::aligned_alloc) supaya load SIMD aligned dan tidak membelah cache line. - Beri tahu compiler bahwa pointer tidak saling tumpang tindih dengan
__restrict__— sering jadi penentu berhasil/tidaknya auto-vectorization. - Di CUDA/OpenCL: field yang diakses per-thread hampir selalu lebih cepat dalam bentuk SoA; tipe vektor bawaan seperti
float4bisa jadi kompromi karena di-load sebagai satu transaksi 16 byte. - Library yang membantu: Kokkos (
Kokkos::Viewbisa gantiLayoutLeft/LayoutRighttanpa mengubah kode kernel), Intel SDLT (SIMD Data Layout Templates), dan ISPC dengan tipesoa.<ref name="ispc" /> - Ingat trade-off-nya: SoA menukar kenyamanan kode dengan bandwidth. Untuk kode non-kritis, AoS yang enak dibaca sering kali pilihan yang benar.
Lihat Juga
Referensi
<references> <ref name="drepper">Ulrich Drepper, What Every Programmer Should Know About Memory, Red Hat, Inc., 2007. https://people.freebsd.org/~lstewart/articles/cpumemory.pdf</ref> <ref name="cuda">NVIDIA, CUDA C++ Best Practices Guide — bagian "Coalesced Access to Global Memory". https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/</ref> <ref name="agner">Agner Fog, Optimizing Software in C++: An Optimization Guide for Windows, Linux, and Mac Platforms. https://www.agner.org/optimize/</ref> <ref name="intelopt">Intel Corporation, Intel 64 and IA-32 Architectures Optimization Reference Manual — pembahasan data layout dan vectorization.</ref> <ref name="fabian">Richard Fabian, Data-Oriented Design: Software Engineering for Limited Resources and Short Schedules, 2018. https://www.dataorienteddesign.com/dodbook/</ref> <ref name="acton">Mike Acton, "Data-Oriented Design and C++", CppCon 2014. https://www.youtube.com/watch?v=rX0ItVEVjHc</ref> <ref name="ispc">Intel, ISPC User's Guide — "Structure of Array Types". https://ispc.github.io/ispc.html</ref> <ref name="hp">John L. Hennessy & David A. Patterson, Computer Architecture: A Quantitative Approach, 6th Edition, Morgan Kaufmann, 2017 — bab Memory Hierarchy Design.</ref> </references>
Kategori:HPC Kategori:Optimasi Performa Kategori:Arsitektur Komputer