Trong C ++ tại sao và làm thế nào các chức năng ảo chậm hơn?


38

Bất cứ ai cũng có thể giải thích chi tiết, cách chính xác bảng ảo hoạt động và con trỏ được liên kết khi các hàm ảo được gọi.

Nếu chúng thực sự chậm hơn, bạn có thể hiển thị thời gian mà hàm ảo cần để thực thi là nhiều hơn các phương thức lớp bình thường không? Thật dễ dàng để mất theo dõi làm thế nào / những gì đang xảy ra mà không thấy một số mã.


5
Tra cứu phương thức gọi chính xác từ vtable rõ ràng sẽ mất nhiều thời gian hơn gọi trực tiếp phương thức, vì có nhiều việc phải làm. Còn bao lâu nữa, hoặc liệu thời gian bổ sung đó có ý nghĩa trong bối cảnh chương trình của riêng bạn hay không, là một câu hỏi khác. vi.wikipedia.org/wiki/Virtual_method_table
Robert Harvey

10
Chậm hơn chính xác những gì? Tôi đã thấy mã bị hỏng, triển khai chậm hành vi động với nhiều câu lệnh chuyển đổi chỉ vì một số lập trình viên đã nghe nói rằng các hàm ảo bị chậm.
Christopher Creutzig

7
Thông thường, không phải bản thân các cuộc gọi ảo là chậm, mà trình biên dịch không có khả năng nội tuyến chúng.
Kevin Hsu

4
@Kevin Hsu: vâng, đây là nó hoàn toàn. Hầu như bất cứ lúc nào ai đó nói với bạn rằng họ đã tăng tốc từ việc loại bỏ một số "phí gọi chức năng ảo", nếu bạn nhìn vào nó, nơi tất cả các tốc độ thực sự đến từ sẽ được tối ưu hóa bởi vì trình biên dịch không thể tối ưu hóa qua cuộc gọi không xác định trước đó.
timday

7
Ngay cả một người có thể đọc mã lắp ráp cũng không thể dự đoán chính xác chi phí hoạt động của nó khi thực thi CPU. Các nhà sản xuất CPU dựa trên máy tính để bàn đã đầu tư vào nhiều thập kỷ nghiên cứu không chỉ dự đoán chi nhánh, mà còn coi trọng dự đoán và thực hiện đầu cơ vì lý do chính là che giấu độ trễ của các chức năng ảo. Tại sao? Bởi vì hệ điều hành và phần mềm máy tính để bàn sử dụng chúng rất nhiều. (Tôi sẽ không nói như vậy về CPU di động.)
rwong

Câu trả lời:


55

Các phương thức ảo thường được thực hiện thông qua cái gọi là bảng phương thức ảo (vtable viết tắt), trong đó các con trỏ hàm được lưu trữ. Điều này thêm sự gián tiếp vào cuộc gọi thực tế (phải lấy địa chỉ của hàm để gọi từ vtable, sau đó gọi nó - trái ngược với việc chỉ gọi nó ngay trước mặt). Tất nhiên, điều này cần một chút thời gian và một số mã hơn.

Tuy nhiên, nó không nhất thiết là nguyên nhân chính của sự chậm chạp. Vấn đề thực sự là trình biên dịch (thường / thường) không thể biết hàm nào sẽ được gọi. Vì vậy, nó không thể nội tuyến nó hoặc thực hiện bất kỳ tối ưu hóa khác như vậy. Điều này một mình có thể thêm một tá hướng dẫn vô nghĩa (chuẩn bị các thanh ghi, gọi điện, sau đó khôi phục trạng thái sau đó) và có thể ức chế các tối ưu hóa khác, dường như không liên quan. Hơn nữa, nếu bạn phân nhánh như điên bằng cách gọi nhiều triển khai khác nhau, bạn phải chịu những lần truy cập giống như bạn bị phân nhánh như điên thông qua các phương tiện khác: Bộ dự đoán bộ đệm và nhánh sẽ không giúp bạn, các nhánh sẽ mất nhiều thời gian hơn dự đoán hoàn hảo chi nhánh.

Lớn nhưng : Những hit hiệu suất thường quá nhỏ để quan trọng. Chúng đáng để xem xét nếu bạn muốn tạo mã hiệu suất cao và xem xét thêm một chức năng ảo sẽ được gọi với tần suất đáng báo động. Tuy nhiên, cũng nên nhớ rằng việc thay thế các cuộc gọi chức năng ảo bằng các phương tiện phân nhánh khác ( if .. else,, switchcon trỏ hàm, v.v.) sẽ không giải quyết được vấn đề cơ bản - rất có thể sẽ chậm hơn. Vấn đề (nếu nó tồn tại ở tất cả) không phải là các chức năng ảo mà là (không cần thiết).

Chỉnh sửa: Sự khác biệt trong hướng dẫn cuộc gọi được mô tả trong các câu trả lời khác. Về cơ bản, mã cho một cuộc gọi tĩnh ("bình thường") là:

  • Sao chép một số thanh ghi trên ngăn xếp, để cho phép hàm được gọi sử dụng các thanh ghi đó.
  • Sao chép các đối số vào các vị trí được xác định trước, để hàm được gọi có thể tìm thấy chúng bất kể nó được gọi từ đâu.
  • Đẩy địa chỉ trả lại.
  • Nhánh / nhảy tới mã của hàm, là một địa chỉ thời gian biên dịch và do đó được mã hóa trong nhị phân bởi trình biên dịch / liên kết.
  • Nhận giá trị trả về từ một vị trí được xác định trước và khôi phục các thanh ghi mà chúng tôi muốn sử dụng.

Một cuộc gọi ảo thực hiện chính xác điều tương tự, ngoại trừ địa chỉ hàm không được biết tại thời điểm biên dịch. Thay vào đó, một vài hướng dẫn ...

  • Lấy con trỏ vtable, trỏ đến một mảng các con trỏ hàm (địa chỉ hàm), một cho mỗi hàm ảo, từ đối tượng.
  • Lấy đúng địa chỉ hàm từ vtable vào một thanh ghi (chỉ mục nơi địa chỉ hàm chính xác được lưu trữ được quyết định tại thời gian biên dịch).
  • Nhảy tới địa chỉ trong thanh ghi đó, thay vì nhảy đến một địa chỉ được mã hóa cứng.

Đối với các nhánh: Một nhánh là bất cứ thứ gì nhảy sang một lệnh khác thay vì chỉ để cho lệnh tiếp theo thực thi. Điều này bao gồm if, switchcác phần của các vòng lặp khác nhau, các lệnh gọi hàm, v.v. và đôi khi trình biên dịch thực hiện những thứ dường như không phân nhánh theo cách thực sự cần một nhánh dưới mui xe. Xem tại sao xử lý một mảng được sắp xếp nhanh hơn một mảng chưa sắp xếp? tại sao điều này có thể chậm, CPU làm gì để chống lại sự chậm lại này và làm thế nào đây không phải là cách chữa trị.


6
@ JörgWMittag tất cả chúng đều là công cụ phiên dịch và chúng vẫn chậm hơn mã nhị phân được tạo bởi trình biên dịch C ++
Sam

13
@ JörgWMittag Những tối ưu hóa này chủ yếu tồn tại để làm cho ràng buộc / ràng buộc muộn (gần như) miễn phí khi không cần thiết , bởi vì trong các ngôn ngữ đó, mọi cuộc gọi đều bị giới hạn về mặt kỹ thuật. Nếu bạn thực sự gọi rất nhiều phương thức ảo khác nhau từ một nơi trong một thời gian ngắn, những tối ưu hóa này sẽ không giúp đỡ hoặc chủ động làm tổn thương (tạo ra nhiều mã cho vô ích). Các anh chàng C ++ không quan tâm đến những tối ưu hóa đó vì họ ở trong một tình huống rất khác ...

10
@ JörgWMittag ... Các anh chàng C ++ không quan tâm đến những tối ưu hóa đó bởi vì họ ở trong một tình huống rất khác: Cách vtable do AOT biên soạn đã khá nhanh, rất ít cuộc gọi thực sự là ảo, nhiều trường hợp đa hình hóa là sớm- bị ràng buộc (thông qua các mẫu) và do đó có thể sửa đổi để tối ưu hóa AOT. Cuối cùng, làm những việc tối ưu thích nghi (thay vì chỉ đầu cơ tại thời gian biên dịch) đòi hỏi hệ mã thời gian chạy, trong đó giới thiệu tấn của đau đầu. Trình biên dịch JIT đã giải quyết những vấn đề đó vì những lý do khác, vì vậy họ không bận tâm, nhưng trình biên dịch AOT muốn tránh nó.

3
câu trả lời tuyệt vời, +1. Một điều cần lưu ý là đôi khi kết quả phân nhánh được biết đến vào thời gian biên dịch, ví dụ khi bạn viết các lớp khung cần hỗ trợ các cách sử dụng khác nhau nhưng một khi mã ứng dụng tương tác với các lớp đó thì việc sử dụng cụ thể đã được biết. Trong trường hợp này, sự thay thế cho các chức năng ảo, có thể là các mẫu C ++. Ví dụ điển hình là CRTP, mô phỏng hành vi chức năng ảo mà không có bất kỳ vtables nào: en.wikipedia.org/wiki/Cantlyly_recurring_template_potype
DXM 23/03/13

3
@James Bạn có một điểm. Điều tôi đã cố gắng nói là: Bất kỳ sự gián tiếp nào cũng có cùng một vấn đề, nó không có gì cụ thể virtual.

23

Đây là một số mã được phân tách thực tế từ một cuộc gọi chức năng ảo và một cuộc gọi không ảo, tương ứng:

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

Bạn có thể thấy rằng cuộc gọi ảo yêu cầu ba hướng dẫn bổ sung để tra cứu địa chỉ chính xác, trong khi địa chỉ của cuộc gọi không ảo có thể được biên dịch.

Tuy nhiên, lưu ý rằng hầu hết thời gian mà thời gian tra cứu thêm có thể được coi là không đáng kể. Trong các tình huống trong đó thời gian tra cứu sẽ có ý nghĩa, như trong một vòng lặp, giá trị thường có thể được lưu trữ bằng cách thực hiện ba hướng dẫn đầu tiên trước vòng lặp.

Một tình huống khác trong đó thời gian tra cứu trở nên quan trọng là nếu bạn có một bộ sưu tập các đối tượng và bạn đang lặp qua việc gọi một hàm ảo trên mỗi đối tượng. Tuy nhiên, trong trường hợp đó, bạn sẽ cần một số phương tiện để chọn chức năng nào để gọi và dù sao, việc tra cứu bảng ảo cũng là phương tiện tốt nhất. Trong thực tế, do mã tra cứu vtable được sử dụng rộng rãi nên nó được tối ưu hóa mạnh mẽ, do đó, cố gắng làm việc xung quanh nó một cách thủ công có khả năng dẫn đến hiệu suất kém hơn .


1
Điều cần hiểu là việc tra cứu vtable và cuộc gọi gián tiếp trong hầu hết các trường hợp sẽ có tác động không đáng kể đến tổng thời gian chạy của phương thức được gọi.
John R. Strohm 23/03/13

11
@ JohnR.Strohm Một người đàn ông không đáng kể là nút cổ chai của một người đàn ông khác
James

1
-0x8(%rbp). oh ... cú pháp AT & T đó.
Abyx

" ba hướng dẫn bổ sung " không, chỉ có hai: tải vptr và tải con trỏ hàm
tò mò

@cquilguy thực tế là ba hướng dẫn bổ sung. Bạn đã quên rằng một phương thức ảo luôn được gọi trên một con trỏ , vì vậy trước tiên bạn phải tải con trỏ vào một thanh ghi. Tóm lại, bước đầu tiên là tải địa chỉ mà biến con trỏ giữ vào thanh ghi% rax, sau đó theo địa chỉ trong thanh ghi, tải vtpr trên địa chỉ này để đăng ký% rax, sau đó theo địa chỉ này trong đăng ký, tải địa chỉ của phương thức được gọi vào% rax, sau đó gọiq *% rax!.
Gab 是

18

Chậm hơn cái gì ?

Các hàm ảo giải quyết một vấn đề không thể giải quyết bằng các lệnh gọi hàm trực tiếp. Nói chung, bạn chỉ có thể so sánh hai chương trình tính toán cùng một thứ. "Trình theo dõi tia này nhanh hơn trình biên dịch" không có ý nghĩa gì và nguyên tắc này khái quát hóa ngay cả đối với những thứ nhỏ như các hàm riêng lẻ hoặc cấu trúc ngôn ngữ lập trình.

Nếu bạn không sử dụng một hàm ảo để tự động chuyển sang một đoạn mã dựa trên mốc thời gian, chẳng hạn như kiểu của một đối tượng, thì bạn sẽ phải sử dụng một cái gì đó khác, như một switchcâu lệnh để thực hiện điều tương tự. Rằng một cái gì đó khác có chi phí riêng của nó, cộng với ý nghĩa đối với việc tổ chức chương trình có ảnh hưởng đến khả năng duy trì và hiệu suất toàn cầu của nó.

Lưu ý rằng trong C ++, các lệnh gọi đến các hàm ảo không phải lúc nào cũng động. Khi các cuộc gọi được thực hiện trên một đối tượng có loại chính xác được biết (vì đối tượng không phải là con trỏ hoặc tham chiếu hoặc do loại của nó có thể được suy ra tĩnh) thì các cuộc gọi chỉ là các cuộc gọi hàm thành viên thông thường. Điều đó không chỉ có nghĩa là không gửi đi mà còn các cuộc gọi này có thể được thực hiện theo cách tương tự như các cuộc gọi thông thường.

Nói cách khác, trình biên dịch C ++ của bạn có thể hoạt động khi các hàm ảo không yêu cầu gửi ảo, vì vậy thường không có lý do gì để lo lắng về hiệu suất của chúng so với các hàm không ảo.

Mới: Ngoài ra, chúng tôi không được quên các thư viện chia sẻ. Nếu bạn đang sử dụng một lớp trong thư viện dùng chung, cuộc gọi đến một hàm thành viên thông thường sẽ không chỉ đơn giản là một chuỗi lệnh tốt như thế nào callq 0x4007aa. Nó phải trải qua một vài vòng, như gián tiếp thông qua "bảng liên kết chương trình" hoặc một số cấu trúc như vậy. Do đó, việc chia sẻ thư viện chia sẻ có thể phần nào (nếu không hoàn toàn) làm chênh lệch chi phí giữa cuộc gọi ảo (thực sự gián tiếp) và cuộc gọi trực tiếp. Vì vậy, lý do về sự đánh đổi chức năng ảo phải tính đến cách chương trình được xây dựng: liệu lớp của đối tượng đích có được liên kết nguyên khối với chương trình thực hiện cuộc gọi hay không.


4
"Chậm hơn cái gì?" - nếu bạn tạo một phương thức ảo mà không cần phải có, bạn có tài liệu so sánh khá tốt.
tdammers

2
Cảm ơn bạn đã chỉ ra rằng các cuộc gọi đến các chức năng ảo không phải lúc nào cũng năng động. Mọi phản hồi khác ở đây làm cho nó trông giống như khai báo một hàm ảo có nghĩa là một cú đánh hiệu suất tự động, bất kể hoàn cảnh nào.
Syndog

12

bởi vì một cuộc gọi ảo tương đương với

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

trong đó với một hàm không ảo, trình biên dịch có thể gập dòng đầu tiên, đây là một bổ sung và một cuộc gọi động được chuyển thành một cuộc gọi tĩnh

điều này cũng cho phép nó nội tuyến hàm (với tất cả các hậu quả tối ưu hóa do)

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.