Nhưng OOP này có thể là một bất lợi cho phần mềm dựa trên hiệu suất, tức là chương trình thực thi nhanh như thế nào?
Thường thì có !!! NHƯNG...
Nói cách khác, nhiều tham chiếu giữa nhiều đối tượng khác nhau, hoặc sử dụng nhiều phương thức từ nhiều lớp, có thể dẫn đến việc thực hiện "nặng" không?
Không cần thiết. Điều này phụ thuộc vào ngôn ngữ / trình biên dịch. Ví dụ, trình biên dịch C ++ tối ưu hóa, với điều kiện bạn không sử dụng các hàm ảo, thường sẽ đè bẹp đối tượng của bạn xuống không. Bạn có thể thực hiện những việc như viết một trình bao bọc trên int
đó hoặc một con trỏ thông minh có phạm vi trên một con trỏ cũ đơn giản, hoạt động nhanh như sử dụng trực tiếp các loại dữ liệu cũ đơn giản này.
Trong các ngôn ngữ khác như Java, có một chút chi phí cho một đối tượng (thường khá nhỏ trong nhiều trường hợp, nhưng thiên văn học trong một số trường hợp hiếm hoi có các đối tượng thực sự tuổi teen). Ví dụ, Integer
có hiệu quả thấp hơn đáng kể so với int
(lấy 16 byte so với 4 trên 64 bit). Tuy nhiên, đây không chỉ là chất thải trắng trợn hoặc bất cứ thứ gì thuộc loại đó. Đổi lại, Java cung cấp những thứ như sự phản chiếu trên mọi loại do người dùng định nghĩa thống nhất, cũng như khả năng ghi đè bất kỳ chức năng nào không được đánh dấu là final
.
Tuy nhiên, hãy lấy kịch bản trường hợp tốt nhất: trình biên dịch C ++ tối ưu hóa có thể tối ưu hóa giao diện đối tượng xuống không chi phí. Thậm chí sau đó, OOP thường sẽ làm giảm hiệu suất và ngăn không cho nó đạt đến đỉnh cao. Điều đó có vẻ như là một nghịch lý hoàn toàn: làm thế nào nó có thể? Vấn đề nằm ở:
Thiết kế giao diện và đóng gói
Vấn đề là ngay cả khi trình biên dịch có thể nén cấu trúc của một đối tượng xuống không chi phí (điều này ít nhất là rất đúng để tối ưu hóa trình biên dịch C ++), việc đóng gói và thiết kế giao diện (và phụ thuộc tích lũy) của các đối tượng hạt mịn thường sẽ ngăn chặn hầu hết các biểu diễn dữ liệu tối ưu cho các đối tượng được dự định tổng hợp bởi số đông (thường là trường hợp đối với phần mềm quan trọng về hiệu năng).
Lấy ví dụ này:
class Particle
{
public:
...
private:
double birth; // 8 bytes
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
/*padding*/ // 4 bytes of padding
};
Particle particles[1000000]; // 1mil particles (~24 megs)
Giả sử mô hình truy cập bộ nhớ của chúng ta chỉ đơn giản là lặp qua các hạt này một cách tuần tự và di chuyển chúng xung quanh từng khung hình, đưa chúng ra khỏi các góc của màn hình và sau đó hiển thị kết quả.
Chúng ta có thể thấy một phần đệm 4 byte sáng chói cần thiết để sắp xếp birth
thành viên đúng cách khi các hạt được tổng hợp liên tục. Đã ~ 16,7% bộ nhớ bị lãng phí với không gian chết được sử dụng để căn chỉnh.
Điều này có vẻ không ổn vì chúng ta có hàng gigabyte DRAM những ngày này. Tuy nhiên, ngay cả những cỗ máy quái thú nhất chúng ta có ngày nay thường chỉ có 8 megabyte khi nói đến vùng chậm nhất và lớn nhất của bộ đệm CPU (L3). Chúng ta càng ít có thể phù hợp ở đó, chúng ta càng trả nhiều tiền hơn cho việc truy cập DRAM lặp đi lặp lại và những thứ chậm hơn nhận được. Đột nhiên, lãng phí 16,7% bộ nhớ dường như không còn là một thỏa thuận tầm thường.
Chúng tôi có thể dễ dàng loại bỏ chi phí này mà không có bất kỳ tác động nào đến việc căn chỉnh trường:
class Particle
{
public:
...
private:
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
};
Particle particles[1000000]; // 1mil particles (~12 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Bây giờ chúng tôi đã giảm bộ nhớ từ 24 megs xuống còn 20 megs. Với kiểu truy cập tuần tự, giờ đây máy sẽ tiêu thụ dữ liệu này nhanh hơn một chút.
Nhưng hãy nhìn vào birth
lĩnh vực này kỹ hơn một chút. Giả sử nó ghi lại thời gian bắt đầu khi một hạt được sinh ra (được tạo ra). Hãy tưởng tượng trường chỉ được truy cập khi một hạt được tạo lần đầu tiên và cứ sau 10 giây để xem một hạt có chết đi và được tái sinh ở một vị trí ngẫu nhiên trên màn hình hay không. Trong trường hợp đó, birth
là một lĩnh vực lạnh. Nó không được truy cập trong các vòng lặp hiệu suất quan trọng của chúng tôi.
Kết quả là, dữ liệu quan trọng về hiệu năng thực tế không phải là 20 megabyte mà thực sự là một khối liền kề 12 megabyte. Bộ nhớ nóng thực tế chúng ta truy cập thường xuyên đã giảm xuống một nửa kích thước của nó! Yêu cầu tăng tốc đáng kể so với giải pháp 24 megabyte ban đầu của chúng tôi (không cần đo lường - đã thực hiện loại công cụ này hàng ngàn lần, nhưng hãy yên tâm nếu nghi ngờ).
Tuy nhiên, hãy chú ý những gì chúng tôi đã làm ở đây. Chúng tôi đã phá vỡ hoàn toàn việc đóng gói của đối tượng hạt này. Trạng thái của nó hiện được phân chia giữa Particle
các trường riêng của một loại và một mảng song song riêng biệt. Và đó là nơi thiết kế hướng đối tượng dạng hạt cản trở.
Chúng ta không thể biểu thị biểu diễn dữ liệu tối ưu khi giới hạn trong thiết kế giao diện của một đối tượng rất đơn giản như một hạt, một pixel, thậm chí là một vectơ 4 thành phần, thậm chí có thể là một đối tượng "sinh vật" duy nhất trong trò chơi , v.v. Tốc độ của một con báo sẽ bị lãng phí nếu nó đứng trên một hòn đảo tuổi teen rộng 2 mét vuông, và đó là điều mà thiết kế hướng đối tượng rất chi tiết thường làm về mặt hiệu suất. Nó giới hạn biểu diễn dữ liệu với bản chất phụ tối ưu.
Để giải quyết vấn đề này, giả sử rằng chúng ta chỉ di chuyển các hạt xung quanh, chúng ta thực sự có thể truy cập các trường x / y / z của chúng trong ba vòng riêng biệt. Trong trường hợp đó, chúng ta có thể hưởng lợi từ nội tại SIMD kiểu SoA với các thanh ghi AVX có thể vector hóa 8 hoạt động SPFP song song. Nhưng để làm điều này, bây giờ chúng ta phải sử dụng đại diện này:
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Bây giờ chúng ta đang bay với mô phỏng hạt, nhưng hãy nhìn những gì đã xảy ra với thiết kế hạt của chúng ta. Nó đã bị phá hủy hoàn toàn, và chúng tôi hiện đang xem xét 4 mảng song song và không có đối tượng nào để tổng hợp chúng. Particle
Thiết kế hướng đối tượng của chúng tôi đã đi sayonara.
Điều này đã xảy ra với tôi nhiều lần làm việc trong các lĩnh vực quan trọng về hiệu suất, nơi người dùng yêu cầu tốc độ chỉ với sự chính xác là điều họ yêu cầu nhiều hơn. Những thiết kế hướng đối tượng nhỏ bé này phải được phá hủy, và sự phá vỡ tầng thường yêu cầu chúng tôi sử dụng chiến lược khấu hao chậm đối với thiết kế nhanh hơn.
Giải pháp
Kịch bản trên chỉ đưa ra một vấn đề với các thiết kế hướng đối tượng chi tiết . Trong những trường hợp đó, chúng tôi thường phải phá hủy cấu trúc để thể hiện các biểu diễn hiệu quả hơn do các đại diện SoA, phân tách trường nóng / lạnh, giảm đệm cho các mẫu truy cập tuần tự (đệm đôi khi hữu ích cho hiệu suất với truy cập ngẫu nhiên các mẫu trong các trường hợp AoS, nhưng hầu như luôn là một trở ngại cho các mẫu truy cập tuần tự), v.v.
Tuy nhiên, chúng ta có thể lấy đại diện cuối cùng mà chúng ta đã giải quyết và vẫn mô hình hóa một giao diện hướng đối tượng:
// Represents a collection of particles.
class ParticleSystem
{
public:
...
private:
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
};
Bây giờ chúng tôi tốt. Chúng ta có thể nhận được tất cả các tính năng hướng đối tượng mà chúng ta thích. Cheetah có cả một đất nước để chạy qua nhanh nhất có thể. Thiết kế giao diện của chúng tôi không còn bẫy chúng tôi vào một góc cổ chai.
ParticleSystem
thậm chí có thể trừu tượng và sử dụng các chức năng ảo. Bây giờ, chúng ta đang trả tiền cho chi phí chung ở cấp tập hợp các hạt thay vì ở cấp độ mỗi hạt . Chi phí hoạt động là 1 / 1.000.000 so với mức khác nếu chúng ta mô hình hóa các đối tượng ở cấp hạt riêng lẻ.
Vì vậy, đó là giải pháp trong các lĩnh vực quan trọng về hiệu năng thực sự xử lý tải nặng và cho tất cả các loại ngôn ngữ lập trình (kỹ thuật này mang lại lợi ích cho C, C ++, Python, Java, JavaScript, Lua, Swift, v.v.). Và nó không thể dễ dàng được gắn nhãn là "tối ưu hóa sớm", vì điều này liên quan đến thiết kế giao diện và kiến trúc . Chúng ta không thể viết một cơ sở mã hóa mô hình hóa một hạt như một đối tượng với một khối lượng phụ thuộc máy khách vào mộtParticle's
giao diện công cộng và sau đó thay đổi tâm trí của chúng tôi sau này. Tôi đã làm điều đó rất nhiều khi được gọi để tối ưu hóa các cơ sở mã kế thừa và cuối cùng có thể mất hàng tháng để viết lại hàng chục ngàn dòng mã một cách cẩn thận để sử dụng thiết kế cồng kềnh. Điều này lý tưởng ảnh hưởng đến cách chúng tôi thiết kế mọi thứ trả trước với điều kiện là chúng tôi có thể dự đoán được một tải nặng.
Tôi tiếp tục lặp lại câu trả lời này dưới hình thức này hay hình thức khác trong nhiều câu hỏi về hiệu suất, và đặc biệt là những câu hỏi liên quan đến thiết kế hướng đối tượng. Thiết kế hướng đối tượng vẫn có thể tương thích với nhu cầu hiệu suất cao nhất, nhưng chúng ta phải thay đổi cách chúng ta nghĩ về nó một chút. Chúng ta phải cho con báo đó một số phòng để chạy nhanh nhất có thể, và điều đó thường là không thể nếu chúng ta thiết kế những vật thể nhỏ bé mà hầu như không lưu trữ bất kỳ trạng thái nào.