Benchmarking sampel kode kecil di C #, dapatkah implementasi ini ditingkatkan?

104

Cukup sering di SO saya mendapati diri saya melakukan benchmarking pada potongan-potongan kecil kode untuk melihat implementasi mana yang tercepat.

Cukup sering saya melihat komentar bahwa kode pembandingan tidak memperhitungkan jitting atau pengumpul sampah.

Saya memiliki fungsi pembandingan sederhana berikut yang perlahan saya kembangkan:

  static void Profile(string description, int iterations, Action func) {
        // warm up 
        func();
        // clean up
        GC.Collect();

        var watch = new Stopwatch();
        watch.Start();
        for (int i = 0; i < iterations; i++) {
            func();
        }
        watch.Stop();
        Console.Write(description);
        Console.WriteLine(" Time Elapsed {0} ms", watch.ElapsedMilliseconds);
    }

Pemakaian:

Profile("a descriptions", how_many_iterations_to_run, () =>
{
   // ... code being profiled
});

Apakah penerapan ini memiliki kekurangan? Apakah cukup baik untuk menunjukkan bahwa implementasi X lebih cepat daripada implementasi Y melalui iterasi Z? Dapatkah Anda memikirkan cara untuk meningkatkan ini?

EDIT Cukup jelas bahwa pendekatan berbasis waktu (sebagai lawan dari iterasi), lebih disukai, apakah ada yang memiliki implementasi di mana pemeriksaan waktu tidak mempengaruhi kinerja?

Sam Saffron
sumber
Lihat juga BenchmarkDotNet .
Ben Hutchison

Jawaban:

95

Berikut adalah fungsi yang dimodifikasi: seperti yang direkomendasikan oleh komunitas, silakan ubah ini menjadi wiki komunitas.

static double Profile(string description, int iterations, Action func) {
    //Run at highest priority to minimize fluctuations caused by other processes/threads
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
    Thread.CurrentThread.Priority = ThreadPriority.Highest;

    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
    return watch.Elapsed.TotalMilliseconds;
}

Pastikan Anda menyusun dalam Rilis dengan pengoptimalan diaktifkan, dan menjalankan tes di luar Visual Studio . Bagian terakhir ini penting karena JIT menjalankan pengoptimalannya dengan debugger terpasang, bahkan dalam mode Rilis.

Sam Saffron
sumber
Anda mungkin ingin melepaskan loop beberapa kali, seperti 10, untuk meminimalkan overhead loop.
Mike Dunlavey
2
Saya baru saja memperbarui untuk menggunakan Stopwatch.StartNew. Bukan perubahan fungsional, tetapi menyimpan satu baris kode.
LukeH
1
@ Luke, perubahan besar (saya harap saya bisa memberi +1). @ Mike saya tidak yakin, saya menduga overhead panggilan virtual akan jauh lebih tinggi daripada perbandingan dan penugasan, sehingga perbedaan kinerja akan dapat diabaikan
Sam Saffron
Saya akan mengusulkan Anda untuk meneruskan hitungan iterasi ke Action, dan membuat loop di sana (mungkin - bahkan tidak digulung). Jika Anda mengukur operasi yang relatif singkat, ini adalah satu-satunya pilihan. Dan saya lebih suka melihat metrik terbalik - misalnya jumlah operan / detik.
Alex Yakunin
2
Apa pendapat Anda tentang menunjukkan waktu rata-rata. Sesuatu seperti ini: Console.WriteLine ("Average Time Elapsed {0} ms", watch.ElapsedMilliseconds / iterations);
rudimenter
22

Finalisasi belum tentu selesai sebelum GC.Collectdikembalikan. Finalisasi diantrekan dan kemudian dijalankan pada thread terpisah. Utas ini masih bisa aktif selama pengujian Anda, memengaruhi hasil.

Jika Anda ingin memastikan bahwa finalisasi telah selesai sebelum memulai pengujian, Anda mungkin ingin memanggil GC.WaitForPendingFinalizers, yang akan memblokir hingga antrian finalisasi dihapus:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
LukeH
sumber
10
Mengapa GC.Collect()sekali lagi?
colinfang
7
@colinfang Karena objek yang sedang "diselesaikan" tidak di-GC oleh finalizer. Jadi yang kedua Collectadalah untuk memastikan objek yang "diselesaikan" juga dikumpulkan.
MAV
15

Jika Anda ingin menghilangkan interaksi GC, Anda mungkin ingin menjalankan panggilan 'pemanasan' setelah panggilan GC.Collect, bukan sebelumnya. Dengan cara itu Anda tahu .NET sudah memiliki cukup memori yang dialokasikan dari OS untuk set kerja fungsi Anda.

Perlu diingat bahwa Anda membuat panggilan metode non-inline untuk setiap iterasi, jadi pastikan Anda membandingkan hal-hal yang Anda uji dengan badan kosong. Anda juga harus menerima bahwa Anda hanya dapat mengatur waktu dengan andal hal-hal yang beberapa kali lebih lama daripada panggilan metode.

Selain itu, tergantung pada jenis hal yang Anda buat profil, Anda mungkin ingin melakukan pengaturan waktu berdasarkan waktu tertentu daripada untuk sejumlah iterasi - ini cenderung mengarah ke angka yang lebih mudah dibandingkan tanpa harus memiliki jangka waktu yang sangat singkat untuk penerapan terbaik dan / atau jangka panjang untuk yang terburuk.

Jonathan Rupp
sumber
1
poin bagus, apakah Anda memiliki implementasi berbasis waktu?
Sam Saffron
6

Saya sama sekali tidak akan melewatkan delegasi:

  1. Panggilan delegasi adalah ~ panggilan metode virtual. Tidak murah: ~ 25% dari alokasi memori terkecil di .NET. Jika Anda tertarik dengan detailnya, lihat misalnya tautan ini .
  2. Delegasi anonim dapat menyebabkan penggunaan penutupan, yang bahkan tidak Anda sadari. Sekali lagi, mengakses bidang closure lebih penting daripada misalnya mengakses variabel pada stack.

Kode contoh yang mengarah ke penggunaan penutupan:

public void Test()
{
  int someNumber = 1;
  Profiler.Profile("Closure access", 1000000, 
    () => someNumber + someNumber);
}

Jika Anda tidak mengetahui tentang penutupan, lihat metode ini di .NET Reflector.

Alex Yakunin
sumber
Poin menarik, tetapi bagaimana Anda membuat metode Profile () yang dapat digunakan kembali jika Anda tidak mengirimkan delegasi? Apakah ada cara lain untuk meneruskan kode arbitrer ke suatu metode?
Abu
1
Kami menggunakan "menggunakan (Pengukuran baru (...)) {... kode terukur ...}". Jadi kita mendapatkan objek Pengukuran yang mengimplementasikan IDisposable alih-alih meneruskan delegasi. Lihat code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/…
Alex Yakunin
Ini tidak akan menyebabkan masalah apa pun dengan penutupan.
Alex Yakunin
3
@AlexYakunin: tautan Anda tampaknya rusak. Bisakah Anda menyertakan kode untuk kelas Pengukuran dalam jawaban Anda? Saya menduga bahwa tidak peduli bagaimana Anda menerapkannya, Anda tidak akan dapat menjalankan kode untuk diprofilkan beberapa kali dengan pendekatan IDisposable ini. Namun, ini memang sangat berguna dalam situasi di mana Anda ingin mengukur kinerja bagian yang berbeda dari aplikasi yang kompleks (saling terkait), selama Anda ingat bahwa pengukuran mungkin tidak akurat, dan tidak konsisten saat dijalankan pada waktu yang berbeda. Saya menggunakan pendekatan yang sama di sebagian besar proyek saya.
ShdNx
1
Persyaratan untuk menjalankan uji kinerja beberapa kali sangat penting (pemanasan + beberapa pengukuran), jadi saya juga beralih ke pendekatan dengan delegasi. Selain itu, jika Anda tidak menggunakan closure, pemanggilan delegasi lebih cepat daripada panggilan metode antarmuka dalam kasus IDisposable.
Alex Yakunin
6

Saya pikir masalah yang paling sulit diatasi dengan metode benchmarking seperti ini adalah memperhitungkan kasus-kasus edge dan hal-hal yang tidak terduga. Misalnya - "Bagaimana cara kerja dua cuplikan kode di bawah beban CPU yang tinggi / penggunaan jaringan / perontokan disk / dll." Mereka bagus untuk pemeriksaan logika dasar untuk melihat apakah algoritme tertentu bekerja secara signifikan lebih cepat daripada yang lain. Tetapi untuk menguji sebagian besar kinerja kode dengan benar, Anda harus membuat pengujian yang mengukur hambatan spesifik dari kode tertentu itu.

Saya masih mengatakan bahwa menguji blok kecil kode sering kali memiliki sedikit laba atas investasi dan dapat mendorong penggunaan kode yang terlalu rumit daripada kode sederhana yang dapat dipelihara. Menulis kode yang jelas yang dapat dipahami oleh pengembang lain, atau saya sendiri dalam 6 bulan ke depan, dapat memahami dengan cepat akan memiliki lebih banyak manfaat kinerja daripada kode yang sangat dioptimalkan.

Paul Alexander
sumber
1
signifikan adalah salah satu istilah yang benar-benar dimuat. terkadang penerapan yang 20% ​​lebih cepat itu penting, terkadang harus 100 kali lebih cepat agar signifikan. Setuju dengan Anda pada kejelasan lihat: stackoverflow.com/questions/1018407/…
Sam Saffron
Dalam hal ini signifikan tidak semua yang dimuat. Anda membandingkan satu atau beberapa implementasi bersamaan dan jika perbedaan performa dari dua implementasi tersebut tidak signifikan secara statistik, tidak ada gunanya melakukan metode yang lebih kompleks.
Paul Alexander
5

Saya akan menelepon func()beberapa kali untuk pemanasan, bukan hanya satu.

Alexey Romanov
sumber
1
Tujuannya adalah untuk memastikan kompilasi jit dilakukan, keuntungan apa yang Anda dapatkan dari memanggil func beberapa kali sebelum pengukuran?
Sam Saffron
3
Untuk memberi JIT kesempatan untuk meningkatkan hasil pertamanya.
Alexey Romanov
1
.NET JIT tidak meningkatkan hasil dari waktu ke waktu (seperti yang dilakukan Java). Ini hanya mengubah metode dari IL ke Majelis sekali, pada panggilan pertama.
Matt Warren
4

Saran untuk peningkatan

  1. Mendeteksi apakah lingkungan eksekusi baik untuk pembandingan (seperti mendeteksi jika debugger terpasang atau jika pengoptimalan jit dinonaktifkan yang akan menghasilkan pengukuran yang salah).

  2. Mengukur bagian-bagian kode secara independen (untuk melihat dengan tepat di mana letak hambatannya).

  3. Membandingkan versi / komponen / potongan kode yang berbeda (Dalam kalimat pertama Anda, Anda mengatakan '... membuat tolok ukur potongan kecil kode untuk melihat implementasi mana yang tercepat.').

Mengenai # 1:

  • Untuk mendeteksi apakah debugger dilampirkan, baca properti System.Diagnostics.Debugger.IsAttached(Ingat juga untuk menangani kasus di mana debugger awalnya tidak terpasang, tetapi dilampirkan setelah beberapa waktu).

  • Untuk mendeteksi apakah pengoptimalan jit dinonaktifkan, baca properti DebuggableAttribute.IsJITOptimizerDisableddari rakitan yang relevan:

    private bool IsJitOptimizerDisabled(Assembly assembly)
    {
        return assembly.GetCustomAttributes(typeof (DebuggableAttribute), false)
            .Select(customAttribute => (DebuggableAttribute) customAttribute)
            .Any(attribute => attribute.IsJITOptimizerDisabled);
    }

Mengenai # 2:

Ini bisa dilakukan dengan banyak cara. Salah satu caranya adalah dengan mengizinkan beberapa delegasi untuk disuplai dan kemudian mengukur delegasi tersebut secara individu.

Mengenai # 3:

Ini juga dapat dilakukan dengan banyak cara, dan kasus penggunaan yang berbeda akan menuntut solusi yang sangat berbeda. Jika tolok ukur dipanggil secara manual, maka menulis ke konsol mungkin baik-baik saja. Namun jika benchmark dilakukan secara otomatis oleh sistem build, maka menulis ke konsol mungkin tidak begitu baik.

Salah satu cara untuk melakukannya adalah dengan mengembalikan hasil benchmark sebagai objek dengan tipe kuat yang dapat dengan mudah digunakan dalam konteks yang berbeda.


Etimo.Benchmarks

Pendekatan lain adalah dengan menggunakan komponen yang ada untuk melakukan benchmark. Sebenarnya, di perusahaan saya, kami memutuskan untuk merilis alat benchmark kami ke domain publik. Pada intinya, ia mengelola pengumpul sampah, jitter, pemanasan, dll, seperti yang disarankan beberapa jawaban lain di sini. Ia juga memiliki tiga fitur yang saya sarankan di atas. Ini mengelola beberapa masalah yang dibahas dalam blog Eric Lippert .

Ini adalah contoh keluaran dimana dua komponen dibandingkan dan hasilnya dituliskan ke konsol. Dalam hal ini, dua komponen yang dibandingkan disebut 'KeyedCollection' dan 'MultiplyIndexedKeyedCollection':

Etimo.Benchmarks - Contoh Output Konsol

Ada paket NuGet , contoh paket NuGet, dan kode sumbernya tersedia di GitHub . Ada juga postingan blog .

Jika Anda sedang terburu-buru, saya sarankan Anda mendapatkan paket sampel dan cukup memodifikasi delegasi sampel sesuai kebutuhan. Jika Anda tidak sedang terburu-buru, ada baiknya membaca postingan blog untuk memahami detailnya.

Joakim
sumber
1

Anda juga harus menjalankan "pemanasan" sebelum pengukuran sebenarnya untuk mengecualikan waktu yang dihabiskan oleh compiler JIT untuk mengubah kode Anda.

Alex Yakunin
sumber
itu dilakukan sebelum pengukuran
Sam Saffron
1

Bergantung pada kode yang Anda tolok ukur dan platform yang menjalankannya, Anda mungkin perlu memperhitungkan bagaimana penyelarasan kode memengaruhi kinerja . Untuk melakukannya mungkin akan memerlukan pembungkus luar yang menjalankan pengujian beberapa kali (dalam domain aplikasi terpisah atau proses?), Beberapa kali pertama memanggil "kode padding" untuk memaksanya dikompilasi JIT, sehingga menyebabkan kode menjadi dibandingkan untuk disejajarkan secara berbeda. Hasil tes yang lengkap akan memberikan pengaturan waktu kasus terbaik dan terburuk untuk berbagai penyelarasan kode.

Edward Brey
sumber
1

Jika Anda mencoba menghilangkan dampak Pengumpulan Sampah dari benchmark lengkap, apakah itu layak untuk disetel GCSettings.LatencyMode?

Jika tidak, dan Anda ingin dampak sampah yang dibuat funcmenjadi bagian dari tolok ukur, bukankah sebaiknya Anda juga memaksa pengumpulan di akhir pengujian (di dalam pengatur waktu)?

Danny Tuppeny
sumber
0

Masalah mendasar dari pertanyaan Anda adalah asumsi bahwa satu pengukuran dapat menjawab semua pertanyaan Anda. Anda perlu mengukur beberapa kali untuk mendapatkan gambaran situasi yang efektif dan terutama dalam bahasa pengumpulan sampah seperti C #.

Jawaban lain memberikan cara yang baik untuk mengukur kinerja dasar.

static void Profile(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Namun, pengukuran tunggal ini tidak memperhitungkan pengumpulan sampah. Profil yang tepat juga menjelaskan kinerja kasus terburuk dari pengumpulan sampah yang tersebar di banyak panggilan (nomor ini tidak berguna karena VM dapat berhenti tanpa pernah mengumpulkan sampah yang tersisa tetapi masih berguna untuk membandingkan dua implementasi yang berbeda dari func.)

static void ProfileGarbageMany(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Dan Anda mungkin juga ingin mengukur kinerja kasus terburuk dari pengumpulan sampah untuk metode yang hanya dipanggil sekali.

static void ProfileGarbage(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Tetapi yang lebih penting daripada merekomendasikan pengukuran tambahan yang mungkin spesifik ke profil adalah gagasan bahwa seseorang harus mengukur beberapa statistik yang berbeda dan bukan hanya satu jenis statistik.

Steven Stewart-Gallus
sumber