std :: function vs template

161

Berkat C ++ 11 kami menerima std::functionkeluarga pembungkus functor. Sayangnya, saya terus mendengar hanya hal-hal buruk tentang penambahan baru ini. Yang paling populer adalah mereka sangat lambat. Saya mengujinya dan mereka benar-benar payah dibandingkan dengan template.

#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 vs 1241 ms. Saya berasumsi ini karena templat dapat digarisbawahi dengan baik, sementara functionitu menutupi internal melalui panggilan virtual.

Jelas template memiliki masalah mereka seperti yang saya lihat:

  • mereka harus disediakan sebagai tajuk yang bukan sesuatu yang Anda mungkin tidak ingin lakukan ketika merilis perpustakaan Anda sebagai kode tertutup,
  • mereka dapat membuat waktu kompilasi lebih lama kecuali extern templatekebijakan -seperti diperkenalkan,
  • tidak ada (setidaknya diketahui oleh saya) cara yang bersih untuk mewakili persyaratan (konsep, siapa pun?) dari sebuah template, batalkan komentar yang menggambarkan fungsi apa yang diharapkan.

Dapatkah saya dengan demikian mengasumsikan bahwa functions dapat digunakan sebagai standar de facto untuk fungsi yang lewat, dan di tempat-tempat di mana templat yang diharapkan berkinerja tinggi harus digunakan?


Edit:

Kompiler saya adalah Visual Studio 2012 tanpa CTP.

Merah XIII
sumber
16
Gunakan std::functionjika dan hanya jika Anda benar - benar membutuhkan koleksi objek yang dapat dipanggil yang heterogen (yaitu tidak ada informasi diskriminatif lebih lanjut tersedia saat runtime).
Kerrek SB
30
Anda membandingkan hal-hal yang salah. Template digunakan dalam kedua kasus - ini bukan " std::functionatau template". Saya pikir di sini masalahnya hanya membungkus lambda std::functionvs tidak membungkus lambda std::function. Saat ini pertanyaan Anda seperti bertanya, "Haruskah saya lebih suka apel, atau mangkuk?"
Lightness Races dalam Orbit
7
Apakah 1ns atau 10ns, keduanya bukan apa-apa.
ipc
23
@ IPC: 1000% bukan apa-apa. Ketika OP mengidentifikasi, Anda mulai peduli ketika skalabilitas masuk ke dalamnya untuk tujuan praktis apa pun.
Lightness Races di Orbit
18
@ IPC Ini 10 kali lebih lambat, yang sangat besar. Kecepatan perlu dibandingkan dengan garis dasar; itu menipu untuk berpikir itu tidak masalah hanya karena itu nanodetik.
Paul Manta

Jawaban:

170

Secara umum, jika Anda menghadapi situasi desain yang memberi Anda pilihan, gunakan template . Saya menekankan desain kata karena saya pikir apa yang Anda perlu fokus adalah perbedaan antara kasus penggunaan std::functiondan template, yang sangat berbeda.

Secara umum, pilihan templat hanyalah turunan dari prinsip yang lebih luas: coba tentukan sebanyak mungkin kendala pada waktu kompilasi . Dasar pemikirannya sederhana: jika Anda dapat menangkap kesalahan, atau jenis ketidakcocokan, bahkan sebelum program Anda dibuat, Anda tidak akan mengirimkan program kereta kepada pelanggan Anda.

Selain itu, ketika Anda menunjukkan dengan benar, panggilan ke fungsi template diselesaikan secara statis (yaitu pada waktu kompilasi), sehingga kompiler memiliki semua informasi yang diperlukan untuk mengoptimalkan dan mungkin inline kode (yang tidak akan mungkin jika panggilan dilakukan melalui vtable).

Ya, memang benar bahwa dukungan template tidak sempurna, dan C ++ 11 masih kurang mendukung konsep; Namun, saya tidak melihat bagaimana std::functionmenyelamatkan Anda dalam hal itu. std::functionbukan alternatif untuk templat, melainkan alat untuk situasi desain di mana templat tidak dapat digunakan.

Salah satu kasus penggunaan tersebut muncul ketika Anda harus menyelesaikan panggilan pada saat run-time dengan memanggil objek yang bisa dipanggil yang mematuhi tanda tangan tertentu, tetapi yang jenis konkretnya tidak diketahui pada waktu kompilasi. Ini biasanya terjadi ketika Anda memiliki koleksi panggilan balik dari tipe-tipe yang berpotensi berbeda , tetapi Anda perlu memohon secara seragam ; jenis dan jumlah panggilan balik terdaftar ditentukan pada saat run-time berdasarkan keadaan program Anda dan logika aplikasi. Beberapa dari callback tersebut dapat berupa functors, beberapa dapat berupa fungsi yang sederhana, beberapa dapat merupakan hasil dari pengikatan fungsi lainnya dengan argumen tertentu.

std::functiondan std::bindjuga menawarkan idiom alami untuk mengaktifkan pemrograman fungsional dalam C ++, di mana fungsi diperlakukan sebagai objek dan secara alami digulung dan digabungkan untuk menghasilkan fungsi lainnya. Meskipun kombinasi semacam ini dapat dicapai dengan templat juga, situasi desain serupa biasanya datang bersama dengan use case yang mengharuskan untuk menentukan jenis objek yang dapat dipanggil yang dipadukan pada saat run-time.

Akhirnya, ada situasi lain di mana std::functiontidak dapat dihindari, misalnya jika Anda ingin menulis lambda rekursif ; Namun, pembatasan ini lebih didikte oleh keterbatasan teknologi daripada oleh perbedaan konseptual yang saya percaya.

Singkatnya, fokuslah pada desain dan cobalah untuk memahami apa saja kasus penggunaan konseptual untuk dua konstruksi ini. Jika Anda membandingkannya dengan cara yang Anda lakukan, Anda memaksa mereka masuk ke arena yang mungkin bukan milik mereka.

Andy Prowl
sumber
23
Saya pikir "Ini biasanya terjadi ketika Anda memiliki koleksi panggilan balik dari jenis yang berpotensi berbeda, tetapi Anda perlu memohon secara seragam;" adalah bagian yang penting. Aturan praktis saya adalah: "Lebih suka std::functiondi ujung penyimpanan dan templat Fundi antarmuka".
R. Martinho Fernandes
2
Catatan: teknik menyembunyikan jenis beton disebut tipe erasure (jangan dikacaukan dengan tipe erasure dalam bahasa yang dikelola). Ini sering diterapkan dalam hal polimorfisme dinamis, tetapi lebih kuat (misalnya unique_ptr<void>memanggil destruktor yang tepat bahkan untuk tipe tanpa destruktor virtual).
ecatmur
2
@ecatmur: Saya setuju pada substansi, meskipun kami sedikit tidak selaras pada terminologi. Polimorfisme dinamis berarti bagi saya "mengasumsikan bentuk yang berbeda pada saat run-time", berlawanan dengan polimorfisme statis yang saya tafsirkan sebagai "mengasumsikan bentuk yang berbeda pada waktu kompilasi"; yang terakhir tidak dapat dicapai melalui templat. Bagi saya, tipe erasure adalah desain-bijaksana, semacam prasyarat untuk dapat mencapai polimorfisme dinamis sama sekali: Anda memerlukan beberapa antarmuka yang seragam untuk berinteraksi dengan objek dari tipe yang berbeda, dan tipe erasure adalah cara untuk mengabstraksi tipe- info spesifik.
Andy Prowl
2
@ecatmur: Jadi polimorfisme dinamis adalah pola konseptual, sedangkan tipe erasure adalah teknik yang memungkinkan untuk mewujudkannya.
Andy Prowl
2
@Downvoter: Saya ingin tahu apa yang Anda temukan salah dalam jawaban ini.
Andy Prowl
89

Andy Prowl telah membahas masalah desain dengan baik. Ini, tentu saja, sangat penting, tetapi saya percaya pertanyaan awal menyangkut masalah kinerja yang lebih terkait std::function.

Pertama-tama, komentar singkat tentang teknik pengukuran: 11ms yang diperoleh calc1tidak memiliki arti sama sekali. Memang, melihat rakitan yang dihasilkan (atau men-debug kode rakitan), orang dapat melihat bahwa pengoptimal VS2012 cukup pintar untuk menyadari bahwa hasil panggilan calc1tidak tergantung pada iterasi dan memindahkan panggilan keluar dari loop:

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

Selain itu, ia menyadari bahwa panggilan calc1tidak memiliki efek yang terlihat dan menjatuhkan panggilan sama sekali. Oleh karena itu, 111ms adalah waktu yang diperlukan untuk menjalankan loop kosong. (Saya terkejut bahwa pengoptimal menjaga loop.) Jadi, berhati-hatilah dengan pengukuran waktu dalam loop. Ini tidak sesederhana kelihatannya.

Seperti yang telah ditunjukkan, pengoptimal memiliki lebih banyak masalah untuk dipahami std::functiondan tidak memindahkan panggilan keluar dari loop. Jadi 1241ms adalah ukuran yang adil untuk calc2.

Perhatikan bahwa, std::functionmampu menyimpan berbagai jenis objek yang bisa dipanggil. Oleh karena itu, ia harus melakukan beberapa sihir penghapus tipe untuk penyimpanan. Secara umum, ini menyiratkan alokasi memori dinamis (secara default melalui panggilan ke new). Sudah diketahui bahwa ini adalah operasi yang cukup mahal.

Standar (20.8.11.2.1 / 5) mendukung implementasi untuk menghindari alokasi memori dinamis untuk objek kecil yang, untungnya, VS2012 melakukannya (khususnya, untuk kode asli).

Untuk mendapatkan gambaran tentang seberapa lambat yang bisa didapat ketika alokasi memori terlibat, saya telah mengubah ekspresi lambda untuk menangkap tiga float. Ini membuat objek yang bisa dipanggil terlalu besar untuk menerapkan optimasi objek kecil:

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

Untuk versi ini, waktunya sekitar 16000 ms (dibandingkan dengan 1241 ms untuk kode asli).

Akhirnya, perhatikan bahwa masa hidup lambda melampirkan bahwa dari std::function. Dalam hal ini, alih-alih menyimpan salinan lambda, std::functiondapat menyimpan "referensi" untuknya. Dengan "referensi" Maksud saya std::reference_wrapperyang mudah dibangun oleh fungsi std::refdan std::cref. Lebih tepatnya, dengan menggunakan:

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

waktu berkurang menjadi sekitar 1860 ms.

Saya menulis tentang itu beberapa waktu yang lalu:

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

Seperti yang saya katakan di artikel, argumen tidak cukup berlaku untuk VS2010 karena dukungannya yang buruk untuk C ++ 11. Pada saat penulisan, hanya versi beta VS2012 yang tersedia tetapi dukungannya untuk C ++ 11 sudah cukup baik untuk masalah ini.

Cassio Neri
sumber
Saya menemukan ini memang menarik, ingin membuat bukti kecepatan kode menggunakan contoh mainan yang dioptimalkan oleh kompiler karena mereka tidak memiliki efek samping. Saya akan mengatakan bahwa seseorang jarang dapat bertaruh pada jenis pengukuran ini, tanpa kode produksi / nyata.
Ghita
@ Ghita: Dalam contoh ini, untuk mencegah kode dioptimalkan, calc1bisa mengambil floatargumen yang akan menjadi hasil dari iterasi sebelumnya. Sesuatu seperti x = calc1(x, [](float arg){ return arg * 0.5f; });. Selain itu, kami harus memastikan calc1penggunaannya x. Tapi, ini belum cukup. Kita perlu menciptakan efek samping. Misalnya, setelah pengukuran, mencetak xpada layar. Meskipun demikian, saya setuju bahwa menggunakan kode mainan untuk pengukuran timimg tidak selalu dapat memberikan indikasi sempurna tentang apa yang akan terjadi dengan kode nyata / produksi.
Cassio Neri
Menurut saya juga, bahwa tolok ukur membangun objek std :: function di dalam loop, dan memanggil calc2 dalam loop. Terlepas dari kompiler yang mungkin atau tidak bisa mengoptimalkan ini, (dan bahwa konstruktor bisa semudah menyimpan vptr), saya akan lebih tertarik pada kasus di mana fungsi dibangun sekali, dan diteruskan ke fungsi lain yang memanggil dalam satu lingkaran. Yaitu panggilan overhead daripada waktu konstruk (dan panggilan 'f' dan bukan dari calc2). Juga akan tertarik jika memanggil f dalam satu loop (dalam calc2), daripada sekali, akan mendapat manfaat dari setiap mengangkat.
greggo
Jawaban yang bagus 2 hal: contoh yang bagus dari penggunaan yang valid untuk std::reference_wrapper(untuk memaksa template; ini bukan hanya untuk penyimpanan umum), dan itu lucu melihat pengoptimal VS gagal untuk membuang loop kosong ... seperti yang saya perhatikan dengan bug GCC inivolatile .
underscore_d
37

Dengan Dentang tidak ada perbedaan kinerja antara keduanya

Menggunakan dentang (3.2, trunk 166872) (-O2 di Linux), binari dari kedua case tersebut sebenarnya identik .

-Aku akan kembali ke dentang di akhir posting. Tapi pertama-tama, gcc 4.7.2:

Sudah ada banyak wawasan yang terjadi, tetapi saya ingin menunjukkan bahwa hasil perhitungan calc1 dan calc2 tidak sama, karena in-lining dll. Bandingkan misalnya jumlah dari semua hasil:

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

dengan calc2 yang menjadi

1.71799e+10, time spent 0.14 sec

sementara dengan calc1 menjadi

6.6435e+10, time spent 5.772 sec

itu faktor ~ 40 dalam perbedaan kecepatan, dan faktor ~ 4 dalam nilai. Yang pertama adalah perbedaan yang jauh lebih besar daripada yang diposting OP (menggunakan studio visual). Sebenarnya mencetak nilai dan akhirnya juga merupakan ide yang baik untuk mencegah kompiler untuk menghapus kode tanpa hasil yang terlihat (as-jika aturan). Cassio Neri sudah mengatakan ini dalam jawabannya. Perhatikan betapa berbedanya hasilnya - Orang harus berhati-hati ketika membandingkan faktor kecepatan kode yang melakukan perhitungan yang berbeda.

Juga, untuk bersikap adil, membandingkan berbagai cara penghitungan berulang kali f (3.3) mungkin tidak begitu menarik. Jika inputnya konstan, seharusnya tidak dalam satu lingkaran. (Pengoptimal mudah memperhatikan)

Jika saya menambahkan argumen nilai yang disediakan pengguna ke Calc1 dan 2 faktor kecepatan antara Calc1 dan Calc2 turun ke faktor 5, dari 40! Dengan visual studio perbedaannya dekat dengan faktor 2, dan dengan dentang tidak ada perbedaan (lihat di bawah).

Juga, karena perkalian cepat, berbicara tentang faktor-faktor perlambatan seringkali tidak menarik. Pertanyaan yang lebih menarik adalah, seberapa kecil fungsi Anda, dan apakah ini disebut hambatan dalam program nyata?

Dentang:

Dentang (saya menggunakan 3,2) benar-benar menghasilkan binari identik ketika saya membalik antara calc1 dan calc2 untuk kode contoh (diposting di bawah). Dengan contoh asli yang diposting dalam pertanyaan, keduanya juga identik tetapi tidak membutuhkan waktu sama sekali (loop hanya dihapus sepenuhnya seperti dijelaskan di atas). Dengan contoh saya yang dimodifikasi, dengan -O2:

Jumlah detik untuk dieksekusi (terbaik 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 

Hasil perhitungan semua binari adalah sama, dan semua tes dieksekusi pada mesin yang sama. Akan menarik jika seseorang dengan dentang yang lebih dalam atau pengetahuan VS dapat mengomentari optimasi apa yang mungkin telah dilakukan.

Kode pengujian saya yang dimodifikasi:

#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;
}

Memperbarui:

Ditambahkan vs2015. Saya juga memperhatikan bahwa ada konversi float ganda di calc1, calc2. Menghapusnya tidak mengubah kesimpulan untuk studio visual (keduanya jauh lebih cepat tetapi rasionya hampir sama).

Johan Lundberg
sumber
8
Yang bisa dibilang hanya menunjukkan patokan yang salah. IMHO, use case yang menarik adalah di mana kode panggilan menerima objek fungsi dari tempat lain, jadi kompiler tidak tahu asal dari fungsi std :: ketika menyusun panggilan. Di sini, kompiler tahu persis komposisi fungsi std :: saat memanggilnya, dengan memperluas calc2 inline ke main. Mudah diperbaiki dengan membuat calc2 'extern' di September. sumber data. Anda kemudian membandingkan apel dengan jeruk; Calc2 sedang melakukan sesuatu yang Calc1 tidak bisa. Dan, loop bisa berada di dalam calc (banyak panggilan ke f); tidak di sekitar ctor dari objek fungsi.
greggo
1
Ketika saya bisa mendapatkan kompiler yang cocok. Dapat mengatakan untuk saat ini bahwa (a) aktor untuk std :: function panggilan 'baru'; (B) panggilan itu sendiri sangat ramping ketika target adalah fungsi aktual yang cocok; (c) dalam kasus dengan pengikatan, ada sejumlah kode yang melakukan adaptasi, dipilih oleh ptr kode dalam fungsi obj, dan yang mengambil data (parm terikat) dari objek obj (d) fungsi 'terikat' mungkin dimasukkan ke dalam adaptor itu, jika kompilator dapat melihatnya.
greggo
Jawaban baru ditambahkan dengan pengaturan yang dijelaskan.
greggo
3
BTW Patokannya tidak salah, pertanyaannya ("std :: function vs template") hanya valid dalam lingkup unit kompilasi yang sama. Jika Anda memindahkan fungsi ke unit lain, templat tidak lagi mungkin, jadi tidak ada yang bisa dibandingkan.
rustyx
13

Berbeda tidak sama.

Itu lebih lambat karena ia melakukan hal-hal yang tidak bisa dilakukan templat. Secara khusus, ini memungkinkan Anda memanggil fungsi apa pun yang dapat dipanggil dengan tipe argumen yang diberikan dan yang tipe pengembaliannya dapat dikonversi ke tipe pengembalian yang diberikan dari kode yang sama .

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;
}

Perhatikan bahwa objek fungsi yang samafun ,, sedang diteruskan ke kedua panggilan ke eval. Ini memegang dua fungsi yang berbeda .

Jika Anda tidak perlu melakukan itu, maka Anda sebaiknya tidak menggunakannya std::function.

Pete Becker
sumber
2
Hanya ingin menunjukkan bahwa ketika 'fun = f2' selesai, objek 'fun' berakhir dengan menunjuk ke fungsi tersembunyi yang mengubah int menjadi ganda, memanggil f2, dan mengonversi hasil ganda kembali ke int. (Dalam contoh aktual , 'f2' dapat dimasukkan ke dalam fungsi itu). Jika Anda menetapkan std :: bind to fun, objek 'fun' dapat berakhir berisi nilai yang akan digunakan untuk parameter terikat. untuk mendukung fleksibilitas ini, penetapan untuk 'bersenang-senang' (atau init of) dapat melibatkan pengalokasian / pengalokasian memori, dan ini bisa memakan waktu lebih lama daripada overhead panggilan yang sebenarnya.
greggo
8

Anda sudah memiliki beberapa jawaban yang bagus di sini, jadi saya tidak akan menentangnya, singkatnya membandingkan fungsi std :: dengan templat seperti membandingkan fungsi virtual ke fungsi. Anda tidak boleh "lebih suka" fungsi virtual daripada fungsi, tetapi Anda menggunakan fungsi virtual saat itu sesuai dengan masalahnya, memindahkan keputusan dari waktu kompilasi ke waktu berjalan. Idenya adalah bahwa daripada harus menyelesaikan masalah menggunakan solusi dipesan lebih dahulu (seperti meja lompatan) Anda menggunakan sesuatu yang memberi kompiler kesempatan yang lebih baik untuk mengoptimalkan untuk Anda. Ini juga membantu programmer lain, jika Anda menggunakan solusi standar.

Agitator
sumber
6

Jawaban ini dimaksudkan untuk berkontribusi, ke set jawaban yang ada, apa yang saya yakini sebagai tolok ukur yang lebih bermakna untuk biaya runtime panggilan std :: function.

Mekanisme std :: function harus dikenali dari apa yang disediakannya: Entitas apa pun yang dapat dipanggil dapat dikonversi ke fungsi std :: function dari tanda tangan yang sesuai. Misalkan Anda memiliki pustaka yang sesuai dengan permukaan ke fungsi yang didefinisikan oleh z = f (x, y), Anda dapat menulisnya untuk menerima a std::function<double(double,double)>, dan pengguna perpustakaan dapat dengan mudah mengkonversi entitas yang dapat dipanggil apa pun menjadi itu; baik itu fungsi biasa, metode instance kelas, atau lambda, atau apa pun yang didukung oleh std :: bind.

Tidak seperti pendekatan templat, ini bekerja tanpa harus mengkompilasi ulang fungsi pustaka untuk berbagai kasus; oleh karena itu, sedikit kode terkompilasi ekstra diperlukan untuk setiap kasus tambahan. Itu selalu mungkin untuk membuat ini terjadi, tetapi dulu memerlukan beberapa mekanisme canggung, dan pengguna perpustakaan mungkin perlu membangun adaptor di sekitar fungsi mereka untuk membuatnya berfungsi. std :: function secara otomatis membuat adaptor apa pun yang diperlukan untuk mendapatkan antarmuka panggilan runtime yang umum untuk semua kasus, yang merupakan fitur baru dan sangat kuat.

Menurut pendapat saya, ini adalah use case yang paling penting untuk fungsi std :: sejauh menyangkut kinerja: Saya tertarik pada biaya memanggil fungsi std :: berkali-kali setelah dibangun satu kali, dan perlu menjadi situasi di mana kompiler tidak dapat mengoptimalkan panggilan dengan mengetahui fungsi yang sebenarnya dipanggil (yaitu Anda harus menyembunyikan implementasi di file sumber lain untuk mendapatkan patokan yang tepat).

Saya membuat tes di bawah ini, mirip dengan OP; tetapi perubahan utamanya adalah:

  1. Setiap case loop 1 miliar kali, tetapi objek std :: function dibangun hanya sekali. Saya telah menemukan dengan melihat kode keluaran yang disebut 'operator baru' saat membuat panggilan fungsi std :: function aktual (mungkin tidak ketika dioptimalkan).
  2. Tes dibagi menjadi dua file untuk mencegah optimasi yang tidak diinginkan
  3. Kasus saya adalah: (a) fungsi digarisbawahi (b) fungsi dilewatkan oleh fungsi pointer biasa (c) fungsi adalah fungsi yang kompatibel dibungkus sebagai std :: function (d) fungsi adalah fungsi yang tidak kompatibel dibuat kompatibel dengan std :: bind, dibungkus sebagai std :: function

Hasil yang saya dapatkan adalah:

  • case (a) (inline) 1.3 nsec

  • semua kasus lain: 3,3 nsec.

Kasus (d) cenderung sedikit lebih lambat, tetapi perbedaannya (sekitar 0,05 nsec) diserap dalam kebisingan.

Kesimpulannya adalah bahwa fungsi std :: adalah overhead yang sebanding (pada waktu panggilan) untuk menggunakan pointer fungsi, bahkan ketika ada adaptasi 'bind' sederhana dengan fungsi sebenarnya. Inline adalah 2 ns lebih cepat daripada yang lain tapi itu tradeoff yang diharapkan karena inline adalah satu-satunya kasus yang 'terprogram' pada saat run time.

Ketika saya menjalankan kode johan-lundberg pada mesin yang sama, saya melihat sekitar 39 nsec per loop, tetapi ada lebih banyak di loop di sana, termasuk konstruktor dan destruktor sebenarnya dari fungsi std :: function, yang mungkin cukup tinggi karena itu melibatkan yang baru dan hapus.

-O2 gcc 4.8.1, untuk target x86_64 (core i5).

Catatan, kode ini dipecah menjadi dua file, untuk mencegah kompiler memperluas fungsi di mana mereka dipanggil (kecuali dalam satu kasus di mana itu dimaksudkan untuk).

----- file sumber pertama --------------

#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;
}

----- file sumber kedua -------------

#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;
}

Bagi mereka yang tertarik, inilah adaptor yang dibuat kompiler untuk membuat 'mul_by' terlihat seperti float (float) - ini disebut 'ketika fungsi yang dibuat sebagai bind (mul_by, _1.0.5) dipanggil:

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

(jadi mungkin sedikit lebih cepat jika saya menulis 0,5f di bind ...) Perhatikan bahwa parameter 'x' tiba di% xmm0 dan tetap di sana.

Berikut kode di area di mana fungsi dibangun, sebelum memanggil test_stdfunc - jalankan melalui c ++ filt:

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)
greggo
sumber
1
Dengan dentang 3.4.1 x64 hasilnya adalah: (a) 1.0, (b) 0.95, (c) 2.0, (d) 5.0.
rustyx
4

Saya menemukan hasil Anda sangat menarik sehingga saya melakukan sedikit penggalian untuk memahami apa yang sedang terjadi. Pertama karena banyak orang lain mengatakan tanpa memiliki hasil efek komputasi keadaan program, kompiler hanya akan mengoptimalkan ini. Kedua memiliki konstanta 3,3 yang diberikan sebagai persenjataan untuk panggilan balik saya menduga bahwa akan ada optimasi lain yang terjadi. Dengan mengingat hal itu saya sedikit mengubah kode benchmark Anda.

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();
}

Mengingat perubahan ini pada kode saya dikompilasi dengan gcc 4.8 -O3 dan mendapat waktu 330ms untuk Calc1 dan 2702 untuk Calc2. Jadi menggunakan template itu 8 kali lebih cepat, angka ini tampak mencurigakan bagi saya, kecepatan kekuatan 8 sering menunjukkan bahwa kompiler telah membuat vektor sesuatu. ketika saya melihat kode yang dihasilkan untuk versi templat, kode itu jelas-jelas divectoreized

.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)

Sedangkan versi std :: function tidak. Ini masuk akal bagi saya, karena dengan templat kompiler tahu pasti bahwa fungsi tidak akan pernah berubah di seluruh loop tetapi dengan std :: fungsi yang diteruskan di dalamnya bisa berubah, karenanya tidak dapat vektor.

Ini mendorong saya untuk mencoba sesuatu yang lain untuk melihat apakah saya bisa mendapatkan kompiler untuk melakukan optimasi yang sama pada std :: function versi. Alih-alih meneruskan fungsi saya membuat fungsi std :: sebagai global var, dan memanggil ini.

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();
}

Dengan versi ini kita melihat bahwa kompiler sekarang telah membuat vektor kode dengan cara yang sama dan saya mendapatkan hasil benchmark yang sama.

  • template: 330ms
  • std :: function: 2702ms
  • global std :: function: 330ms

Jadi kesimpulan saya adalah kecepatan mentah dari std :: function vs functor template hampir sama. Namun itu membuat pekerjaan pengoptimal jauh lebih sulit.

Joshua Ritterman
sumber
1
Intinya adalah untuk melewatkan sebuah functor sebagai parameter. calc3Kasus Anda tidak masuk akal; calc3 sekarang hardcoded untuk memanggil f2. Tentu saja itu bisa dioptimalkan.
rustyx
memang, inilah yang saya coba perlihatkan. Calc3 itu setara dengan templat, dan dalam situasi itu efektif susunan waktu kompilasi seperti templat.
Joshua Ritterman