Câu hỏi của tôi là, vì tôi không lặp lại tuyến tính một mảng liền kề tại một thời điểm trong các trường hợp này, tôi có ngay lập tức hy sinh hiệu suất đạt được từ việc phân bổ các thành phần theo cách này không?
Rất có thể là bạn sẽ nhận được ít bộ nhớ cache hơn với các mảng "dọc" riêng biệt cho mỗi loại thành phần hơn là xen kẽ các thành phần được gắn vào một thực thể trong một khối có kích thước thay đổi "theo chiều ngang".
Lý do là bởi vì, đầu tiên, biểu diễn "dọc" sẽ có xu hướng sử dụng ít bộ nhớ hơn. Bạn không phải lo lắng về việc căn chỉnh cho các mảng đồng nhất được phân bổ liên tục. Với các loại không đồng nhất được phân bổ vào nhóm bộ nhớ, bạn không phải lo lắng về việc căn chỉnh vì phần tử đầu tiên trong mảng có thể có các yêu cầu căn chỉnh và kích thước hoàn toàn khác với phần thứ hai. Kết quả là bạn sẽ thường cần thêm phần đệm, ví dụ như một ví dụ đơn giản:
// Assuming 8-bit chars and 64-bit doubles.
struct Foo
{
// 1 byte
char a;
// 1 byte
char b;
};
struct Bar
{
// 8 bytes
double opacity;
// 8 bytes
double radius;
};
Hãy nói rằng chúng ta muốn interleave Foo
và Bar
và lưu trữ chúng ngay bên cạnh nhau trong bộ nhớ:
// Assuming 8-bit chars and 64-bit doubles.
struct FooBar
{
// 1 byte
char a;
// 1 byte
char b;
// 6 bytes padding for 64-bit alignment of 'opacity'
// 8 bytes
double opacity;
// 8 bytes
double radius;
};
Bây giờ thay vì lấy 18 byte để lưu trữ Foo và Bar trong các vùng bộ nhớ riêng biệt, phải mất 24 byte để kết hợp chúng. Không thành vấn đề nếu bạn trao đổi thứ tự:
// Assuming 8-bit chars and 64-bit doubles.
struct BarFoo
{
// 8 bytes
double opacity;
// 8 bytes
double radius;
// 1 byte
char a;
// 1 byte
char b;
// 6 bytes padding for 64-bit alignment of 'opacity'
};
Nếu bạn chiếm nhiều bộ nhớ hơn trong ngữ cảnh truy cập tuần tự mà không cải thiện đáng kể các mẫu truy cập, thì nhìn chung bạn sẽ phải chịu nhiều lỗi nhớ cache hơn. Trên hết, bước tiến để có được từ một thực thể này đến lần tăng tiếp theo và đến một kích thước thay đổi, khiến bạn phải thực hiện các bước nhảy có kích thước thay đổi trong bộ nhớ để chuyển từ thực thể này sang thực thể tiếp theo để xem những thực thể nào có các thành phần bạn ' lại quan tâm
Vì vậy, sử dụng biểu diễn "dọc" khi bạn lưu trữ các loại thành phần thực sự có khả năng tối ưu hơn so với các lựa chọn thay thế "ngang". Điều đó nói rằng, vấn đề với lỗi nhớ cache với biểu diễn dọc có thể được minh họa ở đây:
Trong đó các mũi tên chỉ đơn giản chỉ ra rằng thực thể "sở hữu" một thành phần. Chúng ta có thể thấy rằng nếu chúng ta cố gắng truy cập tất cả các thành phần chuyển động và kết xuất của các thực thể có cả hai, cuối cùng chúng ta sẽ nhảy khắp nơi trong bộ nhớ. Kiểu truy cập lẻ tẻ đó có thể khiến bạn tải dữ liệu vào một dòng bộ đệm để truy cập, giả sử, một thành phần chuyển động, sau đó truy cập vào nhiều thành phần hơn và dữ liệu trước đó đã bị trục xuất, chỉ để tải lại cùng một vùng bộ nhớ đã bị đuổi ra khỏi một chuyển động khác thành phần. Vì vậy, điều đó có thể rất lãng phí khi tải cùng một vùng bộ nhớ chính xác nhiều lần vào một dòng bộ đệm chỉ để lặp qua và truy cập danh sách các thành phần.
Hãy dọn dẹp mớ hỗn độn đó một chút để chúng ta có thể nhìn rõ hơn:
Lưu ý rằng nếu bạn gặp phải loại kịch bản này, thường thì rất lâu sau khi trò chơi bắt đầu chạy, sau khi nhiều thành phần và thực thể đã được thêm và xóa. Nói chung khi trò chơi bắt đầu, bạn có thể thêm tất cả các thực thể và các thành phần có liên quan lại với nhau, tại thời điểm đó chúng có thể có một mẫu truy cập tuần tự, rất trật tự với địa phương không gian tốt. Sau rất nhiều lần gỡ bỏ và chèn thêm, cuối cùng bạn có thể nhận được một cái gì đó giống như mớ hỗn độn ở trên.
Một cách rất dễ dàng để cải thiện tình huống đó là chỉ đơn giản là sắp xếp các thành phần của bạn dựa trên ID / chỉ mục thực thể sở hữu chúng. Tại thời điểm đó, bạn nhận được một cái gì đó như thế này:
Và đó là một mẫu truy cập thân thiện với bộ nhớ cache hơn nhiều. Nó không hoàn hảo vì chúng ta có thể thấy rằng chúng ta phải bỏ qua một số thành phần kết xuất và chuyển động ở đây và ở đó vì hệ thống của chúng ta chỉ quan tâm đến các thực thể có cả hai và một số thực thể chỉ có thành phần chuyển động và một số chỉ có thành phần kết xuất , nhưng ít nhất bạn cuối cùng cũng có thể xử lý một số thành phần tiếp giáp (thông thường hơn, thông thường, vì thông thường, bạn sẽ đính kèm các thành phần quan tâm có liên quan, như có thể nhiều thực thể trong hệ thống của bạn có thành phần chuyển động sẽ có thành phần kết xuất hơn không phải).
Quan trọng nhất, một khi bạn đã sắp xếp những thứ này, bạn sẽ không tải dữ liệu một vùng bộ nhớ vào một dòng bộ đệm để sau đó tải lại nó trong một vòng lặp.
Và điều này không đòi hỏi một số thiết kế cực kỳ phức tạp, chỉ là một loại cơ số thời gian tuyến tính vượt qua mọi lúc, sau đó, có thể sau khi bạn đã chèn và loại bỏ một loạt các thành phần cho một loại thành phần cụ thể, tại đó bạn có thể đánh dấu nó là cần được sắp xếp Một loại cơ số được triển khai hợp lý (thậm chí bạn có thể song song hóa nó, mà tôi làm) có thể sắp xếp một triệu phần tử trong khoảng 6ms trên i7 lõi tứ của tôi, như được minh họa ở đây:
Sorting 1000000 elements 32 times...
mt_sort_int: {0.203000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_sort: {1.248000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_radix_sort: {0.202000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
std::sort: {1.810000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
qsort: {2.777000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
Ở trên là sắp xếp một triệu phần tử 32 lần (bao gồm cả thời gian để memcpy
kết quả trước và sau khi sắp xếp). Và tôi cho rằng hầu hết thời gian bạn sẽ không thực sự có hàng triệu thành phần để sắp xếp, vì vậy bạn sẽ rất dễ dàng có thể lén điều này ngay bây giờ và ở đó mà không gây ra bất kỳ sự chậm trễ đáng chú ý nào.