Hãy tưởng tượng bạn được giao nhiệm vụ sau với một tệp như thế này:
Name,Department,Salary,JoinDate
John Smith,Marketing,75000,2023-01-15
Alice Johnson,Finance,82000,2022-06-22
Bob Lee,Sales,68000,2024-03-10
Emma Davis,HR,71000,2021-09-01
Bạn muốn chuyển đổi nó thành một danh sách duy nhất chứa tất cả các thuật ngữ trong tệp (có thể rất lớn).
Nói cách khác, bạn muốn chuyển đổi nó thành thứ gì đó như thế này:
[
{"term": "Name", "position": 0, "length": 4},
{"term": "Department", "position": 5, "length": 10},
...
{"term": "2021-09-01", "position": 160, "length": 10}
]
Nói cách khác, có một mảng liên tục duy nhất tham chiếu đến toàn bộ dữ liệu, và điều đó khá hiệu quả để thực hiện. _Lý do_ chúng ta làm điều đó thực ra không quan trọng, nhưng khía cạnh quan trọng là chúng ta đã quan sát thấy hiệu suất kém và sử dụng bộ nhớ cao khi sử dụng cách tiếp cận này.
Hãy giả sử chúng ta có tổng cộng 10 triệu hàng, hoặc 40.000.000 mục. Mỗi mục tốn cho chúng ta 24 byte (8 byte cho Field, 8 byte cho Position, 4 byte cho Length, và 4 byte cho padding). Vì vậy chúng ta kết thúc với khoảng 1GB trong bộ nhớ chỉ để lưu trữ các thứ này.
Chúng ta có thể sử dụng lập trình hướng dữ liệu (Data-Oriented programming) và chia dữ liệu thành các mảng riêng biệt, như sau:
public string[] Fields;
public long[] Positions;
public int[] Lengths;
public Item Get(int i) => new(Fields[i], Positions[i], Lengths[i]);
Cách này giúp chúng ta tiết kiệm khoảng 200MB bộ nhớ, vì bây giờ chúng ta có thể bỏ qua chi phí padding bằng cách chia Item thành các thành phần riêng lẻ.
Bây giờ, chúng ta đã tính đến chi phí bộ nhớ của các chuỗi `Field`. Và đó là vì tất cả chúng đều sử dụng cùng một thực thể chuỗi chính xác (chỉ tên trường được lưu trữ dưới dạng chuỗi).
Về mặt sử dụng bộ nhớ, điều đó có nghĩa là chúng ta không có 40 triệu thực thể chuỗi, mà chỉ có 4.
Tối ưu tiếp theo là giảm chi phí bộ nhớ hơn nữa, như sau:
public string[] FieldsNames; // mảng nhỏ tên các trường - len = 4
public byte[] FieldIndexes; // chỉ mục của tên trường
public long[] Positions;
public int[] Lengths;
public Item Get(int i) => new(
FieldsNames[FieldIndexes[i]],
Positions[i],
Lengths[i]
);
Vì chúng ta biết rằng chúng ta có một tập hợp rất nhỏ các tên trường, chúng ta lưu giữ tất cả chúng trong một mảng duy nhất và tham chiếu đến chúng bằng cách sử dụng chỉ mục (trong trường hợp này, chỉ sử dụng một byte). Về mặt sử dụng bộ nhớ, chúng ta đã giảm từ khoảng 1GB xuống còn ít hơn một nửa.
Cho đến nay, điều đó khá giống như mong đợi. Điều gì _không được mong đợi_ là sự sụt giảm đáng kể trong việc sử dụng CPU do thay đổi cuối cùng này.
Bạn có thể đoán ra lý do tại sao không?
Chìa khóa ở đây là sự thay đổi này:
- public string[] FieldNames;
+ public byte[] FieldIndexes;
Kích thước của mảng trong ví dụ của chúng ta là 40.000.000 phần tử. Vì vậy điều này đại diện cho việc chuyển từ tham chiếu 8 byte sang chỉ mục 1 byte trong mảng `FieldNames`. Lý do cho việc tiết kiệm bộ nhớ là rõ ràng, nhưng lý do cho việc giảm sử dụng _CPU_ là gì?
Trong trường hợp này, bạn phải hiểu mã hóa mà _không có_ ở đó. Khi chúng ta viết trong C#, chúng ta có một đối tác thầm lặng mà chúng ta phải đối mặt, GC. Vì vậy hãy xem xét GC cần làm gì khi nó gặp một mảng chuỗi:
GC đánh dấu mảng có thể truy cập được, sau đó duyệt và đánh dấu _mỗi_ đối tượng chuỗi được tham chiếu. Nó phải duyệt toàn bộ mảng, thực hiện một hoạt động cho mỗi giá trị trong mảng, bất kể giá trị đó là gì (hoặc dù GC đã thấy nó trước đó).
Vì vấn đề đó, ngay cả khi mảng được điền bằng `null`, GC vẫn phải đi qua mảng để xác minh điều đó, điều đó có chi phí đối với các mảng lớn.
Ngược lại, GC cần làm gì khi nó chạy vào một mảng byte:
GC đánh dấu mảng là có thể truy cập được, và vì nó biết rằng không có tham chiếu nào được tìm thấy ở đó, nó đã hoàn thành.
Nói cách khác, thay đổi trong mô hình dữ liệu của chúng ta đã dẫn đến việc chi phí của GC giảm đáng kể.
Điều này có ý nghĩa hoàn hảo khi bạn nghĩ về nó, nhưng đó là một kết quả khá đáng ngạc nhiên khi gặp phải khi làm việc với các tối ưu bộ nhớ.