Saya menulis beberapa kode untuk menguji dampak try-catch, tetapi melihat beberapa hasil yang mengejutkan.
static void Main(string[] args)
{
Thread.CurrentThread.Priority = ThreadPriority.Highest;
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;
long start = 0, stop = 0, elapsed = 0;
double avg = 0.0;
long temp = Fibo(1);
for (int i = 1; i < 100000000; i++)
{
start = Stopwatch.GetTimestamp();
temp = Fibo(100);
stop = Stopwatch.GetTimestamp();
elapsed = stop - start;
avg = avg + ((double)elapsed - avg) / i;
}
Console.WriteLine("Elapsed: " + avg);
Console.ReadKey();
}
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
return fibo;
}
Di komputer saya, ini secara konsisten mencetak nilai sekitar 0,96 ..
Ketika saya membungkus loop for di dalam Fibo () dengan blok try-catch seperti ini:
static long Fibo(int n)
{
long n1 = 0, n2 = 1, fibo = 0;
n++;
try
{
for (int i = 1; i < n; i++)
{
n1 = n2;
n2 = fibo;
fibo = n1 + n2;
}
}
catch {}
return fibo;
}
Sekarang secara konsisten mencetak 0,69 ... - itu benar-benar berjalan lebih cepat! Tapi kenapa?
Catatan: Saya mengkompilasi ini menggunakan konfigurasi Release dan langsung menjalankan file EXE (di luar Visual Studio).
EDIT: Analisis Jon Skeet yang sangat baik menunjukkan bahwa try-catch entah bagaimana menyebabkan CLR x86 menggunakan register CPU dengan cara yang lebih menguntungkan dalam kasus khusus ini (dan saya pikir kita belum mengerti mengapa). Saya mengkonfirmasi temuan Jon bahwa x64 CLR tidak memiliki perbedaan ini, dan lebih cepat daripada x86 CLR. Saya juga menguji menggunakan int
tipe di dalam metode Fibo, bukan long
tipe, dan kemudian x86 CLR sama cepatnya dengan x64 CLR.
UPDATE: Sepertinya masalah ini telah diperbaiki oleh Roslyn. Mesin yang sama, versi CLR yang sama - masalah tetap seperti di atas ketika dikompilasi dengan VS 2013, tetapi masalahnya hilang ketika dikompilasi dengan VS 2015.
Jawaban:
Salah satu insinyur Roslyn yang berspesialisasi dalam memahami optimalisasi penggunaan tumpukan melihat ini dan melaporkan kepada saya bahwa tampaknya ada masalah dalam interaksi antara cara kompiler C # menghasilkan toko variabel lokal dan cara kompiler JIT mendaftar. penjadwalan dalam kode x86 yang sesuai. Hasilnya adalah pembuatan kode suboptimal pada beban dan toko penduduk setempat.
Untuk beberapa alasan yang tidak jelas bagi kita semua, jalur pembuatan kode yang bermasalah dihindari ketika JITter tahu bahwa blok berada di wilayah yang dilindungi coba.
Ini sangat aneh. Kami akan menindaklanjuti dengan tim JITter dan melihat apakah kami dapat memasukkan bug sehingga mereka dapat memperbaikinya.
Selain itu, kami sedang mengerjakan perbaikan untuk algoritma Roslyn ke kompiler C # dan VB untuk menentukan kapan penduduk setempat dapat dibuat "sesaat" - yaitu, hanya didorong dan muncul di tumpukan, daripada mengalokasikan lokasi tertentu di tumpukan untuk durasi aktivasi. Kami percaya bahwa JITter akan dapat melakukan pekerjaan alokasi register yang lebih baik dan yang lainnya jika kami memberikan petunjuk yang lebih baik tentang kapan penduduk lokal dapat "mati" lebih awal.
Terima kasih telah membawa ini menjadi perhatian kami, dan meminta maaf atas perilaku aneh ini.
sumber
Nah, cara Anda mengatur waktu hal-hal tampak sangat buruk bagi saya. Akan jauh lebih masuk akal untuk mengatur waktu seluruh loop:
Dengan begitu Anda tidak berada di bawah kekuasaan timing kecil, aritmatika floating point dan akumulasi kesalahan.
Setelah melakukan perubahan itu, lihat apakah versi "non-tangkapan" masih lebih lambat dari versi "tangkapan".
EDIT: Oke, saya sudah mencobanya sendiri - dan saya melihat hasil yang sama. Sangat aneh. Saya bertanya-tanya apakah mencoba / menangkap itu menonaktifkan beberapa inlining yang buruk, tetapi menggunakan
[MethodImpl(MethodImplOptions.NoInlining)]
bukannya tidak membantu ...Pada dasarnya Anda harus melihat kode JITted yang dioptimalkan di bawah cordbg, saya kira ...
EDIT: Beberapa bit informasi lagi:
n++;
garis masih meningkatkan kinerja, tetapi tidak sebanyak menempatkannya di seluruh blokArgumentException
dalam pengujian saya) itu masih cepatAneh...
EDIT: Oke, kami telah membongkar ...
Ini menggunakan kompiler C # 2 dan. NET 2 (32-bit) CLR, disassembling dengan mdbg (karena saya tidak punya cordbg di komputer saya). Saya masih melihat efek kinerja yang sama, bahkan di bawah debugger. Versi cepat menggunakan
try
blok di sekitar segala sesuatu antara deklarasi variabel dan pernyataan kembali, hanya dengancatch{}
handler. Tentunya versi lambatnya sama kecuali tanpa coba / tangkap. Kode panggilan (yaitu Utama) adalah sama dalam kedua kasus, dan memiliki perwakilan perakitan yang sama (jadi ini bukan masalah inlining).Kode yang dibongkar untuk versi cepat:
Kode yang dibongkar untuk versi lambat:
Dalam setiap kasus
*
ditampilkan di mana debugger dimasukkan dalam "langkah-ke" sederhana.EDIT: Oke, saya sekarang telah melihat melalui kode dan saya pikir saya bisa melihat bagaimana setiap versi bekerja ... dan saya percaya versi lebih lambat lebih lambat karena menggunakan register lebih sedikit dan lebih banyak ruang tumpukan. Untuk nilai kecil dari
n
itu mungkin lebih cepat - tetapi ketika loop mengambil sebagian besar waktu, itu lebih lambat.Mungkin blok coba / tangkap memaksa lebih banyak register untuk disimpan dan dipulihkan, sehingga JIT juga menggunakannya untuk loop ... yang terjadi untuk meningkatkan kinerja secara keseluruhan. Tidak jelas apakah ini merupakan keputusan yang masuk akal bagi JIT untuk tidak menggunakan sebanyak mungkin register dalam kode "normal".
EDIT: Baru saja mencoba ini di mesin x64 saya. CLR x64 jauh lebih cepat (sekitar 3-4 kali lebih cepat) daripada CLR x86 pada kode ini, dan di bawah x64 blok try / catch tidak membuat perbedaan yang nyata.
sumber
esi,edi
untuk salah satu dari rindu, bukan tumpukan. Ini digunakanebx
sebagai penghitung, di mana versi lambat digunakanesi
.Disassemblies Jon menunjukkan, bahwa perbedaan antara dua versi adalah bahwa versi cepat menggunakan sepasang register (
esi,edi
) untuk menyimpan salah satu variabel lokal di mana versi lambat tidak.Kompiler JIT membuat asumsi yang berbeda mengenai register yang digunakan untuk kode yang berisi blok try-catch vs. kode yang tidak. Ini menyebabkannya membuat pilihan alokasi register yang berbeda. Dalam hal ini, ini mendukung kode dengan blok try-catch. Kode yang berbeda dapat menyebabkan efek sebaliknya, jadi saya tidak akan menganggap ini sebagai teknik percepatan tujuan umum.
Pada akhirnya, sangat sulit untuk mengetahui kode mana yang akan berjalan paling cepat. Sesuatu seperti alokasi register dan faktor-faktor yang mempengaruhinya adalah detail implementasi tingkat rendah seperti itu sehingga saya tidak melihat bagaimana teknik spesifik mana pun dapat menghasilkan kode yang lebih cepat secara andal.
Sebagai contoh, perhatikan dua metode berikut. Mereka diadaptasi dari contoh kehidupan nyata:
Yang satu adalah versi generik yang lain. Mengganti tipe generik dengan
StructArray
akan membuat metode identik. KarenaStructArray
merupakan tipe nilai, ia mendapatkan versi kompilasi sendiri dari metode generik. Namun waktu berjalan sebenarnya jauh lebih lama daripada metode khusus, tetapi hanya untuk x86. Untuk x64, timingnya hampir sama. Dalam kasus lain, saya telah mengamati perbedaan untuk x64 juga.sumber
Ini seperti kasus inlining menjadi buruk. Pada inti x86, jitter memiliki register ebx, edx, esi dan edi tersedia untuk penyimpanan tujuan umum variabel lokal. Register ecx tersedia dalam metode statis, tidak harus menyimpan ini . Register eax sering diperlukan untuk perhitungan. Tetapi ini adalah register 32-bit, untuk variabel tipe lama harus menggunakan sepasang register. Yaitu edx: eax untuk perhitungan dan edi: ebx untuk penyimpanan.
Itulah yang menonjol dalam pembongkaran untuk versi lambat, baik edi maupun ebx tidak digunakan.
Ketika jitter tidak dapat menemukan register yang cukup untuk menyimpan variabel lokal maka itu harus menghasilkan kode untuk memuat dan menyimpannya dari frame stack. Itu memperlambat kode, mencegah pengoptimalan prosesor yang dinamai "register renaming", trik pengoptimalan inti prosesor internal yang menggunakan banyak salinan register dan memungkinkan eksekusi super skalar. Yang memungkinkan beberapa instruksi untuk berjalan secara bersamaan, bahkan ketika mereka menggunakan register yang sama. Tidak memiliki register yang cukup adalah masalah umum pada core x86, dibahas di x64 yang memiliki 8 register tambahan (r9 hingga r15).
Jitter akan melakukan yang terbaik untuk menerapkan optimasi pembuatan kode lain, ia akan mencoba untuk menyatukan metode Fibo () Anda. Dengan kata lain, tidak membuat panggilan ke metode tetapi menghasilkan kode untuk metode inline dalam metode Main (). Optimasi yang cukup penting itu, untuk satu, membuat properti dari kelas C # gratis, memberi mereka perf bidang. Ini menghindari overhead membuat panggilan metode dan mengatur frame stack-nya, menghemat beberapa nanodetik.
Ada beberapa aturan yang menentukan kapan suatu metode dapat diuraikan. Mereka tidak benar-benar didokumentasikan tetapi telah disebutkan dalam posting blog. Satu aturan adalah bahwa itu tidak akan terjadi ketika tubuh metode terlalu besar. Itu mengalahkan keuntungan dari inlining, itu menghasilkan terlalu banyak kode yang tidak cocok juga di cache instruksi L1. Aturan keras lain yang berlaku di sini adalah bahwa suatu metode tidak akan diuraikan ketika berisi pernyataan coba-coba. Latar belakang di baliknya adalah detail implementasi pengecualian, mereka mendukung dukungan bawaan Windows untuk SEH (Structure Exception Handling) yang berbasis stack-frame.
Salah satu perilaku algoritma alokasi register dalam jitter dapat disimpulkan dari bermain dengan kode ini. Tampaknya menyadari ketika jitter sedang mencoba untuk sebaris metode. Satu aturan tampaknya hanya menggunakan pasangan edx: eax register yang dapat digunakan untuk kode inline yang memiliki variabel lokal bertipe panjang. Tapi tidak edi: ebx. Tidak diragukan lagi karena itu akan terlalu merusak pembuatan kode untuk metode pemanggilan, baik edi dan ebx adalah register penyimpanan yang penting.
Jadi Anda mendapatkan versi cepat karena jitter tahu di muka bahwa tubuh metode berisi pernyataan try / catch. Ia tahu itu tidak pernah bisa diuraikan jadi siap menggunakan edi: ebx untuk penyimpanan untuk variabel panjang. Anda mendapatkan versi lambat karena jitter tidak tahu di muka bahwa inlining tidak akan berfungsi. Itu hanya ditemukan setelah menghasilkan kode untuk badan metode.
Kelemahannya adalah ia tidak kembali dan menghasilkan kembali kode untuk metode ini. Ini bisa dimengerti, mengingat kendala waktu yang harus digunakan.
Perlambatan ini tidak terjadi pada x64 karena untuk yang memiliki 8 register lagi. Untuk yang lain karena bisa menyimpan lama hanya dalam satu register (seperti rax). Dan perlambatan tidak terjadi ketika Anda menggunakan int bukan panjang karena jitter memiliki lebih banyak fleksibilitas dalam memilih register.
sumber
Saya akan menempatkan ini sebagai komentar karena saya benar-benar tidak yakin bahwa ini mungkin terjadi, tetapi seingat saya itu bukan percobaan / kecuali pernyataan melibatkan modifikasi pada cara mekanisme pembuangan sampah kompiler bekerja, dalam hal itu membersihkan alokasi memori objek secara rekursif dari stack. Mungkin tidak ada objek yang akan dibersihkan dalam kasus ini atau loop for dapat merupakan penutupan bahwa mekanisme pengumpulan sampah mengakui cukup untuk menegakkan metode pengumpulan yang berbeda. Mungkin tidak, tapi saya pikir itu layak disebut karena saya belum melihatnya dibahas di tempat lain.
sumber