Bộ nhớ/AoS vs SoA — bố cục dữ liệu theo cache
11/13
Bài 11 / 13~18 phútCache & memory hierarchyMiễn phí lượt xem

AoS vs SoA — bố cục dữ liệu theo cache

Array of structs (AoS) hay struct of arrays (SoA): cùng dữ liệu, hai bố cục bộ nhớ. Khi vòng lặp chỉ dùng vài field, SoA giảm cache miss mạnh. Cách chọn và đo trước-sau.

TL;DR: Cùng một tập dữ liệu có hai cách bố trí trong bộ nhớ. AoS (Array of Structs): mảng các bản ghi, mỗi bản ghi gói đủ field liền nhau — [{x,y,z}, {x,y,z}, ...]. SoA (Struct of Arrays): mỗi field thành một mảng riêng — xs[], ys[], zs[]. Khi vòng lặp nóng chỉ dùng một phần field (ví dụ chỉ x), SoA cho mỗi cache line toàn x dùng được; còn AoS kéo cả y, z vô ích vào line, phí băng thông. SoA cũng mở đường cho SIMD (xử lý nhiều phần tử cùng lúc). Đổi lại, AoS thuận khi bạn dùng cả bản ghi cùng lúc, hoặc thêm/xoá nguyên bản ghi. Đây là một quyết định data-oriented design: tổ chức dữ liệu theo cách nó được truy cập, không theo cách nó "trông gọn gàng" về mặt OOP. Capstone của course sẽ đo trực tiếp chênh lệch này.

Bạn lưu một triệu hạt (particle) trong game vật lý, mỗi hạt có vị trí, vận tốc, màu, khối lượng, id. Vòng lặp cập nhật vật lý mỗi frame chỉ cần vị trí và vận tốc. Lưu chúng kiểu "mảng các object Particle" nghe tự nhiên — nhưng nó khiến mỗi cache line kéo về cả màu, khối lượng, id không dùng. Đổi sang "vài mảng song song, mỗi mảng một field" có thể tăng tốc gấp đôi. Khác biệt không ở thuật toán, mà ở bố cục dữ liệu.

Bài này so sánh AoS và SoA, giải thích vì sao SoA thắng trong vòng lặp dùng ít field, và khi nào AoS vẫn là lựa chọn đúng.

1. Analogy — hồ sơ nhân viên: theo người hay theo cột

Một công ty lưu hồ sơ nhân viên. Hai cách:

  • Theo người (AoS): mỗi nhân viên một tập hồ sơ đầy đủ — tên, lương, phòng ban, ngày sinh, ảnh — cất chung một folder. Cần xem hết thông tin một người: tiện, mở một folder là đủ.
  • Theo cột (SoA): một danh sách tất cả tên, một danh sách tất cả lương, một danh sách tất cả phòng ban... Cần tính tổng lương cả công ty: chỉ rút đúng danh sách lương, không phải mở từng folder lật qua ảnh và ngày sinh.

Nếu thao tác thường gặp là "duyệt một field qua mọi người" (tính tổng lương, lọc theo phòng), cách theo cột nhanh hơn nhiều. Nếu thao tác là "lấy đủ thông tin một người", cách theo người tiện hơn.

Hồ sơ nhân viênBố cục dữ liệu
Folder đầy đủ mỗi ngườiAoS — array of structs
Danh sách riêng từng cộtSoA — struct of arrays
Xem hết thông tin một ngườiTruy cập nhiều field của một phần tử → AoS hợp
Tính tổng một cột qua mọi ngườiDuyệt một field qua mọi phần tử → SoA hợp
💡 Cách nhớ

Câu hỏi quyết định: vòng lặp nóng của bạn duyệt nhiều field của một phần tử (→ AoS) hay một field qua nhiều phần tử (→ SoA)? Bố trí dữ liệu theo chiều bạn duyệt nó nhiều nhất.

2. Cơ chế — hai bố cục trong bộ nhớ

Xét một điểm 3D với toạ độ x, y, z (mỗi float 4 byte) và vài field khác.

AoS — mảng các struct, mỗi struct gói đủ field liền nhau:

AoS: [x0 y0 z0 m0][x1 y1 z1 m1][x2 y2 z2 m2]...
      \__struct 0__/\__struct 1__/
Cache line keo ve: x0 y0 z0 m0 x1 y1 z1 ...  (lan du loai field)

SoA — mỗi field một mảng riêng, đặt cạnh chỉ số:

SoA: xs = [x0 x1 x2 x3 x4 ...]   <- toan x, lien nhau
     ys = [y0 y1 y2 y3 y4 ...]
     zs = [z0 z1 z2 z3 z4 ...]
     ms = [m0 m1 m2 m3 m4 ...]
Cache line cua xs keo ve: x0 x1 x2 ... x15  (toan x dung duoc)

Giả sử một vòng lặp chỉ cần x (ví dụ tìm x lớn nhất):

  • AoS: mỗi cache line 64 byte chứa ~4 struct (nếu struct 16 byte), nhưng chỉ 4 giá trị x dùng được — 75% line là y, z, m vô ích. Tỉ lệ dữ liệu hữu ích ~25%.
  • SoA: cache line của xs chứa 16 giá trị x — 100% dùng được. Tỉ lệ hữu ích 100%, miss ít hơn ~4 lần.
flowchart TB
  subgraph aos["AoS — duyet chi x"]
    direction LR
    A1["line: x y z m x y z m"] --> A2["chi 2/8 la x can"]
  end
  subgraph soa["SoA — duyet chi x"]
    direction LR
    S1["line: x x x x x x x x"] --> S2["8/8 la x can"]
  end

Với dữ liệu lớn hơn cache, chênh lệch tỉ lệ hữu ích chuyển thẳng thành chênh lệch số cache miss, và thành chênh lệch thời gian.

3. SoA và SIMD

SoA còn một lợi thế thứ hai: nó hợp với SIMD (Single Instruction Multiple Data — một lệnh xử lý nhiều giá trị cùng lúc, ví dụ cộng 8 float trong một lệnh AVX). SIMD cần dữ liệu cùng loại nằm liền nhau để nạp một lúc vào thanh ghi vector. SoA cho đúng điều đó: xs là 16 x liền nhau, nạp thẳng 8–16 cái vào một thanh ghi SIMD và xử lý song song.

AoS thì khó hơn: x nằm cách nhau bởi y, z, m, nên muốn gom 8 x để SIMD phải "gather" rải rác — chậm hoặc bất khả. Đây là lý do code hiệu năng cao (game engine, xử lý tín hiệu, ML) thường dùng SoA cho các vòng lặp nóng.

// SoA: cong delta vao toan bo x bang SIMD (vi du dich chuyen hat theo truc x)
for i = 0; i < n; i += 8:
    vec = simd_load(xs + i)       // nap 8 float x lien nhau tu xs[]
    vec = simd_add(vec, delta8)   // cong 8 gia tri cung mot lenh
    simd_store(xs + i, vec)       // ghi 8 ket qua ve
// AoS khong goi gon vay duoc: 8 x nam cach nhau boi y, z, m -> phai gather
Data-oriented design — tư duy ngược OOP một chút

OOP dạy gói dữ liệu liên quan vào một object (Particle có đủ position, velocity, color). Đẹp về mô hình hoá, nhưng không phải lúc nào tối ưu cho cách CPU đọc dữ liệu. Data-oriented design (DOD) đảo lại: tổ chức dữ liệu theo cách nó được xử lý theo lô, không theo "thực thể" khái niệm. Nếu hệ thống vật lý duyệt position+velocity của mọi hạt mỗi frame, thì position và velocity nên là các mảng song song (SoA), bất kể về mặt khái niệm chúng "thuộc về" một hạt. DOD không phủ nhận OOP — nó nhắc rằng bố cục bộ nhớ là một quyết định hiệu năng riêng, tách khỏi mô hình hoá khái niệm.

4. Khi nào AoS, khi nào SoA

Không có cái nào luôn thắng — chọn theo mẫu truy cập:

Tình huốngNên dùngVì sao
Vòng lặp dùng ít field qua nhiều phần tửSoAMỗi line toàn field cần, ít miss, hợp SIMD
Vòng lặp dùng cả bản ghi cùng lúcAoSCác field của một phần tử cùng line, một lần nạp đủ
Thêm/xoá nguyên phần tử thường xuyênAoSMột thao tác trên một struct; SoA phải sửa mọi mảng
Cần SIMD trên một fieldSoAField liền nhau nạp thẳng vào thanh ghi vector
Dữ liệu nhỏ (vừa cache)Tuỳ tiệnKhác biệt không đáng kể, ưu tiên code rõ
Cần truyền/nối tiếp nguyên bản ghiAoSBản ghi liền khối, dễ copy/serialize
SoA làm code khó đọc hơn — cân nhắc thật

SoA phá tính đóng gói: thay vì một Particle p, bạn thao tác xs[i], ys[i], vs[i] — dễ lệch chỉ số, khó bảo trì, mất an toàn kiểu. Chỉ chuyển sang SoA khi: (1) đây là hot path thật, (2) dữ liệu lớn hơn cache, (3) profiler xác nhận cache miss là bottleneck, (4) speedup đo được biện minh cho độ phức tạp thêm. Nhiều ngôn ngữ/thư viện (ECS trong game, dataframe như pandas/Arrow) cung cấp SoA "đóng gói sẵn" để bạn hưởng lợi mà không tự quản chỉ số.

5. Áp dụng vào code của bạn

Quy trình quyết định bố cục:

  1. Xác định vòng lặp nóng và field nó dùng. Nếu hot loop chạm hầu hết field của mỗi phần tử → AoS giữ nguyên. Nếu nó chỉ chạm 1–2 field qua hàng triệu phần tử → ứng viên SoA.
  2. Ước lượng tỉ lệ hữu ích. AoS: kích thước field dùng / kích thước cả struct. Nếu tỉ lệ thấp (dùng 8/64 byte = 12%), SoA có thể tăng tốc tới ~8 lần ở phần chờ bộ nhớ.
  3. Cân nhắc chi phí bảo trì. SoA phức tạp hoá code; chỉ nhận nếu speedup đáng. Cân nhắc dùng thư viện SoA sẵn (ECS, columnar store) thay vì tự quản mảng song song.
  4. Đo trước-sau. Viết cả hai bản, benchmark trên dữ liệu thật kích thước thật, so cache-miss và thời gian. Capstone course này (bài cuối Module 4) cho bạn thực hành đúng quy trình đó.

Một dấu hiệu nhận biết trong thực tế: các hệ thống xử lý dữ liệu cột (columnar database như ClickHouse, định dạng Apache Arrow/Parquet, dataframe) về bản chất là SoA ở quy mô lớn — chúng lưu theo cột chính vì truy vấn phân tích thường quét vài cột qua hàng tỉ dòng, đúng kịch bản SoA thắng.

6. Liên hệ các bài khác

7. Tóm tắt

  • AoS (array of structs): mảng bản ghi, mỗi bản ghi gói đủ field liền nhau. SoA (struct of arrays): mỗi field một mảng riêng.
  • Vòng lặp dùng ít field qua nhiều phần tử → SoA cho mỗi cache line toàn field cần, giảm miss tới vài lần. Dùng cả bản ghi → AoS hợp hơn.
  • SoA còn mở đường cho SIMD: field liền nhau nạp thẳng vào thanh ghi vector, xử lý nhiều phần tử một lệnh.
  • Đây là tư duy data-oriented design: bố cục dữ liệu theo cách nó được xử lý theo lô, tách khỏi mô hình hoá khái niệm OOP.
  • AoS vẫn thắng khi: dùng cả bản ghi, thêm/xoá nguyên phần tử, hoặc cần serialize cả bản ghi.
  • SoA phức tạp hoá code (lệch chỉ số, mất đóng gói) — chỉ chọn khi hot path, dữ liệu lớn hơn cache, và speedup đo được biện minh. Columnar DB và dataframe là SoA ở quy mô lớn.

8. Tự kiểm tra

Tự kiểm tra
Q1
Mô tả khác biệt bố cục bộ nhớ giữa AoS và SoA cho một tập điểm 3D (x, y, z).
AoS (array of structs): một mảng các bản ghi, mỗi bản ghi gói x, y, z liền nhau — bộ nhớ là [x0 y0 z0][x1 y1 z1][x2 y2 z2].... Các field của cùng một điểm nằm cạnh nhau. SoA (struct of arrays): ba mảng riêng — xs = [x0 x1 x2 ...], ys = [y0 y1 y2 ...], zs = [z0 z1 z2 ...]. Các giá trị cùng một field nằm cạnh nhau, còn ba toạ độ của một điểm thì cách xa (ở ba mảng khác nhau, cùng chỉ số i). Cùng dữ liệu, khác hoàn toàn cách sắp trong bộ nhớ — và đó là điều quyết định cache hit/miss khi duyệt.
Q2
Một vòng lặp chỉ đọc field x của một triệu điểm. Vì sao SoA giảm cache miss so với AoS, và giảm khoảng bao nhiêu?
Với AoS, mỗi cache line 64 byte chứa vài struct đầy đủ, nhưng chỉ vài giá trị x trong đó là cần — phần còn lại (y, z, field khác) bị kéo về vô ích. Nếu struct 16 byte (x,y,z,m), mỗi line có 4 struct = 4 giá trị x hữu ích trên 16 giá trị → ~25% hữu ích. Với SoA, cache line của mảng xs chứa 16 giá trị x liền nhau — 100% hữu ích. Vì cùng số phần tử cần đọc, SoA dùng ít cache line hơn ~4 lần để lấy đủ các x, nên số cache miss (và thời gian chờ RAM) giảm tương ứng ~4 lần. Mức giảm chính xác bằng tỉ lệ kích thước struct / kích thước field dùng.
Q3
Vì sao SoA hợp với SIMD còn AoS thì khó?
SIMD (một lệnh xử lý nhiều giá trị, ví dụ cộng 8 float bằng một lệnh AVX) cần các giá trị cùng loại nằm liền nhau để nạp một lượt vào thanh ghi vector. Trong SoA, mảng xs là các x liền nhau, nên nạp thẳng 8–16 cái vào một thanh ghi SIMD và xử lý song song — lý tưởng. Trong AoS, các x bị xen kẽ bởi y, z, m nên cách nhau trong bộ nhớ; muốn gom 8 x phải làm "gather" từ các vị trí rải rác — chậm hoặc không khả thi với phần cứng SIMD đơn giản. Vì thế code cần vector hoá hot loop (game engine, xử lý tín hiệu, ML) thường chọn SoA: nó vừa giảm cache miss, vừa mở khoá throughput SIMD.
Q4
Nêu hai tình huống AoS là lựa chọn đúng hơn SoA.
(1) Vòng lặp dùng cả bản ghi cùng lúc: nếu mỗi lần xử lý một phần tử bạn cần hầu hết field của nó (ví dụ render một đối tượng cần position + color + size cùng lúc), AoS giữ các field đó cùng cache line — một lần nạp đủ; SoA thì phải chạm nhiều mảng, mỗi mảng một line. (2) Thêm/xoá nguyên phần tử thường xuyên: với AoS, thêm một phần tử là một thao tác trên một struct; với SoA phải thêm vào mọi mảng song song và giữ chúng đồng bộ, dễ lỗi và tốn hơn. Ngoài ra AoS cũng thuận khi cần serialize/truyền nguyên bản ghi (nó đã là một khối liền). Quy tắc: AoS khi đơn vị truy cập là "cả phần tử"; SoA khi đơn vị truy cập là "một field qua nhiều phần tử".
Q5
Columnar database (ClickHouse) và định dạng Apache Arrow lưu dữ liệu theo cột. Liên hệ điều này với AoS/SoA và giải thích vì sao nó hợp cho truy vấn phân tích.
Lưu "theo cột" chính là SoA ở quy mô lớn: mỗi cột (field) được lưu thành một vùng liền nhau, thay vì lưu từng dòng (bản ghi) đầy đủ liền nhau như cách "theo dòng" (AoS, kiểu OLTP truyền thống). Truy vấn phân tích thường quét vài cột qua hàng tỉ dòng — ví dụ "tổng doanh thu theo tháng" chỉ cần cột doanh thu và cột tháng, không cần 50 cột khác. Với bố cục cột (SoA), chỉ đúng các cột cần được đọc từ đĩa/bộ nhớ, mỗi cache line (và mỗi block đĩa) toàn dữ liệu hữu ích; còn bố cục dòng (AoS) sẽ kéo về cả bản ghi đầy đủ, phí phần lớn băng thông. Cộng thêm: cột cùng kiểu liền nhau nén tốt hơn và hợp SIMD. Đây là lý do cốt lõi columnar store thắng cho workload analytics — cùng nguyên lý cache line ở bài 02, áp lên tầng đĩa và bộ nhớ ở quy mô tỉ dòng.

Bài tiếp theo: False sharing & cache coherence

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên