Nói chung, có đáng để sử dụng các chức năng ảo để tránh phân nhánh không?


21

Dường như có các hướng dẫn tương đương thô để tương đương với chi phí của một nhánh bỏ lỡ các chức năng ảo có sự đánh đổi tương tự:

  • hướng dẫn so với bộ nhớ cache dữ liệu
  • rào cản tối ưu hóa

Nếu bạn nhìn vào một cái gì đó như:

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

Bạn có thể có một mảng hàm thành viên hoặc nếu nhiều hàm phụ thuộc vào cùng một phân loại hoặc tồn tại phân loại phức tạp hơn, hãy sử dụng các hàm ảo:

p->do()

Nhưng, nói chung, các chức năng ảo đắt như thế nào so với phân nhánh Thật khó để kiểm tra trên đủ các nền tảng để tổng quát hóa, vì vậy tôi đã tự hỏi liệu có ai có quy tắc thô sơ không (đáng yêu nếu đơn giản chỉ là 4 ifgiây là điểm dừng)

Nói chung các chức năng ảo rõ ràng hơn và tôi sẽ nghiêng về phía chúng. Nhưng, tôi có một số phần rất quan trọng nơi tôi có thể thay đổi mã từ các hàm ảo sang các nhánh. Tôi muốn có suy nghĩ về điều này trước khi tôi thực hiện điều này. (đó không phải là một thay đổi nhỏ hoặc dễ dàng thử nghiệm trên nhiều nền tảng)


12
Vâng, yêu cầu hiệu suất của bạn là gì? Bạn có những con số khó mà bạn phải đạt được, hoặc bạn đang tham gia vào việc tối ưu hóa sớm? Cả hai phương thức phân nhánh và ảo đều cực kỳ rẻ trong sơ đồ lớn của mọi thứ (ví dụ so với các thuật toán xấu, I / O hoặc phân bổ heap).
amon

4
Làm bất cứ điều gì dễ đọc / linh hoạt hơn / không có khả năng cản trở những thay đổi trong tương lai, và một khi bạn đã làm việc thì hãy lập hồ sơ và xem điều này có thực sự quan trọng không. Thông thường nó không.
Ixrec

1
Câu hỏi: "Nhưng, nói chung, các chức năng ảo đắt thế nào ..." Trả lời: Chi nhánh gián tiếp (wikipedia)
rwong

1
Hãy nhớ rằng hầu hết các câu trả lời đều dựa trên việc đếm số lượng hướng dẫn. Là một trình tối ưu hóa mã cấp thấp, tôi không tin vào số lượng hướng dẫn; bạn phải chứng minh chúng trên một kiến ​​trúc CPU cụ thể - về mặt vật lý - trong các điều kiện thử nghiệm. Các câu trả lời hợp lệ cho câu hỏi này phải là kinh nghiệm và thực nghiệm, không phải trên lý thuyết.
rwong

3
Vấn đề với câu hỏi này là nó giả định rằng điều này đủ lớn để lo lắng. Trong phần mềm thực tế, các vấn đề về hiệu suất xuất hiện rất nhiều, như những lát bánh pizza với nhiều kích cỡ. Ví dụ nhìn vào đây . Đừng cho rằng bạn biết vấn đề lớn nhất là gì - hãy để chương trình cho bạn biết. Khắc phục điều đó, và sau đó để nó cho bạn biết cái tiếp theo là gì. Làm điều này nửa tá lần, và bạn thể xuống nơi mà các cuộc gọi chức năng ảo đáng để lo lắng. Họ không bao giờ có, theo kinh nghiệm của tôi.
Mike Dunlavey

Câu trả lời:


21

Tôi muốn nhảy vào đây trong số những câu trả lời đã rất xuất sắc này và thừa nhận rằng tôi đã áp dụng cách tiếp cận xấu xí khi thực sự làm việc ngược với mô hình chống thay đổi mã đa hình thành switcheshoặc if/elsecác nhánh với mức tăng được đo. Nhưng tôi đã không làm việc bán buôn này, chỉ cho những con đường quan trọng nhất. Nó không phải là quá đen và trắng.

Từ chối trách nhiệm, tôi làm việc trong các lĩnh vực như raytracing, nơi mà tính chính xác không quá khó để đạt được (và dù sao cũng thường mờ và gần đúng) trong khi tốc độ thường là một trong những phẩm chất cạnh tranh nhất được tìm kiếm. Việc giảm thời gian kết xuất thường là một trong những yêu cầu phổ biến nhất của người dùng, với việc chúng tôi liên tục gãi đầu và tìm ra cách để đạt được nó cho các đường đo quan trọng nhất.

Tái cấu trúc đa hình của các điều kiện

Đầu tiên, đáng để hiểu tại sao đa hình có thể được ưa thích từ khía cạnh duy trì hơn là phân nhánh có điều kiện ( switchhoặc một loạt các if/elsetuyên bố). Lợi ích chính ở đây là mở rộng .

Với mã đa hình, chúng tôi có thể giới thiệu một kiểu con mới cho cơ sở mã của chúng tôi, thêm các thể hiện của nó vào một số cấu trúc dữ liệu đa hình và có tất cả các mã đa hình hiện có vẫn hoạt động tự động mà không cần sửa đổi thêm. Nếu bạn có một loạt mã nằm rải rác trong một cơ sở mã lớn tương tự như dạng "Nếu loại này là 'foo', hãy làm điều đó" , bạn có thể thấy mình có một gánh nặng khủng khiếp khi cập nhật 50 phần mã khác nhau để giới thiệu một loại điều mới, và cuối cùng vẫn còn thiếu một vài thứ.

Các lợi ích duy trì của đa hình sẽ giảm đi một cách tự nhiên ở đây nếu bạn chỉ có một vài hoặc thậm chí một phần của cơ sở mã cần thực hiện kiểm tra kiểu như vậy.

Rào cản tối ưu hóa

Tôi sẽ đề nghị không xem xét điều này từ quan điểm phân nhánh và đường ống quá nhiều, và xem xét nó nhiều hơn từ tư duy thiết kế trình biên dịch của các rào cản tối ưu hóa. Có nhiều cách để cải thiện dự đoán nhánh áp dụng cho cả hai trường hợp, như sắp xếp dữ liệu dựa trên loại phụ (nếu nó phù hợp với một chuỗi).

Điều khác biệt hơn giữa hai chiến lược này là lượng thông tin mà trình tối ưu hóa có trước. Một cuộc gọi hàm được biết đến cung cấp nhiều thông tin hơn, một cuộc gọi hàm gián tiếp gọi một hàm không xác định tại thời gian biên dịch dẫn đến một rào cản tối ưu hóa.

Khi hàm được gọi, trình biên dịch có thể xóa sạch cấu trúc và nén nó xuống smithereens, thực hiện các cuộc gọi, loại bỏ các hàm răng cưa tiềm năng, thực hiện công việc tốt hơn trong phân bổ lệnh / đăng ký, thậm chí có thể sắp xếp lại các vòng lặp và các dạng khác của nhánh. switchđược mã hóa các LUT thu nhỏ khi thích hợp (một điều GCC 5.3 gần đây đã làm tôi ngạc nhiên với một tuyên bố bằng cách sử dụng LUT dữ liệu được mã hóa cứng cho các kết quả thay vì bảng nhảy).

Một số lợi ích bị mất khi chúng tôi bắt đầu đưa các ẩn số thời gian biên dịch vào hỗn hợp, như với trường hợp gọi hàm gián tiếp, và đó là nơi phân nhánh có điều kiện rất có thể mang lại lợi thế.

Tối ưu hóa bộ nhớ

Lấy một ví dụ về một trò chơi video bao gồm xử lý một chuỗi các sinh vật lặp đi lặp lại trong một vòng lặp chặt chẽ. Trong trường hợp như vậy, chúng ta có thể có một số thùng chứa đa hình như thế này:

vector<Creature*> creatures;

Lưu ý: để đơn giản tôi tránh unique_ptrở đây.

... Đâu Creaturelà một loại cơ sở đa hình. Trong trường hợp này, một trong những khó khăn với các thùng chứa đa hình là chúng thường muốn phân bổ bộ nhớ cho từng kiểu con riêng biệt / riêng lẻ (ví dụ: sử dụng cách ném mặc định operator newcho từng sinh vật riêng lẻ).

Điều đó thường sẽ tạo ưu tiên đầu tiên cho tối ưu hóa (chúng ta cần nó) dựa trên bộ nhớ thay vì phân nhánh. Một chiến lược ở đây là sử dụng một bộ cấp phát cố định cho từng loại phụ, khuyến khích một đại diện liền kề bằng cách phân bổ trong các khối lớn và bộ nhớ gộp cho mỗi loại phụ được phân bổ. Với chiến lược như vậy, chắc chắn có thể giúp sắp xếp creaturesthùng chứa này theo loại phụ (cũng như địa chỉ), vì điều đó không chỉ có thể cải thiện dự đoán chi nhánh mà còn cải thiện địa phương tham chiếu (cho phép truy cập nhiều sinh vật cùng loại. từ một dòng bộ đệm duy nhất trước khi trục xuất).

Phá hoại một phần cấu trúc dữ liệu và vòng lặp

Giả sử bạn đã trải qua tất cả các chuyển động này và bạn vẫn mong muốn tốc độ cao hơn. Điều đáng chú ý là mỗi bước chúng ta mạo hiểm ở đây đều làm giảm khả năng bảo trì và chúng ta sẽ ở giai đoạn mài mòn kim loại với lợi nhuận hiệu suất giảm dần. Vì vậy, cần phải có một nhu cầu hiệu suất khá đáng kể nếu chúng ta bước vào lãnh thổ này, nơi chúng ta sẵn sàng hy sinh khả năng bảo trì hơn nữa để đạt được hiệu suất nhỏ hơn và nhỏ hơn.

Tuy nhiên, bước tiếp theo để thử (và luôn sẵn sàng sao lưu các thay đổi của chúng tôi nếu nó không giúp ích gì cả) có thể là ảo hóa thủ công.

Mẹo kiểm soát phiên bản: trừ khi bạn am hiểu tối ưu hóa hơn tôi rất nhiều, có thể đáng để tạo một chi nhánh mới vào thời điểm này với sự sẵn sàng để ném nó đi nếu nỗ lực tối ưu hóa của chúng tôi rất có thể xảy ra. Đối với tôi, đó là tất cả các thử nghiệm và lỗi sau các loại điểm này ngay cả với một trình hồ sơ trong tay.

Tuy nhiên, chúng ta không phải áp dụng tư duy bán buôn này. Tiếp tục ví dụ của chúng tôi, giả sử trò chơi video này bao gồm chủ yếu là các sinh vật người. Trong trường hợp như vậy, chúng ta chỉ có thể làm ảo hóa các sinh vật người bằng cách nâng chúng ra và tạo ra một cấu trúc dữ liệu riêng cho chúng.

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures

Điều này ngụ ý rằng tất cả các khu vực trong cơ sở mã của chúng ta cần xử lý sinh vật cần một vòng lặp trường hợp đặc biệt riêng cho sinh vật người. Tuy nhiên, điều đó giúp loại bỏ hàng rào công văn động (hoặc có lẽ, một cách thích hợp hơn, rào cản tối ưu hóa) cho con người, cho đến nay, là loại sinh vật phổ biến nhất. Nếu những khu vực này có số lượng lớn và chúng tôi có thể đủ khả năng, chúng tôi có thể làm điều này:

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures
vector<Creature*> creatures;        // contains humans and other creatures

... Nếu chúng ta có thể đủ khả năng này, các con đường ít quan trọng hơn có thể tồn tại như cũ và chỉ đơn giản là xử lý tất cả các loại sinh vật một cách trừu tượng. Các đường dẫn quan trọng có thể xử lý humanstrong một vòng lặp và other_creaturestrong vòng lặp thứ hai.

Chúng tôi có thể mở rộng chiến lược này khi cần thiết và có khả năng siết chặt một số lợi ích theo cách này, nhưng đáng chú ý là chúng tôi làm giảm khả năng bảo trì trong quá trình. Sử dụng các mẫu hàm ở đây có thể giúp tạo mã cho cả người và sinh vật mà không cần sao chép logic theo cách thủ công.

Phá hoại một phần các lớp học

Một cái gì đó tôi đã làm cách đây nhiều năm thực sự rất thô thiển, và tôi thậm chí không chắc nó còn có lợi nữa (đây là thời C ++ 03), là một phần sai lệch của một lớp. Trong trường hợp đó, chúng tôi đã lưu trữ một ID lớp với mỗi phiên bản cho các mục đích khác (được truy cập thông qua một trình truy cập trong lớp cơ sở không ảo). Ở đó chúng tôi đã làm một cái gì đó tương tự như thế này (trí nhớ của tôi hơi mơ hồ):

switch (obj->type())
{
   case id_common_type:
       static_cast<CommonType*>(obj)->non_virtual_do_something();
       break;
   ...
   default:
       obj->virtual_do_something();
       break;
}

... Nơi virtual_do_somethingđược triển khai để gọi các phiên bản không ảo trong một lớp con. Thật thô thiển, tôi biết, đang thực hiện một chương trình truyền hình tĩnh rõ ràng để làm sai lệch một cuộc gọi chức năng. Tôi không biết bây giờ nó có lợi như thế nào vì tôi đã không thử loại điều này trong nhiều năm. Với việc tiếp xúc với thiết kế hướng dữ liệu, tôi thấy chiến lược phân chia cấu trúc dữ liệu và vòng lặp theo kiểu nóng / lạnh sẽ hữu ích hơn nhiều, mở ra nhiều cánh cửa hơn cho các chiến lược tối ưu hóa (và ít xấu xí hơn).

Bán buôn ảo hóa

Tôi phải thừa nhận rằng tôi chưa bao giờ nhận được điều này khi áp dụng một tư duy tối ưu hóa, vì vậy tôi không biết gì về lợi ích. Tôi đã tránh các chức năng gián tiếp trong tầm nhìn xa trong trường hợp tôi biết rằng sẽ chỉ có một tập hợp các điều kiện trung tâm (ví dụ: xử lý sự kiện chỉ với một sự kiện xử lý vị trí trung tâm), nhưng không bao giờ bắt đầu với một tư duy đa hình và tối ưu hóa mọi cách đến đây.

Về mặt lý thuyết, lợi ích trước mắt ở đây có thể là một cách xác định loại nhỏ hơn tiềm năng so với con trỏ ảo (ví dụ: một byte nếu bạn có thể cam kết rằng có 256 loại duy nhất hoặc ít hơn) ngoài việc xóa bỏ hoàn toàn các rào cản tối ưu hóa này .

Trong một số trường hợp, nó cũng có thể giúp viết mã dễ bảo trì hơn (so với các ví dụ ảo hóa thủ công được tối ưu hóa ở trên) nếu bạn chỉ sử dụng một switchcâu lệnh trung tâm mà không phải phân tách cấu trúc dữ liệu và vòng lặp dựa trên kiểu con hoặc nếu có lệnh - sự phụ thuộc trong những trường hợp này khi mọi thứ phải được xử lý theo một thứ tự chính xác (ngay cả khi điều đó khiến chúng tôi phân nhánh khắp nơi). Điều này sẽ dành cho những trường hợp bạn không có quá nhiều nơi cần phải làm switch.

Tôi thường không khuyến nghị điều này ngay cả với một tư duy rất quan trọng về hiệu suất trừ khi điều này khá dễ để duy trì. "Dễ bảo trì" sẽ có xu hướng xoay quanh hai yếu tố chính:

  • Không có nhu cầu mở rộng thực sự (ví dụ: biết chắc chắn rằng bạn có chính xác 8 loại việc cần xử lý, và không bao giờ nữa).
  • Không có nhiều vị trí trong mã của bạn cần kiểm tra các loại này (ví dụ: một vị trí trung tâm).

... Tuy nhiên, tôi đề xuất kịch bản trên trong hầu hết các trường hợp và lặp lại hướng tới các giải pháp hiệu quả hơn bằng cách ảo hóa một phần khi cần thiết. Nó cung cấp cho bạn nhiều phòng thở hơn để cân bằng giữa khả năng mở rộng và nhu cầu bảo trì với hiệu suất.

Hàm ảo so với Hàm con trỏ

Để loại bỏ điều này, tôi nhận thấy ở đây có một số cuộc thảo luận về các hàm ảo so với các con trỏ hàm. Đúng là các chức năng ảo đòi hỏi một chút công việc để gọi, nhưng điều đó không có nghĩa là chúng chậm hơn. Theo trực giác, nó thậm chí có thể làm cho họ nhanh hơn.

Nó phản trực giác ở đây bởi vì chúng ta thường đo lường chi phí theo hướng dẫn mà không chú ý đến tính năng động của hệ thống phân cấp bộ nhớ có xu hướng có tác động đáng kể hơn nhiều.

Nếu chúng ta so sánh a classvới 20 hàm ảo so với structlưu trữ 20 con trỏ hàm và cả hai đều được khởi tạo nhiều lần, thì chi phí bộ nhớ của mỗi classtrường hợp trong trường hợp này là 8 byte cho con trỏ ảo trên máy 64 bit, trong khi bộ nhớ tổng phí structlà 160 byte.

Chi phí thực tế có thể có rất nhiều lỗi bộ nhớ cache bắt buộc và không bắt buộc với bảng con trỏ hàm so với lớp sử dụng các hàm ảo (và có thể lỗi trang ở quy mô đầu vào đủ lớn). Chi phí đó có xu hướng giảm bớt công việc hơi thêm của việc lập chỉ mục một bảng ảo.

Tôi cũng đã xử lý các cơ sở mã C cũ (cũ hơn tôi) khi chuyển các structscon trỏ hàm đầy đủ và khởi tạo nhiều lần, thực sự đã tăng hiệu suất đáng kể (cải thiện hơn 100%) bằng cách biến chúng thành các lớp có chức năng ảo và chỉ đơn giản là do giảm đáng kể việc sử dụng bộ nhớ, tăng tính thân thiện với bộ nhớ cache, v.v.

Mặt khác, khi so sánh trở nên nhiều hơn về táo với táo, tôi cũng đã thấy suy nghĩ ngược lại về việc dịch từ tư duy chức năng ảo C ++ sang tư duy con trỏ chức năng kiểu C sẽ hữu ích trong các loại tình huống này:

class Functionoid
{
public:
    virtual ~Functionoid() {}
    virtual void operator()() = 0;
};

... Trong đó lớp đang lưu trữ một hàm overridable đơn lẻ (hoặc hai nếu chúng ta đếm hàm hủy ảo). Trong những trường hợp đó, nó chắc chắn có thể giúp trong các đường dẫn quan trọng để biến điều đó thành thế này:

void (*func_ptr)(void* instance_data);

... lý tưởng đằng sau một giao diện loại an toàn để ẩn các phôi nguy hiểm đến / từ void*.

Trong những trường hợp chúng ta muốn sử dụng một lớp với một hàm ảo duy nhất, nó có thể nhanh chóng giúp sử dụng các con trỏ hàm thay thế. Một lý do lớn thậm chí không nhất thiết là giảm chi phí khi gọi một con trỏ hàm. Đó là bởi vì chúng ta không còn phải đối mặt với sự cám dỗ để phân bổ từng chức năng riêng biệt trên các vùng phân tán của heap nếu chúng ta tập hợp chúng thành một cấu trúc bền bỉ. Cách tiếp cận này có thể giúp dễ dàng hơn để tránh tình trạng phân mảnh bộ nhớ và phân mảnh bộ nhớ nếu dữ liệu cá thể là đồng nhất, ví dụ, và chỉ hành vi khác nhau.

Vì vậy, chắc chắn có một số trường hợp sử dụng con trỏ hàm có thể giúp ích, nhưng thường thì tôi đã tìm thấy nó theo cách khác nếu chúng ta so sánh một loạt các bảng con trỏ hàm với một vtable duy nhất chỉ yêu cầu một con trỏ được lưu trữ trên mỗi thể hiện của lớp . Vtable đó thường sẽ ngồi trong một hoặc nhiều dòng bộ đệm L1 cũng như các vòng lặp chặt chẽ.

Phần kết luận

Vì vậy, dù sao, đó là spin nhỏ của tôi về chủ đề này. Tôi khuyên bạn nên mạo hiểm trong các lĩnh vực này một cách thận trọng. Các phép đo tin cậy, không phải bản năng và được đưa ra theo cách các tối ưu hóa này thường làm giảm khả năng bảo trì, chỉ đi xa nhất có thể (và một lộ trình khôn ngoan sẽ là sai lầm về phía bảo trì).


Hàm ảo là các con trỏ hàm, chỉ được thực hiện trong khả năng của lớp đó. Khi một hàm ảo được gọi, đầu tiên nó được tra cứu ở trẻ và lên chuỗi thừa kế. Đây là lý do tại sao thừa kế sâu rất tốn kém và thường được tránh trong c ++.
Robert Baron

@RobertBaron: Tôi chưa bao giờ thấy các chức năng ảo được triển khai như bạn đã nói (= với việc tra cứu chuỗi thông qua hệ thống phân cấp lớp). Nói chung các trình biên dịch chỉ tạo ra một vtable "làm phẳng" cho từng loại cụ thể với tất cả các con trỏ hàm chính xác, và trong thời gian chạy, cuộc gọi được giải quyết với một tra cứu bảng thẳng; không có hình phạt nào được trả cho hệ thống phân cấp thừa kế sâu.
Matteo Italia

Matteo, đây là lời giải thích mà một lãnh đạo kỹ thuật đã đưa ra cho tôi nhiều năm trước. Cấp, nó là cho c ++, vì vậy anh ta có thể đã xem xét ý nghĩa của nhiều thừa kế. Cảm ơn bạn đã làm rõ sự hiểu biết của tôi về cách vtables được tối ưu hóa.
Robert Baron

Cảm ơn câu trả lời tốt (+1). Tôi tự hỏi bao nhiêu điều này áp dụng giống hệt cho std :: visit thay vì các hàm ảo.
DaveFar

13

Quan sát:

  • Với nhiều trường hợp, các chức năng ảo nhanh hơn vì tra cứu vtable là một O(1)hoạt động trong khi else if()thang là một O(n)hoạt động. Tuy nhiên, điều này chỉ đúng nếu phân phối các trường hợp bằng phẳng.

  • Đối với một đơn if() ... else, điều kiện nhanh hơn vì bạn lưu chi phí cuộc gọi chức năng.

  • Vì vậy, khi bạn có phân phối phẳng các trường hợp, điểm hòa vốn phải tồn tại. Câu hỏi duy nhất là nó nằm ở đâu.

  • Nếu bạn sử dụng switch()thay vì gọi else if()thang hoặc gọi hàm ảo, trình biên dịch của bạn có thể tạo mã thậm chí tốt hơn: nó có thể thực hiện một nhánh đến một vị trí được tra cứu từ bảng, nhưng đó không phải là lệnh gọi hàm. Đó là, bạn có tất cả các thuộc tính của lệnh gọi hàm ảo mà không cần tất cả các hàm gọi hàm.

  • Nếu một cái thường xuyên hơn nhiều so với phần còn lại, bắt đầu if() ... elsevới trường hợp đó sẽ mang lại cho bạn hiệu suất tốt nhất: Bạn sẽ thực thi một nhánh có điều kiện duy nhất được dự đoán chính xác trong hầu hết các trường hợp.

  • Trình biên dịch của bạn không có kiến ​​thức về phân phối các trường hợp dự kiến ​​và sẽ giả định phân phối phẳng.

Kể từ khi trình biên dịch của bạn có khả năng có một số chẩn đoán tốt tại chỗ như khi mã một switch()như một else if()bậc thang hoặc như một tra cứu bảng. Tôi sẽ có xu hướng tin tưởng vào phán quyết của nó trừ khi bạn biết rằng việc phân phối các trường hợp là sai lệch.

Vì vậy, lời khuyên của tôi là:

  • Nếu một trong các trường hợp lùn phần còn lại về tần số, hãy sử dụng else if()thang được sắp xếp .

  • Mặt khác, sử dụng một switch()câu lệnh, trừ khi một trong các phương thức khác làm cho mã của bạn dễ đọc hơn nhiều. Hãy chắc chắn rằng bạn không mua tăng hiệu suất tiêu cực với khả năng đọc giảm đáng kể.

  • Nếu bạn đã sử dụng một switch()và vẫn không hài lòng với hiệu suất, hãy thực hiện so sánh, nhưng hãy chuẩn bị để biết rằng đó switch()đã là khả năng nhanh nhất.


2
Một số trình biên dịch cho phép các chú thích cho trình biên dịch biết trường hợp nào có khả năng là đúng và các trình biên dịch đó có thể tạo mã nhanh hơn miễn là chú thích là chính xác.
gnasher729

5
một hoạt động O (1) không nhất thiết phải nhanh hơn trong thời gian thực hiện trong thế giới thực so với O (n) hoặc thậm chí O (n ^ 20).
whatsisname 04/11/2015

2
@whatsisname Đó là lý do tại sao tôi nói "trong nhiều trường hợp". Theo định nghĩa O(1)O(n)tồn tại một hàm ksao cho O(n)hàm lớn hơn O(1)hàm cho tất cả n >= k. Câu hỏi duy nhất là liệu bạn có khả năng có nhiều trường hợp như vậy không. Và, vâng, tôi đã thấy các switch()câu lệnh với rất nhiều trường hợp rằng một else if()bậc thang chắc chắn chậm hơn một lệnh gọi hàm ảo hoặc một công văn được tải.
cmaster

Vấn đề tôi gặp phải với câu trả lời này là cảnh báo duy nhất chống lại việc đưa ra quyết định dựa trên mức tăng hiệu suất hoàn toàn không liên quan được ẩn ở đâu đó trong đoạn kế tiếp. Mọi thứ khác ở đây đều giả vờ rằng có thể là một ý tưởng tốt để đưa ra quyết định ifso switchvới các chức năng ảo dựa trên sự hoàn hảo. Trong những trường hợp cực kỳ hiếm thì có thể như vậy, nhưng trong phần lớn các trường hợp thì không.
Doc Brown

7

Nói chung, có đáng để sử dụng các chức năng ảo để tránh phân nhánh không?

Nói chung, có. Những lợi ích cho việc bảo trì là rất đáng kể (thử nghiệm trong tách, tách mối quan tâm, cải thiện tính mô đun và khả năng mở rộng).

Nhưng, nói chung, các chức năng ảo đắt tiền như thế nào so với việc phân nhánh Thật khó để kiểm tra trên đủ các nền tảng để tổng quát hóa, vì vậy tôi đã tự hỏi liệu có ai có quy tắc thô sơ không (đáng yêu nếu nó đơn giản như 4 ifs là điểm dừng)

Trừ khi bạn đã định hình mã của mình và biết công văn giữa các nhánh ( đánh giá điều kiện ) mất nhiều thời gian hơn so với các tính toán được thực hiện ( mã trong các nhánh ), tối ưu hóa các tính toán được thực hiện.

Đó là, câu trả lời chính xác cho "mức độ đắt của các chức năng ảo so với phân nhánh" là đo lường và tìm hiểu.

Nguyên tắc chung : trừ khi có tình huống ở trên (phân biệt chi phí đắt hơn tính toán chi nhánh), hãy tối ưu hóa phần mã này cho nỗ lực bảo trì (sử dụng các hàm ảo).

Bạn nói rằng bạn muốn phần này chạy nhanh nhất có thể; Nhanh như thế nào? Yêu cầu cụ thể của bạn là gì?

Nói chung các chức năng ảo rõ ràng hơn và tôi sẽ nghiêng về phía chúng. Nhưng, tôi có một số phần rất quan trọng nơi tôi có thể thay đổi mã từ các hàm ảo sang các nhánh. Tôi muốn có suy nghĩ về điều này trước khi tôi thực hiện điều này. (đó không phải là một thay đổi nhỏ hoặc dễ dàng thử nghiệm trên nhiều nền tảng)

Sử dụng chức năng ảo rồi. Điều này thậm chí sẽ cho phép bạn tối ưu hóa trên mỗi nền tảng nếu cần thiết và vẫn giữ sạch mã máy khách.


Đã thực hiện rất nhiều chương trình bảo trì, tôi sẽ thận trọng một chút: các chức năng ảo là IMNSHO khá tệ cho việc bảo trì, chính xác là vì những lợi thế bạn liệt kê. Vấn đề cốt lõi là sự linh hoạt của họ; bạn có thể dính khá nhiều thứ vào đó ... và mọi người làm. Rất khó để lý do tĩnh về công văn động. Tuy nhiên, trong hầu hết các trường hợp cụ thể, mã không cần tất cả tính linh hoạt đó và việc loại bỏ tính linh hoạt thời gian chạy có thể giúp bạn dễ dàng suy luận về mã hơn. Tuy nhiên, tôi không muốn đi xa để nói rằng bạn không bao giờ nên sử dụng công văn động; đó là vô lý.
Eamon Nerbonne

Các trừu tượng đẹp nhất để làm việc là những trừu tượng hiếm gặp (ví dụ, một cơ sở mã hóa chỉ có một vài trừu tượng mờ đục), nhưng siêu mạnh mẽ. Về cơ bản: không dính một cái gì đó đằng sau sự trừu tượng của công văn động chỉ vì nó có hình dạng tương tự cho một trường hợp cụ thể; chỉ làm như vậy nếu bạn không thể hình dung một cách hợp lý bất kỳ lý do nào để quan tâm đến bất kỳ sự phân biệt nào giữa các đối tượng chia sẻ giao diện đó. Nếu bạn không thể: tốt hơn để có một người trợ giúp không đóng gói hơn là một sự trừu tượng bị rò rỉ. Và thậm chí sau đó; có sự đánh đổi giữa tính linh hoạt thời gian chạy và tính linh hoạt của cơ sở mã.
Eamon Nerbonne

5

Các câu trả lời khác đã cung cấp các lập luận lý thuyết tốt. Tôi muốn thêm kết quả của một thử nghiệm mà tôi đã thực hiện gần đây để ước tính liệu có nên triển khai máy ảo (VM) hay không bằng cách sử dụng switchmã lớn hoặc thay vì giải thích mã op là chỉ mục vào một mảng các con trỏ hàm. Mặc dù điều này không hoàn toàn giống như một virtuallời gọi hàm, tôi nghĩ rằng nó khá gần.

Tôi đã viết một tập lệnh Python để tạo ngẫu nhiên mã C ++ 14 cho máy ảo với kích thước tập lệnh được chọn ngẫu nhiên (mặc dù không đồng nhất, lấy mẫu ở phạm vi thấp dày đặc hơn) trong khoảng từ 1 đến 10000. Máy ảo được tạo luôn có 128 thanh ghi và không có RAM. Các hướng dẫn không có ý nghĩa và tất cả đều có hình thức sau đây.

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

Kịch bản cũng tạo ra các thói quen gửi bằng cách sử dụng một switchcâu lệnh

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

Khoan và một loạt các con trỏ chức năng.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

Mà thói quen gửi đã được tạo được chọn ngẫu nhiên cho mỗi VM được tạo.

Để đo điểm chuẩn, luồng mã op được tạo bởi std::random_devicecông cụ ngẫu nhiên Mersenne twister ( std::mt19937_64).

Mã cho mỗi VM đã được biên soạn với GCC 5.2.0 bằng cách sử dụng -DNDEBUG, -O3-std=c++14chuyển mạch. Đầu tiên, nó được biên dịch bằng cách sử dụng -fprofile-generatetùy chọn và dữ liệu hồ sơ được thu thập để mô phỏng 1000 hướng dẫn ngẫu nhiên. Mã này sau đó được biên dịch lại với -fprofile-usetùy chọn cho phép tối ưu hóa dựa trên dữ liệu hồ sơ được thu thập.

VM sau đó đã được thực hiện (trong cùng một quy trình) bốn lần cho 50 000 000 chu kỳ và thời gian cho mỗi lần chạy được đo. Lần chạy đầu tiên đã bị loại bỏ để loại bỏ các hiệu ứng bộ đệm lạnh. PRNG không được nối lại giữa các lần chạy để chúng không thực hiện cùng một chuỗi hướng dẫn.

Sử dụng thiết lập này, 1000 điểm dữ liệu cho mỗi thói quen gửi đi đã được thu thập. Dữ liệu được thu thập trên APU AMD A8-6600K lõi tứ với bộ đệm 2048 KiB chạy GNU / Linux 64 bit mà không cần máy tính để bàn đồ họa hoặc các chương trình khác đang chạy. Dưới đây là một biểu đồ về thời gian CPU trung bình (với độ lệch chuẩn) trên mỗi lệnh cho mỗi VM.

nhập mô tả hình ảnh ở đây

Từ dữ liệu này, tôi có thể tin tưởng rằng sử dụng bảng chức năng là một ý tưởng tốt ngoại trừ có thể cho một số lượng rất nhỏ mã op. Tôi không có lời giải thích cho các ngoại lệ của switchphiên bản trong khoảng từ 500 đến 1000 hướng dẫn.

Tất cả mã nguồn cho điểm chuẩn cũng như dữ liệu thử nghiệm đầy đủ và âm mưu độ phân giải cao có thể được tìm thấy trên trang web của tôi .


3

Ngoài câu trả lời hay của cmaster, mà tôi nêu lên, hãy nhớ rằng các con trỏ hàm thường nhanh hơn so với các hàm ảo. Việc gửi các hàm ảo thường bao gồm đầu tiên theo một con trỏ từ đối tượng đến vtable, lập chỉ mục một cách thích hợp và sau đó hủy bỏ một con trỏ hàm. Vì vậy, bước cuối cùng là như nhau, nhưng có những bước bổ sung ban đầu. Ngoài ra, các hàm ảo luôn lấy "cái này" làm đối số, con trỏ hàm linh hoạt hơn.

Một lưu ý khác: nếu đường dẫn quan trọng của bạn liên quan đến một vòng lặp, việc sắp xếp vòng lặp theo đích đến có thể hữu ích. Rõ ràng đây là nlogn, trong khi đi qua vòng lặp chỉ là n, nhưng nếu bạn sẽ đi qua nhiều lần thì điều này có thể đáng giá. Bằng cách sắp xếp theo đích gửi, bạn đảm bảo rằng cùng một mã được thực thi lặp đi lặp lại, giữ cho nó nóng trong icache, giảm thiểu các lỗi bộ nhớ cache.

Chiến lược thứ ba cần ghi nhớ: nếu bạn quyết định chuyển khỏi các hàm / hàm con trỏ ảo sang các chiến lược if / switch, bạn cũng có thể được phục vụ tốt bằng cách chuyển từ các đối tượng đa hình sang một cái gì đó như boost :: biến thể (cũng cung cấp công tắc trường hợp dưới dạng trừu tượng của khách truy cập). Các đối tượng đa hình phải được lưu trữ bằng con trỏ cơ sở, vì vậy dữ liệu của bạn ở khắp nơi trong bộ đệm. Điều này có thể dễ dàng có ảnh hưởng lớn hơn trên con đường quan trọng của bạn so với chi phí tra cứu ảo. Trong khi đó biến thể được lưu trữ nội tuyến như là một liên minh phân biệt đối xử; nó có kích thước bằng loại dữ liệu lớn nhất (cộng với một hằng số nhỏ). Nếu các đối tượng của bạn không khác nhau về kích thước quá nhiều, đây là một cách tuyệt vời để xử lý chúng.

Trên thực tế, tôi sẽ không ngạc nhiên nếu việc cải thiện tính liên kết bộ nhớ cache của dữ liệu của bạn sẽ có tác động lớn hơn câu hỏi ban đầu của bạn, vì vậy tôi chắc chắn sẽ xem xét thêm về điều đó.


Tôi không biết rằng một chức năng ảo bao gồm "các bước bổ sung" mặc dù. Cho rằng cách bố trí của lớp được biết tại thời gian biên dịch, về cơ bản nó giống như một truy cập mảng. Tức là có một con trỏ đến đầu lớp và phần bù của hàm được biết vì vậy chỉ cần thêm nó vào, đọc kết quả và đó là địa chỉ. Không nhiều chi phí.

1
Nó không liên quan đến các bước bổ sung. Bản thân vtable chứa các con trỏ hàm, vì vậy khi bạn thực hiện nó với vtable, bạn đã đạt đến trạng thái giống như bạn đã bắt đầu với một con trỏ hàm. Tất cả mọi thứ trước khi bạn đến vtable là công việc làm thêm. Các lớp không chứa vtables của chúng, chúng chứa các con trỏ tới vtables và theo con trỏ đó là một sự bổ sung bổ sung. Trong thực tế, đôi khi có một sự quy định thứ ba vì các lớp đa hình thường được giữ bởi con trỏ lớp cơ sở, vì vậy bạn phải hủy bỏ một con trỏ để lấy địa chỉ vtable (để hủy đăng ký nó ;-)).
Nir Friedman

Mặt khác, thực tế là vtable được lưu trữ bên ngoài thể hiện thực sự có thể hữu ích cho địa phương tạm thời so với, ví dụ, một loạt các cấu trúc khác nhau của các con trỏ hàm trong đó mỗi con trỏ hàm được lưu trữ trong một địa chỉ bộ nhớ khác nhau. Trong những trường hợp như vậy, một vtable với một triệu vptrs có thể dễ dàng đánh bại một triệu bảng con trỏ hàm (bắt đầu chỉ bằng mức tiêu thụ bộ nhớ). Nó có thể là một phần của một tung lên ở đây - không dễ dàng để phá vỡ. Nói chung, tôi đồng ý rằng con trỏ hàm thường rẻ hơn một chút nhưng không dễ để đặt cái này lên trên cái kia.

Tôi nghĩ, đặt một cách khác, khi các hàm ảo bắt đầu nhanh chóng và vượt trội so với các con trỏ hàm là khi bạn có một khối lượng các đối tượng liên quan (trong đó mỗi đối tượng sẽ cần lưu trữ nhiều con trỏ hàm hoặc một vptr). Các con trỏ hàm có xu hướng rẻ hơn nếu bạn có, chỉ một con trỏ hàm được lưu trong bộ nhớ sẽ được gọi là một thuyền nhiều lần. Mặt khác, các con trỏ hàm có thể bắt đầu chậm hơn với số lượng dự phòng dữ liệu và bộ nhớ cache bị mất do kết quả của nhiều bộ nhớ bị treo thừa và trỏ đến cùng một địa chỉ.

Tất nhiên, với các con trỏ hàm, bạn vẫn có thể lưu trữ chúng ở một vị trí trung tâm ngay cả khi chúng được chia sẻ bởi một triệu đối tượng riêng biệt để tránh chiếm bộ nhớ và khiến một khối lượng bộ nhớ cache bị mất. Nhưng sau đó, chúng bắt đầu trở nên tương đương với các con trỏ, liên quan đến việc truy cập con trỏ đến một vị trí được chia sẻ trong bộ nhớ để đến các địa chỉ chức năng thực tế mà chúng ta muốn gọi. Câu hỏi cơ bản ở đây là: bạn có lưu trữ địa chỉ chức năng gần hơn với dữ liệu bạn hiện đang truy cập hoặc ở một vị trí trung tâm không? vtables chỉ cho phép cái sau. Con trỏ chức năng cho phép cả hai cách.

2

Tôi có thể giải thích lý do tại sao tôi nghĩ rằng đây là một vấn đề XY ? (Bạn không đơn độc khi hỏi họ.)

Tôi giả sử mục tiêu thực sự là tiết kiệm thời gian tổng thể, không chỉ để hiểu một điểm về lỗi nhớ cache và các chức năng ảo.

Đây là một ví dụ về điều chỉnh hiệu suất thực , trong phần mềm thực.

Trong phần mềm thực tế, mọi thứ được thực hiện điều đó, cho dù lập trình viên có kinh nghiệm đến đâu, có thể được thực hiện tốt hơn. Người ta không biết chúng là gì cho đến khi chương trình được viết và điều chỉnh hiệu suất có thể được thực hiện. Gần như luôn luôn có nhiều hơn một cách để tăng tốc chương trình. Rốt cuộc, để nói một chương trình là tối ưu, bạn đang nói rằng trong các chương trình có thể giải quyết vấn đề của bạn, không ai trong số họ mất ít thời gian hơn. Có thật không?

Trong ví dụ tôi liên kết đến, ban đầu mất 2700 micro giây cho mỗi "công việc". Một loạt sáu vấn đề đã được khắc phục, đi ngược chiều kim đồng hồ quanh chiếc bánh pizza. Lần tăng tốc đầu tiên đã loại bỏ 33% thời gian. Cái thứ hai loại bỏ 11%. Nhưng hãy chú ý, cái thứ hai không phải là 11% tại thời điểm nó được tìm thấy, nó là 16%, vì vấn đề đầu tiên đã biến mất . Tương tự, vấn đề thứ ba được phóng to từ 7,4% lên 13% (gần gấp đôi) vì hai vấn đề đầu tiên đã biến mất.

Cuối cùng, quá trình phóng đại này cho phép loại bỏ tất cả trừ 3,7 micro giây. Đó là 0,14% thời gian ban đầu, hoặc tăng tốc là 730 lần.

nhập mô tả hình ảnh ở đây

Loại bỏ các vấn đề lớn ban đầu cho tốc độ tăng tốc vừa phải, nhưng chúng mở đường cho việc loại bỏ các vấn đề sau này. Những vấn đề sau này ban đầu có thể là những phần không đáng kể trong tổng số, nhưng sau khi những vấn đề ban đầu được loại bỏ, những vấn đề nhỏ này trở nên lớn và có thể tạo ra sự tăng tốc lớn. (Điều quan trọng là phải hiểu rằng, để có được kết quả này, không ai có thể bỏ qua và bài đăng này cho thấy họ có thể dễ dàng như thế nào.)

nhập mô tả hình ảnh ở đây

Chương trình cuối cùng có tối ưu không? Chắc là không. Không có bất kỳ sự tăng tốc nào có liên quan đến việc bỏ lỡ bộ nhớ cache. Bộ nhớ cache sẽ bỏ lỡ vấn đề bây giờ? Có lẽ.

EDIT: Tôi đang nhận được sự đánh giá thấp từ những người tham gia vào "các phần rất quan trọng" trong câu hỏi của OP. Bạn không biết một cái gì đó là "rất quan trọng" cho đến khi bạn biết nó chiếm bao nhiêu thời gian. Nếu chi phí trung bình của các phương thức được gọi là 10 chu kỳ trở lên, theo thời gian, phương thức gửi đến chúng có lẽ không "quan trọng", so với những gì chúng thực sự đang làm. Tôi thấy điều này lặp đi lặp lại, nơi mọi người coi "cần mỗi nano giây" là một lý do để trở nên khôn ngoan và ngốc nghếch.


anh ấy đã nói rằng anh ấy có một số "phần rất quan trọng" đòi hỏi mỗi nano giây cuối cùng của hiệu suất. Vì vậy, đây không phải là một câu trả lời cho câu hỏi mà anh ấy đã hỏi (ngay cả khi đó sẽ là một câu trả lời tuyệt vời cho câu hỏi của người khác)
gbjbaanb

2
@gbjbaanb: Nếu mỗi nano giây cuối cùng được tính, tại sao câu hỏi bắt đầu bằng "nói chung"? Điều đó thật vớ vẩn. Khi đếm nano giây, bạn không thể tìm câu trả lời chung chung, bạn nhìn vào trình biên dịch làm gì, bạn xem phần cứng làm gì, bạn thử các biến thể và bạn đo lường mọi biến thể.
gnasher729

@ gnasher729 Tôi không biết, nhưng tại sao nó lại kết thúc với "các phần rất quan trọng"? Tôi đoán, giống như slashdot, người ta phải luôn đọc nội dung, và không chỉ tiêu đề!
gbjbaanb

2
@gbjbaanb: Mọi người đều nói rằng họ đã có "những phần rất quan trọng". Làm sao họ biết? Tôi không biết điều gì là quan trọng cho đến khi tôi lấy, giả sử, 10 mẫu và xem nó trên 2 hoặc nhiều hơn. Trong trường hợp như thế này, nếu các phương thức được gọi thực hiện hơn 10 hướng dẫn, thì chi phí chức năng ảo có thể không đáng kể.
Mike Dunlavey

@ gnasher729: Vâng, điều đầu tiên tôi làm là lấy các mẫu ngăn xếp, và trên mỗi cái, kiểm tra xem chương trình đang làm gì và tại sao. Sau đó, nếu nó dành toàn bộ thời gian của mình trong lá của cây gọi và tất cả các cuộc gọi thực sự không thể tránh khỏi , thì trình biên dịch và phần cứng có làm gì không. Bạn chỉ biết vấn đề gửi phương thức nếu mẫu đất trong quá trình thực hiện phương thức gửi.
Mike Dunlavey
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.