std :: chức năng vs mẫu


161

Nhờ C ++ 11, chúng tôi đã nhận được std::functiongia đình của trình bao bọc functor. Thật không may, tôi chỉ nghe thấy những điều xấu về những bổ sung mới này. Phổ biến nhất là chúng chậm kinh khủng. Tôi đã thử nghiệm nó và họ thực sự hút so với các mẫu.

#include <iostream>
#include <functional>
#include <string>
#include <chrono>

template <typename F>
float calc1(F f) { return -1.0f * f(3.3f) + 666.0f; }

float calc2(std::function<float(float)> f) { return -1.0f * f(3.3f) + 666.0f; }

int main() {
    using namespace std::chrono;

    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        calc1([](float arg){ return arg * 0.5f; });
    }
    const auto tp2 = high_resolution_clock::now();

    const auto d = duration_cast<milliseconds>(tp2 - tp1);  
    std::cout << d.count() << std::endl;
    return 0;
}

111 ms so với 1241 ms. Tôi giả sử điều này là do các mẫu có thể được nội tuyến độc đáo, trong khi functions bao gồm các phần bên trong thông qua các cuộc gọi ảo.

Rõ ràng các mẫu có vấn đề của họ khi tôi thấy chúng:

  • chúng phải được cung cấp dưới dạng các tiêu đề không phải là điều bạn có thể không muốn làm khi phát hành thư viện của mình dưới dạng mã đóng,
  • họ có thể làm cho thời gian biên dịch lâu hơn nhiều trừ khi extern templatechính sách tương tự được đưa ra,
  • không có (ít nhất là đối với tôi) cách thể hiện rõ ràng các yêu cầu (khái niệm, bất cứ ai?) của một mẫu, đưa ra một nhận xét mô tả loại functor nào được mong đợi.

Do đó, tôi có thể giả sử rằng functions có thể được sử dụng như là tiêu chuẩn thực tế của việc truyền functor không, và ở những nơi nên sử dụng các mẫu hiệu suất cao được mong đợi?


Biên tập:

Trình biên dịch của tôi là Visual Studio 2012 không có CTP.


16
Sử dụng std::functionnếu và chỉ khi bạn thực sự cần một bộ sưu tập các đối tượng có thể gọi không đồng nhất (nghĩa là không có thêm thông tin phân biệt đối xử nào trong thời gian chạy).
Kerrek SB

30
Bạn đang so sánh những điều sai trái. Mẫu được sử dụng trong cả hai trường hợp - đó không phải là " std::functionhoặc mẫu". Tôi nghĩ ở đây vấn đề chỉ đơn giản là gói một lambda trong std::functionvs không gói lambda vào std::function. Tại thời điểm câu hỏi của bạn giống như hỏi "tôi nên thích một quả táo, hay một cái bát?"
Các cuộc đua nhẹ nhàng trong quỹ đạo

7
Cho dù 1ns hay 10ns, cả hai đều không là gì.
ipc

23
@ipc: 1000% không phải là không có gì. Như OP xác định, bạn bắt đầu quan tâm khi khả năng mở rộng đi vào nó cho bất kỳ mục đích thực tế nào.
Các cuộc đua nhẹ nhàng trong quỹ đạo

18
@ipc Nó chậm hơn 10 lần, rất lớn. Tốc độ cần phải được so sánh với đường cơ sở; thật giả dối khi nghĩ rằng nó không quan trọng chỉ vì nó là nano giây.
Paul Manta

Câu trả lời:


170

Nói chung, nếu bạn đang phải đối mặt với một tình huống thiết kế cho bạn lựa chọn, hãy sử dụng các mẫu . Tôi nhấn mạnh thiết kế từ vì tôi nghĩ những gì bạn cần tập trung vào là sự khác biệt giữa các trường hợp sử dụng std::functionvà các mẫu, khá khác nhau.

Nói chung, việc lựa chọn các mẫu chỉ là một ví dụ của một nguyên tắc rộng hơn: cố gắng chỉ định càng nhiều ràng buộc càng tốt tại thời gian biên dịch . Lý do rất đơn giản: nếu bạn có thể gặp lỗi hoặc loại không khớp, ngay cả trước khi chương trình của bạn được tạo, bạn sẽ không gửi chương trình lỗi cho khách hàng của mình.

Hơn nữa, như bạn đã chỉ ra một cách chính xác, các lệnh gọi đến các hàm mẫu được giải quyết tĩnh (tức là tại thời gian biên dịch), do đó trình biên dịch có tất cả các thông tin cần thiết để tối ưu hóa và có thể nội tuyến mã (điều này sẽ không thể thực hiện được nếu cuộc gọi được thực hiện thông qua một vtable).

Đúng, đúng là hỗ trợ mẫu không hoàn hảo và C ++ 11 vẫn thiếu hỗ trợ cho các khái niệm; tuy nhiên, tôi không thấy làm thế nào std::functionsẽ cứu bạn trong khía cạnh đó. std::functionkhông phải là một thay thế cho các mẫu, mà là một công cụ cho các tình huống thiết kế nơi các mẫu không thể được sử dụng.

Một trường hợp sử dụng như vậy phát sinh khi bạn cần giải quyết cuộc gọi trong thời gian chạy bằng cách gọi một đối tượng có thể gọi tuân thủ một chữ ký cụ thể, nhưng không xác định được loại cụ thể tại thời điểm biên dịch. Đây thường là trường hợp khi bạn có một tập hợp các cuộc gọi lại có khả năng khác nhau , nhưng bạn cần phải gọi một cách thống nhất ; loại và số lượng các cuộc gọi lại đã đăng ký được xác định tại thời gian chạy dựa trên trạng thái chương trình của bạn và logic ứng dụng. Một số trong số các cuộc gọi lại có thể là functor, một số có thể là các hàm đơn giản, một số có thể là kết quả của việc ràng buộc các hàm khác với các đối số nhất định.

std::functionstd::bindcũng cung cấp một thành ngữ tự nhiên để cho phép lập trình chức năng trong C ++, trong đó các hàm được coi là đối tượng và được uốn cong tự nhiên và kết hợp để tạo ra các chức năng khác. Mặc dù kiểu kết hợp này cũng có thể đạt được với các mẫu, một tình huống thiết kế tương tự thường đi kèm với các trường hợp sử dụng yêu cầu xác định loại đối tượng có thể gọi được kết hợp trong thời gian chạy.

Cuối cùng, có những tình huống khác std::functionkhông thể tránh khỏi, ví dụ nếu bạn muốn viết lambdas đệ quy ; tuy nhiên, những hạn chế này được quyết định nhiều hơn bởi những hạn chế về công nghệ hơn là những khác biệt về khái niệm mà tôi tin.

Tóm lại, tập trung vào thiết kế và cố gắng hiểu các trường hợp sử dụng khái niệm cho hai cấu trúc này là gì. Nếu bạn đặt chúng vào so sánh theo cách bạn đã làm, bạn đang buộc chúng vào một đấu trường mà chúng có thể không thuộc về.


23
Tôi nghĩ rằng "Đây thường là trường hợp khi bạn có một tập hợp các cuộc gọi lại có khả năng khác nhau, nhưng bạn cần phải gọi một cách thống nhất;" là bit quan trọng. Nguyên tắc nhỏ của tôi là: "Thích std::functionđầu cuối lưu trữ và mẫu Funtrên giao diện".
R. Martinho Fernandes

2
Lưu ý: kỹ thuật ẩn các loại bê tông được gọi là xóa kiểu (không bị nhầm lẫn với xóa kiểu trong các ngôn ngữ được quản lý). Nó thường được triển khai dưới dạng đa hình động, nhưng mạnh hơn (ví dụ unique_ptr<void>gọi các hàm hủy thích hợp ngay cả đối với các loại không có hàm hủy ảo).
ecatmur

2
@ecatmur: Tôi đồng ý về chất này, mặc dù chúng tôi hơi không đồng ý về thuật ngữ. Đa hình động có nghĩa với tôi "giả sử các dạng khác nhau trong thời gian chạy", trái ngược với đa hình tĩnh mà tôi hiểu là "giả sử các dạng khác nhau trong thời gian biên dịch"; cái sau không thể đạt được thông qua các mẫu. Đối với tôi, kiểu xóa là, khôn ngoan về thiết kế, là một điều kiện tiên quyết để có thể đạt được tính đa hình động: bạn cần một số giao diện thống nhất để tương tác với các đối tượng thuộc các loại khác nhau và xóa kiểu là một cách để loại bỏ loại- thông tin cụ thể.
Andy Prowl

2
@ecatmur: Vì vậy, theo một cách nào đó, đa hình động là mô hình khái niệm, trong khi xóa kiểu là một kỹ thuật cho phép hiện thực hóa nó.
Andy Prowl

2
@Downvoter: Tôi sẽ tò mò muốn nghe những gì bạn thấy sai trong câu trả lời này.
Andy Prowl 16/03/13

89

Andy Prowl có các vấn đề thiết kế độc đáo. Điều này, tất nhiên, rất quan trọng, nhưng tôi tin rằng câu hỏi ban đầu liên quan đến nhiều vấn đề hiệu suất liên quan đến std::function.

Trước hết, một nhận xét nhanh về kỹ thuật đo lường: 11ms thu được calc1không có ý nghĩa gì cả. Thật vậy, nhìn vào cụm được tạo (hoặc gỡ lỗi mã lắp ráp), người ta có thể thấy rằng trình tối ưu hóa của VS2012 đủ thông minh để nhận ra rằng kết quả của cuộc gọi calc1là độc lập với phép lặp và chuyển cuộc gọi ra khỏi vòng lặp:

for (int i = 0; i < 1e8; ++i) {
}
calc1([](float arg){ return arg * 0.5f; });

Hơn nữa, nó nhận ra rằng cuộc gọi calc1không có hiệu lực rõ ràng và bỏ cuộc gọi hoàn toàn. Do đó, 111ms là thời gian mà vòng lặp trống cần để chạy. (Tôi ngạc nhiên rằng trình tối ưu hóa đã giữ vòng lặp.) Vì vậy, hãy cẩn thận với các phép đo thời gian trong các vòng lặp. Điều này không đơn giản như nó có vẻ.

Như đã chỉ ra, trình tối ưu hóa có nhiều rắc rối hơn để hiểu std::functionvà không di chuyển cuộc gọi ra khỏi vòng lặp. Vì vậy, 1241ms là một phép đo công bằng cho calc2.

Lưu ý rằng, std::functioncó thể lưu trữ các loại đối tượng có thể gọi khác nhau. Do đó, nó phải thực hiện một số phép thuật tẩy xóa cho việc lưu trữ. Nói chung, điều này ngụ ý phân bổ bộ nhớ động (theo mặc định thông qua một cuộc gọi đến new). Nó được biết rằng đây là một hoạt động khá tốn kém.

Tiêu chuẩn (20.8.11.2.1 / 5) bao gồm các triển khai bộ nhớ động để tránh việc cấp phát bộ nhớ động cho các đối tượng nhỏ, rất may, VS2012 thực hiện (đặc biệt là cho mã gốc).

Để có được ý tưởng về việc nó có thể chậm hơn bao nhiêu khi phân bổ bộ nhớ, tôi đã thay đổi biểu thức lambda để chụp ba floatgiây. Điều này làm cho đối tượng có thể gọi được quá lớn để áp dụng tối ưu hóa đối tượng nhỏ:

float a, b, c; // never mind the values
// ...
calc2([a,b,c](float arg){ return arg * 0.5f; });

Đối với phiên bản này, thời gian là khoảng 16000ms (so với 1241ms cho mã gốc).

Cuối cùng, lưu ý rằng thời gian tồn tại của lambda bao gồm thời gian của std::function. Trong trường hợp này, thay vì lưu trữ một bản sao của lambda, std::functioncó thể lưu trữ một "tài liệu tham khảo" cho nó. Theo "tham chiếu", ý tôi là std::reference_wrappercái dễ dàng được xây dựng bởi các hàm std::refstd::cref. Chính xác hơn, bằng cách sử dụng:

auto func = [a,b,c](float arg){ return arg * 0.5f; };
calc2(std::cref(func));

thời gian giảm xuống khoảng 1860ms.

Tôi đã viết về điều đó một thời gian trước:

http://www.drdobbs.com/cpp/ffic-use-of-lambda-expressions-and/232500059

Như tôi đã nói trong bài viết, các đối số không hoàn toàn áp dụng cho VS2010 do hỗ trợ kém cho C ++ 11. Tại thời điểm viết bài, chỉ có phiên bản beta của VS2012 nhưng hỗ trợ cho C ++ 11 đã đủ tốt cho vấn đề này.


Tôi thực sự thấy điều này thú vị, muốn làm bằng chứng về tốc độ mã bằng các ví dụ đồ chơi được trình biên dịch tối ưu hóa vì chúng không có bất kỳ tác dụng phụ nào. Tôi sẽ nói rằng hiếm khi người ta có thể đặt cược vào các loại phép đo này, mà không có một số mã sản xuất / thực tế.
Ghita

@ Ghita: Trong ví dụ này, để ngăn chặn mã được tối ưu hóa, calc1có thể lấy một floatđối số sẽ là kết quả của lần lặp trước. Một cái gì đó như x = calc1(x, [](float arg){ return arg * 0.5f; });. Ngoài ra, chúng tôi phải đảm bảo rằng việc calc1sử dụng x. Nhưng, điều này vẫn chưa đủ. Chúng ta cần tạo ra một hiệu ứng phụ. Chẳng hạn, sau khi đo, in xtrên màn hình. Mặc dù vậy, tôi đồng ý rằng việc sử dụng mã đồ chơi để đo thời gian không thể luôn đưa ra một dấu hiệu hoàn hảo về những gì sẽ xảy ra với mã thực / sản xuất.
Cassio Neri

Dường như với tôi, điểm chuẩn cũng xây dựng đối tượng hàm std :: bên trong vòng lặp và gọi calc2 trong vòng lặp. Bất kể trình biên dịch có thể hoặc không thể tối ưu hóa điều này, (và rằng hàm tạo có thể đơn giản như lưu trữ vptr), tôi sẽ quan tâm nhiều hơn đến trường hợp hàm được xây dựng một lần và chuyển sang hàm khác gọi nó trong một vòng lặp. Tức là tổng phí cuộc gọi thay vì thời gian xây dựng (và cuộc gọi của 'f' chứ không phải của calc2). Cũng sẽ được quan tâm nếu gọi f trong một vòng lặp (trong calc2), chứ không phải một lần, sẽ được hưởng lợi từ bất kỳ cẩu nào.
greggo

Câu trả lời chính xác. 2 điều: ví dụ hay về việc sử dụng hợp lệ cho std::reference_wrapper(để ép buộc các mẫu; nó không chỉ dành cho lưu trữ chung) và thật buồn cười khi thấy trình tối ưu hóa của VS không thể loại bỏ một vòng lặp trống ... như tôi nhận thấy với lỗi GCC nàyvolatile .
gạch dưới

37

Với Clang không có sự khác biệt về hiệu suất giữa hai

Sử dụng clang (3.2, thân 166872) (-O2 trên Linux), các nhị phân từ hai trường hợp thực sự giống hệt nhau .

-Tôi sẽ quay lại kêu vang ở cuối bài. Nhưng trước tiên, gcc 4.7.2:

Đã có rất nhiều cái nhìn sâu sắc đang diễn ra, nhưng tôi muốn chỉ ra rằng kết quả tính toán của calc1 và calc2 không giống nhau, do trong lớp, v.v. So sánh ví dụ tổng của tất cả các kết quả:

float result=0;
for (int i = 0; i < 1e8; ++i) {
  result+=calc2([](float arg){ return arg * 0.5f; });
}

với calc2 trở thành

1.71799e+10, time spent 0.14 sec

trong khi với calc1 nó trở thành

6.6435e+10, time spent 5.772 sec

đó là hệ số ~ 40 về chênh lệch tốc độ và hệ số ~ 4 trong các giá trị. Đầu tiên là một sự khác biệt lớn hơn nhiều so với những gì OP đăng (sử dụng visual studio). Trên thực tế, việc in ra giá trị cuối cùng cũng là một ý tưởng tốt để ngăn trình biên dịch xóa mã mà không có kết quả rõ ràng (quy tắc as-if). Cassio Neri đã nói điều này trong câu trả lời của mình. Lưu ý kết quả khác nhau như thế nào - Người ta phải cẩn thận khi so sánh các yếu tố tốc độ của các mã thực hiện các phép tính khác nhau.

Ngoài ra, để công bằng, so sánh nhiều cách tính toán lặp lại f (3.3) có lẽ không thú vị lắm. Nếu đầu vào không đổi thì không nên ở trong một vòng lặp. (Thật dễ dàng để trình tối ưu hóa nhận thấy)

Nếu tôi thêm một đối số giá trị do người dùng cung cấp cho calc1 và 2 thì hệ số tốc độ giữa calc1 và calc2 giảm xuống hệ số 5, từ 40! Với studio hình ảnh, sự khác biệt gần với hệ số 2 và với tiếng kêu không có sự khác biệt (xem bên dưới).

Ngoài ra, vì phép nhân rất nhanh, nên nói về các yếu tố làm chậm thường không thú vị. Một câu hỏi thú vị hơn là, các chức năng của bạn nhỏ như thế nào và những cuộc gọi này có phải là nút cổ chai trong một chương trình thực không?

Kêu vang:

Clang (tôi đã sử dụng 3.2) thực sự tạo ra các nhị phân giống hệt nhau khi tôi lật giữa calc1 và calc2 cho mã ví dụ (được đăng dưới đây). Với ví dụ ban đầu được đăng trong câu hỏi, cả hai cũng giống hệt nhau nhưng không mất thời gian (các vòng lặp hoàn toàn bị loại bỏ như mô tả ở trên). Với ví dụ sửa đổi của tôi, với -O2:

Số giây để thực hiện (tốt nhất là 3):

clang:        calc1:           1.4 seconds
clang:        calc2:           1.4 seconds (identical binary)

gcc 4.7.2:    calc1:           1.1 seconds
gcc 4.7.2:    calc2:           6.0 seconds

VS2012 CTPNov calc1:           0.8 seconds 
VS2012 CTPNov calc2:           2.0 seconds 

VS2015 (14.0.23.107) calc1:    1.1 seconds 
VS2015 (14.0.23.107) calc2:    1.5 seconds 

MinGW (4.7.2) calc1:           0.9 seconds
MinGW (4.7.2) calc2:          20.5 seconds 

Kết quả tính toán của tất cả các nhị phân là như nhau và tất cả các thử nghiệm đã được thực hiện trên cùng một máy. Sẽ rất thú vị nếu ai đó có kiến ​​thức sâu hơn về kiến ​​thức hoặc VS có thể nhận xét về những gì tối ưu hóa có thể đã được thực hiện.

Mã kiểm tra sửa đổi của tôi:

#include <functional>
#include <chrono>
#include <iostream>

template <typename F>
float calc1(F f, float x) { 
  return 1.0f + 0.002*x+f(x*1.223) ; 
}

float calc2(std::function<float(float)> f,float x) { 
  return 1.0f + 0.002*x+f(x*1.223) ; 
}

int main() {
    using namespace std::chrono;

    const auto tp1 = high_resolution_clock::now();

    float result=0;
    for (int i = 0; i < 1e8; ++i) {
      result=calc1([](float arg){ 
          return arg * 0.5f; 
        },result);
    }
    const auto tp2 = high_resolution_clock::now();

    const auto d = duration_cast<milliseconds>(tp2 - tp1);  
    std::cout << d.count() << std::endl;
    std::cout << result<< std::endl;
    return 0;
}

Cập nhật:

Đã thêm vs2015. Tôi cũng nhận thấy rằng có gấp đôi-> chuyển đổi float trong calc1, calc2. Loại bỏ chúng không thay đổi kết luận cho phòng thu trực quan (cả hai đều nhanh hơn rất nhiều nhưng tỷ lệ là như nhau).


8
Mà được cho là chỉ cho thấy điểm chuẩn là sai. IMHO trường hợp sử dụng thú vị là nơi mã gọi nhận được một đối tượng hàm từ một nơi khác, vì vậy trình biên dịch không biết nguồn gốc của hàm std :: khi biên dịch cuộc gọi. Ở đây, trình biên dịch biết chính xác thành phần của hàm std :: khi gọi nó, bằng cách mở rộng calc2 inline thành main. Dễ dàng sửa bằng cách tạo calc2 'extern' trong sep. tập tin nguồn. Sau đó, bạn đang so sánh táo w / cam; calc2 đang làm một cái gì đó calc1 không thể. Và, vòng lặp có thể ở bên trong calc (nhiều lệnh gọi đến f); không xung quanh ctor của đối tượng hàm.
greggo

1
Khi tôi có thể đến một trình biên dịch phù hợp. Có thể nói bây giờ rằng (a) ctor cho một hàm std :: thực tế gọi là 'mới'; (b) bản thân cuộc gọi khá nạc khi mục tiêu là một chức năng thực tế phù hợp; (c) trong trường hợp có ràng buộc, có một đoạn mã thực hiện điều chỉnh, được chọn bởi một mã ptr trong hàm obj và lấy dữ liệu (parms ràng buộc) từ hàm obj (d) hàm 'ràng buộc' được nội tuyến vào bộ điều hợp đó, nếu trình biên dịch có thể nhìn thấy nó.
greggo

Câu trả lời mới được thêm vào với các thiết lập được mô tả.
greggo

3
BTW Điểm chuẩn không sai, câu hỏi ("std :: function vs template") chỉ có giá trị trong phạm vi của cùng một đơn vị biên dịch. Nếu bạn di chuyển chức năng sang đơn vị khác, mẫu không còn có thể, do đó không có gì để so sánh.
rustyx

13

Khác nhau không giống nhau.

Nó chậm hơn vì nó làm những việc mà một mẫu không thể làm được. Cụ thể, nó cho phép bạn gọi bất kỳ hàm nào có thể được gọi với các loại đối số đã cho và loại trả về có thể chuyển đổi thành loại trả về đã cho từ cùng một mã .

void eval(const std::function<int(int)>& f) {
    std::cout << f(3);
}

int f1(int i) {
    return i;
}

float f2(double d) {
    return d;
}

int main() {
    std::function<int(int)> fun(f1);
    eval(fun);
    fun = f2;
    eval(fun);
    return 0;
}

Lưu ý rằng cùng một đối tượng chức năng fun, đang được truyền cho cả hai cuộc gọi đến eval. Nó giữ hai chức năng khác nhau .

Nếu bạn không cần phải làm điều đó, thì bạn không nên sử dụng std::function.


2
Chỉ muốn chỉ ra rằng khi 'fun = f2' hoàn thành, đối tượng 'fun' kết thúc chỉ đến một hàm ẩn chuyển đổi int thành double, gọi f2 và chuyển đổi kết quả kép trở lại int. (Trong ví dụ thực tế , 'f2' có thể được đưa vào chức năng đó). Nếu bạn chỉ định std :: bind to fun, đối tượng 'fun' có thể chứa các giá trị được sử dụng cho các tham số bị ràng buộc. để hỗ trợ tính linh hoạt này, việc gán cho 'niềm vui' (hoặc init) có thể liên quan đến việc phân bổ / giải phóng bộ nhớ và có thể mất nhiều thời gian hơn so với chi phí cuộc gọi thực tế.
greggo

8

Bạn đã có một số câu trả lời hay ở đây, vì vậy tôi sẽ không mâu thuẫn với chúng, nói ngắn gọn là so sánh hàm std :: với các mẫu giống như so sánh các hàm ảo với các hàm. Bạn không bao giờ nên "thích" các hàm ảo cho các hàm, mà thay vào đó bạn sử dụng các hàm ảo khi nó phù hợp với vấn đề, chuyển các quyết định từ thời gian biên dịch sang thời gian chạy. Ý tưởng là thay vì phải giải quyết vấn đề bằng cách sử dụng giải pháp bespoke (như bảng nhảy), bạn sử dụng một cái gì đó giúp trình biên dịch có cơ hội tối ưu hóa tốt hơn cho bạn. Nó cũng giúp các lập trình viên khác, nếu bạn sử dụng một giải pháp tiêu chuẩn.


6

Câu trả lời này nhằm đóng góp vào tập hợp các câu trả lời hiện có, điều mà tôi tin là một điểm chuẩn có ý nghĩa hơn cho chi phí thời gian chạy của các lệnh gọi hàm std ::.

Cơ chế chức năng std :: nên được công nhận cho những gì nó cung cấp: Bất kỳ thực thể có thể gọi nào cũng có thể được chuyển đổi thành chức năng std :: có chữ ký thích hợp. Giả sử bạn có một thư viện phù hợp với một bề mặt cho một hàm được xác định bởi z = f (x, y), bạn có thể viết nó để chấp nhận a std::function<double(double,double)>và người dùng của thư viện có thể dễ dàng chuyển đổi bất kỳ thực thể có thể gọi nào sang đó; có thể là một hàm thông thường, một phương thức của một thể hiện của lớp hoặc lambda hoặc bất cứ thứ gì được hỗ trợ bởi std :: bind.

Không giống như cách tiếp cận mẫu, cách này hoạt động mà không phải biên dịch lại hàm thư viện cho các trường hợp khác nhau; theo đó, ít mã biên dịch thêm là cần thiết cho từng trường hợp bổ sung. Luôn luôn có thể làm điều này xảy ra, nhưng nó thường yêu cầu một số cơ chế khó xử và người dùng thư viện có thể sẽ cần phải xây dựng một bộ chuyển đổi xung quanh chức năng của họ để làm cho nó hoạt động. std :: function tự động xây dựng bất kỳ bộ điều hợp nào cần thiết để có được giao diện cuộc gọi thời gian chạy chung cho tất cả các trường hợp, đây là một tính năng mới và rất mạnh mẽ.

Theo quan điểm của tôi, đây là trường hợp sử dụng quan trọng nhất đối với chức năng std :: liên quan đến hiệu suất: Tôi quan tâm đến chi phí gọi hàm std :: nhiều lần sau khi nó được xây dựng một lần và nó cần phải là một tình huống trong đó trình biên dịch không thể tối ưu hóa cuộc gọi bằng cách biết hàm thực sự được gọi (tức là bạn cần ẩn việc thực hiện trong tệp nguồn khác để có điểm chuẩn phù hợp).

Tôi đã thực hiện bài kiểm tra dưới đây, tương tự như của OP; nhưng những thay đổi chính là:

  1. Mỗi trường hợp lặp 1 tỷ lần, nhưng các đối tượng hàm std :: chỉ được xây dựng một lần. Tôi đã tìm thấy bằng cách xem mã đầu ra mà 'toán tử mới' được gọi khi xây dựng các lệnh gọi hàm std :: thực tế (có thể không phải khi chúng được tối ưu hóa).
  2. Kiểm tra được chia thành hai tệp để ngăn chặn tối ưu hóa không mong muốn
  3. Các trường hợp của tôi là: (a) hàm được nội tuyến (b) được truyền bởi một hàm con trỏ hàm thông thường (c) là một hàm tương thích được bao bọc bởi hàm std :: function (d) là một hàm không tương thích được thực hiện tương thích với std :: liên kết, bọc như hàm std ::

Kết quả tôi nhận được là:

  • trường hợp (a) (nội tuyến) 1,3 nsec

  • tất cả các trường hợp khác: 3,3 nsec.

Trường hợp (d) có xu hướng chậm hơn một chút, nhưng sự khác biệt (khoảng 0,05 nsec) được hấp thụ trong tiếng ồn.

Kết luận là hàm std :: có thể so sánh được (tại thời điểm cuộc gọi) với việc sử dụng một con trỏ hàm, ngay cả khi có sự thích ứng 'liên kết' đơn giản với hàm thực tế. Nội tuyến nhanh hơn 2 ns so với các nội dung khác nhưng đó là một sự đánh đổi dự kiến ​​vì nội tuyến là trường hợp duy nhất "cứng cáp" trong thời gian chạy.

Khi tôi chạy mã của johan-lundberg trên cùng một máy, tôi thấy khoảng 39 nsec mỗi vòng lặp, nhưng có nhiều hơn trong vòng lặp ở đó, bao gồm cả hàm tạo và hàm hủy thực tế của hàm std ::, có lẽ khá cao vì nó liên quan đến một cái mới và xóa.

-O2 gcc 4.8.1, đến x86_64 mục tiêu (lõi i5).

Lưu ý, mã được chia thành hai tệp, để ngăn trình biên dịch mở rộng các hàm nơi chúng được gọi (ngoại trừ trong trường hợp nó dự định).

----- tập tin nguồn đầu tiên --------------

#include <functional>


// simple funct
float func_half( float x ) { return x * 0.5; }

// func we can bind
float mul_by( float x, float scale ) { return x * scale; }

//
// func to call another func a zillion times.
//
float test_stdfunc( std::function<float(float)> const & func, int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func(x);
    }
    return y;
}

// same thing with a function pointer
float test_funcptr( float (*func)(float), int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func(x);
    }
    return y;
}

// same thing with inline function
float test_inline(  int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func_half(x);
    }
    return y;
}

----- tập tin nguồn thứ hai -------------

#include <iostream>
#include <functional>
#include <chrono>

extern float func_half( float x );
extern float mul_by( float x, float scale );
extern float test_inline(  int nloops );
extern float test_stdfunc( std::function<float(float)> const & func, int nloops );
extern float test_funcptr( float (*func)(float), int nloops );

int main() {
    using namespace std::chrono;


    for(int icase = 0; icase < 4; icase ++ ){
        const auto tp1 = system_clock::now();

        float result;
        switch( icase ){
         case 0:
            result = test_inline( 1e9);
            break;
         case 1:
            result = test_funcptr( func_half, 1e9);
            break;
         case 2:
            result = test_stdfunc( func_half, 1e9);
            break;
         case 3:
            result = test_stdfunc( std::bind( mul_by, std::placeholders::_1, 0.5), 1e9);
            break;
        }
        const auto tp2 = high_resolution_clock::now();

        const auto d = duration_cast<milliseconds>(tp2 - tp1);  
        std::cout << d.count() << std::endl;
        std::cout << result<< std::endl;
    }
    return 0;
}

Đối với những người quan tâm, đây là bộ điều hợp trình biên dịch được xây dựng để làm cho 'mul_by' trông giống như một float (float) - đây là 'được gọi' khi hàm được tạo dưới dạng bind (mul_by, _1,0.5) được gọi:

movq    (%rdi), %rax                ; get the std::func data
movsd   8(%rax), %xmm1              ; get the bound value (0.5)
movq    (%rax), %rdx                ; get the function to call (mul_by)
cvtpd2ps    %xmm1, %xmm1        ; convert 0.5 to 0.5f
jmp *%rdx                       ; jump to the func

(vì vậy có thể đã nhanh hơn một chút nếu tôi viết 0,5f trong liên kết ...) Lưu ý rằng tham số 'x' đến% xmm0 và chỉ ở đó.

Đây là mã trong khu vực nơi chức năng được xây dựng, trước khi gọi test_stdfunc - chạy qua bộ lọc c ++:

movl    $16, %edi
movq    $0, 32(%rsp)
call    operator new(unsigned long)      ; get 16 bytes for std::function
movsd   .LC0(%rip), %xmm1                ; get 0.5
leaq    16(%rsp), %rdi                   ; (1st parm to test_stdfunc) 
movq    mul_by(float, float), (%rax)     ; store &mul_by  in std::function
movl    $1000000000, %esi                ; (2nd parm to test_stdfunc)
movsd   %xmm1, 8(%rax)                   ; store 0.5 in std::function
movq    %rax, 16(%rsp)                   ; save ptr to allocated mem

   ;; the next two ops store pointers to generated code related to the std::function.
   ;; the first one points to the adaptor I showed above.

movq    std::_Function_handler<float (float), std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_invoke(std::_Any_data const&, float), 40(%rsp)
movq    std::_Function_base::_Base_manager<std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation), 32(%rsp)


call    test_stdfunc(std::function<float (float)> const&, int)

1
Với clang 3.4.1 x64, kết quả là: (a) 1.0, (b) 0.95, (c) 2.0, (d) 5.0.
rustyx

4

Tôi thấy kết quả của bạn rất thú vị vì vậy tôi đã đào một chút để hiểu chuyện gì đang xảy ra. Trước hết như nhiều người khác đã nói với việc có kết quả tính toán hiệu ứng trạng thái của chương trình, trình biên dịch sẽ chỉ tối ưu hóa điều này. Thứ hai có hằng số 3,3 được đưa ra như một vũ khí cho cuộc gọi lại Tôi nghi ngờ rằng sẽ có những tối ưu hóa khác đang diễn ra. Với ý nghĩ đó tôi đã thay đổi mã điểm chuẩn của bạn một chút.

template <typename F>
float calc1(F f, float i) { return -1.0f * f(i) + 666.0f; }
float calc2(std::function<float(float)> f, float i) { return -1.0f * f(i) + 666.0f; }
int main() {
    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        t += calc2([&](float arg){ return arg * 0.5f + t; }, i);
    }
    const auto tp2 = high_resolution_clock::now();
}

Thay đổi mã này tôi đã biên dịch với gcc 4.8 -O3 và có thời gian 330ms cho calc1 và 2702 cho calc2. Vì vậy, việc sử dụng mẫu nhanh hơn 8 lần, con số này có vẻ đáng ngờ đối với tôi, tốc độ của sức mạnh 8 thường cho thấy trình biên dịch đã vectơ một cái gì đó. Khi tôi nhìn vào mã được tạo cho phiên bản mẫu, nó rõ ràng đã được chỉnh sửa

.L34:
cvtsi2ss        %edx, %xmm0
addl    $1, %edx
movaps  %xmm3, %xmm5
mulss   %xmm4, %xmm0
addss   %xmm1, %xmm0
subss   %xmm0, %xmm5
movaps  %xmm5, %xmm0
addss   %xmm1, %xmm0
cvtsi2sd        %edx, %xmm1
ucomisd %xmm1, %xmm2
ja      .L37
movss   %xmm0, 16(%rsp)

Trường hợp như phiên bản chức năng std :: thì không. Điều này có ý nghĩa với tôi, vì với khuôn mẫu, trình biên dịch biết chắc rằng hàm sẽ không bao giờ thay đổi trong suốt vòng lặp nhưng với hàm std :: được truyền trong nó có thể thay đổi, do đó không thể được vector hóa.

Điều này khiến tôi phải thử một cái gì đó khác để xem liệu tôi có thể khiến trình biên dịch thực hiện tối ưu hóa tương tự trên phiên bản hàm std :: không. Thay vì truyền vào một hàm, tôi tạo một hàm std :: như một var toàn cục và được gọi là hàm này.

float calc3(float i) {  return -1.0f * f2(i) + 666.0f; }
std::function<float(float)> f2 = [](float arg){ return arg * 0.5f; };

int main() {
    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        t += calc3([&](float arg){ return arg * 0.5f + t; }, i);
    }
    const auto tp2 = high_resolution_clock::now();
}

Với phiên bản này, chúng ta thấy rằng trình biên dịch hiện đã vector hóa mã theo cùng một cách và tôi nhận được kết quả điểm chuẩn tương tự.

  • mẫu: 330ms
  • std :: chức năng: 2702ms
  • toàn cầu std :: chức năng: 330ms

Vì vậy, kết luận của tôi là tốc độ thô của hàm std :: so với hàm functor mẫu khá giống nhau. Tuy nhiên, nó làm cho công việc của trình tối ưu hóa khó khăn hơn nhiều.


1
Toàn bộ vấn đề là để vượt qua một functor như một tham số. calc3Trường hợp của bạn không có ý nghĩa; calc3 hiện được mã hóa cứng để gọi f2. Tất nhiên điều đó có thể được tối ưu hóa.
rustyx

Thật vậy, đây là những gì tôi đã cố gắng thể hiện. Calc3 đó tương đương với mẫu và trong tình huống đó thực sự là một cấu trúc thời gian biên dịch giống như một mẫu.
Joshua Ritterman
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.