Menulis kode terkelola yang lebih cepat: Mengetahui Biaya Hal Apa
Jan Gray
Tim Performa MICROSOFT CLR
Juni 2003
Berlaku untuk:
Microsoft® .NET Framework
ringkasan : Artikel ini menyajikan model biaya tingkat rendah untuk waktu eksekusi kode terkelola, berdasarkan waktu operasi yang diukur, sehingga pengembang dapat membuat keputusan pengodean yang lebih baik dan menulis kode yang lebih cepat. (30 halaman cetak)
Unduh
Isi
Pengantar (dan Janji)
Menuju Model Biaya untuk Kode Terkelola
Biaya Hal Apa dalam Kode Terkelola
Kesimpulan
Sumber daya
Pengantar (dan Janji)
Ada banyak cara untuk menerapkan komputasi, dan beberapa jauh lebih baik daripada yang lain: lebih sederhana, lebih bersih, lebih mudah dipertahankan. Beberapa cara sangat cepat dan beberapa sangat lambat.
Jangan melakukan kode lambat dan gemuk di dunia. Tidakkah kau membenci kode seperti itu? Kode yang berjalan sesuai dan dimulai? Kode yang mengunci UI selama beberapa detik pada waktunya? Kode yang mematok CPU atau meronta-rontakan disk?
Jangan lakukan itu. Sebaliknya, berdiri dan berjanji bersama saya:
"Saya berjanji saya tidak akan mengirim kode lambat. Kecepatan adalah fitur yang saya pedulikan. Setiap hari saya akan memperhatikan kinerja kode saya. Saya akan secara teratur dan metodis mengukur kecepatan dan ukurannya. Saya akan belajar, membangun, atau membeli alat yang saya butuhkan untuk melakukan ini. Ini tanggung jawab saya."
(Benar-benar.) Jadi apakah Anda berjanji? Bagus untukmu.
Jadi bagaimana Anda menulis hari kode tercepat dan terketat di siang hari? Ini adalah masalah secara sadar memilih cara hemat dalam preferensi untuk pemborosan, cara kembung, lagi dan lagi, dan masalah pemikiran melalui konsekuensinya. Setiap halaman kode tertentu menangkap lusinan keputusan kecil tersebut.
Tetapi Anda tidak dapat membuat pilihan cerdas di antara alternatif jika Anda tidak tahu biaya hal-hal apa: Anda tidak dapat menulis kode yang efisien jika Anda tidak tahu biaya hal-hal apa.
Itu lebih mudah di masa lalu yang baik. Programmer C yang baik tahu. Setiap operator dan operasi di C, baik itu penetapan, bilangan bulat atau matematika floating-point, dereferensi, atau panggilan fungsi, dipetakan lebih atau kurang satu-ke-satu ke satu operasi komputer primitif. Benar, kadang-kadang beberapa instruksi mesin diperlukan untuk menempatkan operan yang tepat di register yang tepat, dan kadang-kadang satu instruksi dapat menangkap beberapa operasi C (terkenal *dest++ = *src++;
), tetapi Anda biasanya dapat menulis (atau membaca) baris kode C dan tahu ke mana waktu itu pergi. Untuk kode dan data, pengkompilasi C adalah WYWIWYG—"apa yang Anda tulis adalah apa yang Anda dapatkan". (Pengecualiannya adalah, dan adalah, panggilan fungsi. Jika Anda tidak tahu berapa biaya fungsi, Anda tidak tahu diddly.)
Pada tahun 1990-an, untuk menikmati banyak rekayasa perangkat lunak dan manfaat produktivitas abstraksi data, pemrograman berorientasi objek, dan penggunaan kembali kode, industri perangkat lunak PC melakukan transisi dari C ke C++.
C++ adalah superset C, dan "bayar sesuai penggunaan"—fitur baru tidak dikenakan biaya jika Anda tidak menggunakannya—sehingga keahlian pemrograman C, termasuk model biaya internal seseorang, berlaku langsung. Jika Anda mengambil beberapa kode C yang berfungsi dan mengkompilasi ulang untuk C++, waktu eksekusi dan overhead ruang seharusnya tidak banyak berubah.
Di sisi lain, C++ memperkenalkan banyak fitur bahasa baru, termasuk konstruktor, destruktor, baru, hapus, tunggal, beberapa dan warisan virtual, cast, fungsi anggota, fungsi virtual, operator kelebihan beban, pointer ke anggota, array objek, penanganan pengecualian, dan komposisi yang sama, yang dikenakan biaya tersembunyi yang tidak sepele. Misalnya, fungsi virtual memerlukan biaya dua tidak langsung tambahan per panggilan, dan menambahkan bidang penunjuk vtable tersembunyi ke setiap instans. Atau pertimbangkan bahwa kode yang terlihat tidak berbahaya ini:
{ complex a, b, c, d; … a = b + c * d; }
dikompilasi menjadi sekitar tiga belas panggilan fungsi anggota implisit (mudah-mudahan inlined).
Sembilan tahun yang lalu kami menjelajahi subjek ini dalam artikel saya C++: Under the Hood. Saya menulis:
"Penting untuk memahami bagaimana bahasa pemrograman Anda diterapkan. Pengetahuan seperti itu menghilangkan rasa takut dan keajaiban "Apa yang dilakukan kompilator di sini?"; menunjukkan keyakinan untuk menggunakan fitur baru; dan memberikan wawasan saat men-debug dan mempelajari fitur bahasa lain. Ini juga memberikan nuansa untuk biaya relatif dari berbagai pilihan pengkodean yang diperlukan untuk menulis kode yang paling efisien dari hari ke hari."
Sekarang kita akan melihat kode terkelola yang sama. Artikel ini mengeksplorasi biaya waktu dan ruang rendah dari eksekusi terkelola, jadi kami dapat membuat tradeoff yang lebih cerdas dalam pengkodan sehari-hari.
Dan menepati janji kita.
Mengapa Kode Terkelola?
Untuk sebagian besar pengembang kode asli, kode terkelola adalah platform yang lebih baik dan lebih produktif untuk menjalankan perangkat lunak mereka. Ini menghapus seluruh kategori bug, seperti kerusakan tumpukan dan kesalahan array-index-out-of-bound yang sering menyebabkan sesi debugging larut malam yang membuat frustrasi. Ini mendukung persyaratan modern seperti kode seluler yang aman (melalui keamanan akses kode) dan layanan Web XML, dan dibandingkan dengan win32/COM/ATL/MFC/VB yang menua, .NET Framework adalah desain slate bersih yang menyegarkan, di mana Anda bisa menyelesaikan lebih banyak dengan lebih sedikit upaya.
Untuk komunitas pengguna Anda, kode terkelola memungkinkan aplikasi yang lebih kaya dan lebih kuat—hidup lebih baik melalui perangkat lunak yang lebih baik.
Apa Rahasia untuk Menulis Kode Terkelola yang Lebih Cepat?
Hanya karena Anda bisa menyelesaikan lebih sedikit upaya bukanlah lisensi untuk mendedikasikan tanggung jawab Anda untuk membuat kode dengan bijaksana. Pertama, Anda harus mengakuinya pada diri sendiri: "Saya seorang pemula." Kau pemula. Aku juga seorang pemula. Kita semua babes di tanah kode terkelola. Kita semua masih mempelajari talinya—termasuk hal-hal apa yang harus dikuras.
Ketika datang ke .NET Framework yang kaya dan nyaman, itu seperti kita anak-anak di toko permen. "Wow, aku tidak perlu melakukan semua hal strncpy
yang melelahkan itu, aku hanya bisa '+' string bersama-sama! Wow, aku bisa memuat megabyte XML dalam beberapa baris kode! Whoo-hoo!"
Semuanya begitu mudah. Sangat mudah, memang. Sangat mudah untuk membakar megabyte ram penguraian xml infoset hanya untuk menarik beberapa elemen dari mereka. Di C atau C++ itu sangat menyakitkan Anda akan berpikir dua kali, mungkin Anda akan membangun mesin status pada beberapa API seperti SAX. Dengan .NET Framework, Anda hanya memuat seluruh infoset dalam satu gulp. Mungkin kau bahkan melakukannya berulang kali. Kemudian mungkin aplikasi Anda tampaknya tidak begitu cepat lagi. Mungkin memiliki seperangkat megabyte yang berfungsi. Mungkin Anda harus berpikir dua kali tentang apa yang metode mudah biaya ...
Sayangnya, menurut saya, dokumentasi .NET Framework saat ini tidak cukup merinci implikasi performa jenis dan metode Kerangka Kerja—bahkan tidak menentukan metode mana yang mungkin membuat objek baru. Pemodelan performa bukanlah subjek yang mudah ditutupi atau didokumen; tetapi tetap saja, "tidak mengetahui" membuatnya jauh lebih sulit bagi kita untuk membuat keputusan berdasarkan informasi.
Karena kita semua pemula di sini, dan karena kita tidak tahu biaya apa pun, dan karena biayanya tidak didokumenkan dengan jelas, apa yang harus kita lakukan?
Ukurlah. Rahasianya adalah mengukurnya dan waspada. Kita semua harus masuk ke kebiasaan mengukur biaya hal-hal. Jika kita pergi ke kesulitan mengukur biaya hal-hal apa, maka kita tidak akan menjadi orang-orang yang secara tidak sengaja memanggil metode baru whizzy yang biaya sepuluh kali lipat apa yang kita diasumsikan biayanya.
(By the way, untuk mendapatkan wawasan yang lebih dalam tentang performa yang mendasar BCL (pustaka kelas dasar) atau CLR itu sendiri, pertimbangkan untuk melihat Shared Source CLI, alias Rotor. Kode rotor berbagi garis darah dengan .NET Framework dan CLR. Ini bukan kode yang sama di seluruh, tetapi meskipun demikian, saya berjanji bahwa studi bijaksana Rotor akan memberi Anda wawasan baru tentang terjadi di bawah kap CLR. Tetapi pastikan untuk meninjau lisensi SSCLI terlebih dahulu!)
Pengetahuan
Jika Anda ber cita-cita menjadi sopir taksi di London, Anda harus terlebih dahulu mendapatkan The Knowledge. Siswa belajar selama berbulan-bulan untuk menghafal ribuan jalan kecil di London dan mempelajari rute terbaik dari tempat ke tempat. Dan mereka pergi keluar setiap hari pada skuter untuk mengintai sekitar dan memperkuat pembelajaran buku mereka.
Demikian pula, jika Anda ingin menjadi pengembang kode terkelola berkinerja tinggi, Anda harus memperoleh Pengetahuan Kode Terkelola. Anda harus mempelajari biaya setiap operasi tingkat rendah. Anda harus mempelajari fitur seperti delegasi dan biaya keamanan akses kode. Anda harus mempelajari biaya jenis dan metode yang Anda gunakan, dan yang Anda tulis. Dan tidak ada salahnya untuk menemukan metode mana yang mungkin terlalu mahal untuk aplikasi Anda—sehingga hindari metode tersebut.
Pengetahuan tidak ada dalam buku apa pun, sayangnya. Anda harus keluar skuter Anda dan jelajahi —yaitu, crank up csc, ildasm, debugger VS.NET, CLR Profiler, profiler Anda, beberapa timer perf, dan sebagainya, dan lihat apa biaya kode Anda dalam waktu dan ruang.
Menuju Model Biaya untuk Kode Terkelola
Selain itu, mari kita pertimbangkan model biaya untuk kode terkelola. Dengan begitu Anda akan dapat melihat metode daun dan mengetahui sekilas ekspresi dan pernyataan mana yang lebih mahal; dan Anda akan dapat membuat pilihan yang lebih cerdas saat Menulis kode baru.
(Ini tidak akan mengatasi biaya transitif untuk memanggil metode atau metode .NET Framework Anda. Itu harus menunggu artikel lain di hari lain.)
Sebelumnya saya menyatakan bahwa sebagian besar model biaya C masih berlaku dalam skenario C++. Demikian pula, sebagian besar model biaya C/C++ masih berlaku untuk kode terkelola.
Bagaimana bisa begitu? Kau tahu model eksekusi CLR. Anda menulis kode Anda dalam salah satu dari beberapa bahasa. Anda mengkompilasinya ke format CIL (Common Intermediate Language), yang dikemas ke dalam rakitan. Anda menjalankan rakitan aplikasi utama, dan mulai menjalankan CIL. Tapi bukankah itu urutan besarnya lebih lambat, seperti penerjemah bytecode tua?
Pengkompilasi Just-in-Time
Tidak, itu tidak. CLR menggunakan pengkompilasi JIT (just-in-time) untuk mengkompilasi setiap metode dalam CIL ke dalam kode x86 asli dan kemudian menjalankan kode asli. Meskipun ada penundaan kecil untuk kompilasi JIT dari setiap metode seperti yang pertama kali disebut, setiap metode yang disebut menjalankan kode asli murni tanpa overhead interpretif.
Tidak seperti proses kompilasi C++ off-line tradisional, waktu yang dihabiskan dalam kompilator JIT adalah penundaan "waktu jam dinding", di setiap wajah pengguna, sehingga pengompilasi JIT tidak memiliki kemewahan pengoptimalan lengkap berlalu. Meskipun demikian, daftar pengoptimalan yang dilakukan kompilator JIT sangat mengesankan:
- Pelipatan konstanta
- Propagasi konstanta dan salin
- Eliminasi subekspresi umum
- Gerakan kode invarian perulangan
- Penyimpanan mati dan penghapusan kode mati
- Mendaftarkan alokasi
- Metode inlining
- Unrolling perulangan (perulangan kecil dengan tubuh kecil)
Hasilnya sebanding dengan kode asli tradisional—setidaknya di ballpark yang sama.
Sedangkan untuk data, Anda akan menggunakan campuran jenis nilai atau jenis referensi. Jenis nilai, termasuk jenis integral, jenis titik mengambang, enum, dan struktur, biasanya hidup di tumpukan. Mereka sama kecil dan cepat seperti lokal dan struktur berada di C/C++. Seperti halnya C/C++, Anda mungkin harus menghindari melewati struktur besar sebagai argumen metode atau mengembalikan nilai, karena overhead penyalinan bisa sangat mahal.
Jenis referensi dan jenis nilai berkotak hidup di timbunan. Mereka ditangani oleh referensi objek, yang hanya penunjuk mesin seperti penunjuk objek di C/C++.
Jadi kode terkelola jitted bisa cepat. Dengan beberapa pengecualian yang kita bahas di bawah ini, jika Anda memiliki nuansa usus untuk biaya beberapa ekspresi dalam kode C asli, Anda tidak akan salah memodelkan biayanya setara dalam kode terkelola.
Saya juga harus menyebutkan NGEN, alat yang "ahead-of-time" mengkompilasi CIL ke dalam rakitan kode asli. Meskipun NGEN'ing rakitan Anda saat ini tidak memiliki dampak besar (baik atau buruk) pada waktu eksekusi, NGEN dapat mengurangi total set kerja untuk rakitan bersama yang dimuat ke dalam banyak AppDomain dan proses. (OS dapat berbagi satu salinan kode NGEN di semua klien; sedangkan kode jitted biasanya tidak dibagikan di seluruh AppDomains atau proses. Tapi lihat juga LoaderOptimizationAttribute.MultiDomain
.)
Manajemen Memori Otomatis
Keberangkatan kode terkelola yang paling signifikan (dari asli) adalah manajemen memori otomatis. Anda mengalokasikan objek baru, tetapi pengumpul sampah CLR (GC) secara otomatis membebaskannya untuk Anda ketika mereka menjadi tidak dapat dijangkau. GC berjalan sekarang dan lagi, seringkali tidak jelas, umumnya menghentikan aplikasi Anda hanya selama satu atau dua milidetik—kadang-kadang lebih lama.
Beberapa artikel lain membahas implikasi kinerja pengumpul sampah dan kami tidak akan merekapitulasinya di sini. Jika aplikasi Anda mengikuti rekomendasi dalam artikel lain ini, biaya keseluruhan pengumpulan sampah dapat tidak signifikan, beberapa persen dari waktu eksekusi, kompetitif dengan atau lebih unggul dari objek C++ tradisional new
dan delete
. Biaya pembuatan yang diamortisasi dan kemudian secara otomatis mengklaim ulang objek cukup rendah sehingga Anda dapat membuat puluhan juta objek kecil per detik.
Tetapi alokasi objek masih belum gratis. Objek memakan ruang. Alokasi objek yang merajalela menyebabkan siklus pengumpulan sampah yang lebih sering.
Jauh lebih buruk, tidak perlu mempertahankan referensi ke grafik objek yang tidak berguna membuatnya tetap hidup. Kami terkadang melihat program sederhana dengan set kerja 100+ MB yang dapat diratakan, yang penulisnya menolak kuulpbilitasnya dan sebaliknya mengaitkan performa mereka yang buruk dengan beberapa masalah misterius, tidak dikenal (dan karenanya tidak dapat ditarik) dengan kode terkelola itu sendiri. Ini tragis. Tetapi kemudian studi satu jam dengan CLR Profiler dan perubahan pada beberapa baris kode memotong penggunaan timbunan mereka dengan faktor sepuluh atau lebih. Jika Anda menghadapi masalah set kerja yang besar, langkah pertama adalah melihat di cermin.
Jadi jangan membuat objek yang tidak perlu. Hanya karena manajemen memori otomatis menghilangkan banyak kompleksitas, kerumitan, dan bug alokasi dan pembebasan objek, karena sangat cepat dan sangat nyaman, kita secara alami cenderung membuat semakin banyak objek, seolah-olah mereka tumbuh di pohon. Jika Anda ingin menulis kode terkelola yang sangat cepat, buat objek dengan bijaksana dan tepat.
Ini juga berlaku untuk desain API. Dimungkinkan untuk merancang jenis dan metodenya sehingga mereka mengharuskan klien untuk membuat objek baru dengan wild abandon. Jangan lakukan itu.
Biaya Hal Apa dalam Kode Terkelola
Sekarang mari kita pertimbangkan biaya waktu berbagai operasi kode terkelola tingkat rendah.
Tabel 1 menyajikan perkiraan biaya berbagai operasi kode terkelola tingkat rendah, dalam nanodetik, pada PC quiescent 1,1 GHz Pentium-III yang menjalankan Windows XP dan .NET Framework v1.1 ("Everett"), yang dikumpulkan dengan serangkaian perulangan waktu sederhana.
Driver pengujian memanggil setiap metode pengujian, menentukan sejumlah iterasi yang akan dilakukan, secara otomatis diskalakan untuk melakukan iterasi antara 218 dan 230 iterasi, seperlunya untuk melakukan setiap pengujian setidaknya selama 50 md. Secara umum, ini cukup lama untuk mengamati beberapa siklus pengumpulan sampah generasi 0 dalam pengujian yang melakukan alokasi objek intens. Tabel menunjukkan hasil rata-rata lebih dari 10 uji coba, serta uji coba terbaik (waktu minimum) untuk setiap subjek pengujian.
Setiap perulangan pengujian tidak terdaftar 4 hingga 64 kali seperlunya untuk mengurangi overhead perulangan pengujian. Saya memeriksa kode asli yang dihasilkan untuk setiap pengujian untuk memastikan pengkompilasi JIT tidak mengoptimalkan pengujian—misalnya, dalam beberapa kasus saya memodifikasi pengujian untuk menjaga hasil menengah tetap hidup selama dan setelah perulangan pengujian. Demikian pula saya membuat perubahan untuk menghalangi eliminasi subekspresi umum dalam beberapa pengujian.
Tabel 1 Waktu Primitif (rata-rata dan minimum) (ns)
Avg | Min | Primitif | Avg | Min | Primitif | Avg | Min | Primitif |
---|---|---|---|---|---|---|---|---|
0.0 | 0.0 | Menguasai | 2.6 | 2.6 | valtype L1 baru | 0.8 | 0.8 | isinst up 1 |
1.0 | 1.0 | Tambahkan int | 4.6 | 4.6 | valtype L2 baru | 0.8 | 0.8 | isinst down 0 |
1.0 | 1.0 | Int sub | 6.4 | 6.4 | valtype L3 baru | 6.3 | 6.3 | isinst down 1 |
2.7 | 2.7 | Int mul | 8.0 | 8.0 | valtype L4 baru | 10.7 | 10.6 | isinst (naik 2) turun 1 |
35.9 | 35.7 | Int div | 23.0 | 22.9 | valtype L5 baru | 6.4 | 6.4 | isinst down 2 |
2.1 | 2.1 | Pergeseran int | 22.0 | 20.3 | reftype L1 baru | 6.1 | 6.1 | isinst down 3 |
2.1 | 2.1 | penambahan panjang | 26.1 | 23.9 | reftype L2 baru | 1.0 | 1.0 | dapatkan bidang |
2.1 | 2.1 | sub panjang | 30.2 | 27.5 | reftype L3 baru | 1.2 | 1.2 | dapatkan prop |
34.2 | 34.1 | panjang mul | 34.1 | 30.8 | reftype L4 baru | 1.2 | 1.2 | set bidang |
50.1 | 50.0 | div panjang | 39.1 | 34.4 | reftype baru L5 | 1.2 | 1.2 | set prop |
5.1 | 5.1 | pergeseran panjang | 22.3 | 20.3 | ctor kosong reftype baru L1 | 0.9 | 0.9 | dapatkan bidang ini |
1.3 | 1.3 | penambahan float | 26.5 | 23.9 | ctor kosong reftype baru L2 | 0.9 | 0.9 | dapatkan prop ini |
1.4 | 1.4 | sub float | 38.1 | 34.7 | ctor kosong reftype baru L3 | 1.2 | 1.2 | atur bidang ini |
2.0 | 2.0 | float mul | 34.7 | 30.7 | ctor kosong reftype baru L4 | 1.2 | 1.2 | atur prop ini |
27.7 | 27.6 | float div | 38.5 | 34.3 | ctor kosong reftype baru L5 | 6.4 | 6.3 | dapatkan prop virtual |
1.5 | 1.5 | tambahkan ganda | 22.9 | 20.7 | ctor reftype baru L1 | 6.4 | 6.3 | atur prop virtual |
1.5 | 1.5 | sub ganda | 27.8 | 25.4 | ctor reftype baru L2 | 6.4 | 6.4 | write barrier |
2.1 | 2.0 | mul ganda | 32.7 | 29.9 | ctor reftype baru L3 | 1.9 | 1.9 | muat elem array int |
27.7 | 27.6 | div ganda | 37.7 | 34.1 | ctor reftype baru L4 | 1.9 | 1.9 | simpan int array elem |
0.2 | 0.2 | panggilan statis inlined | 43.2 | 39.1 | ctor reftype baru L5 | 2.5 | 2.5 | load obj array elem |
6.1 | 6.1 | panggilan statis | 28.6 | 26.7 | ctor reftype baru no-inl L1 | 16.0 | 16.0 | simpan array obj elem |
1.1 | 1.0 | panggilan instans inlined | 38.9 | 36.5 | ctor reftype baru no-inl L2 | 29.0 | 21.6 | int kotak |
6.8 | 6.8 | panggilan instans | 50.6 | 47.7 | ctor reftype baru no-inl L3 | 3.0 | 3.0 | batalkan kotak masuk |
0.2 | 0.2 | inlined panggilan inst ini | 61.8 | 58.2 | ctor reftype baru no-inl L4 | 41.1 | 40.9 | delegasikan pemanggilan |
6.2 | 6.2 | panggilan instans ini | 72.6 | 68.5 | ctor reftype baru no-inl L5 | 2.7 | 2.7 | jumlah array 1000 |
5.4 | 5.4 | panggilan virtual | 0.4 | 0.4 | cast up 1 | 2.8 | 2.8 | jumlah array 10000 |
5.4 | 5.4 | panggilan virtual ini | 0.3 | 0.3 | cast down 0 | 2.9 | 2.8 | jumlah array 100000 |
6.6 | 6.5 | panggilan antarmuka | 8.9 | 8.8 | cast down 1 | 5.6 | 5.6 | jumlah array 1000000 |
1.1 | 1.0 | panggilan instans inst itf | 9.8 | 9.7 | cast (naik 2) turun 1 | 3.5 | 3.5 | daftar jumlah 1000 |
0.2 | 0.2 | panggilan instans itf ini | 8.9 | 8.8 | cast down 2 | 6.1 | 6.1 | daftar jumlah 10000 |
5.4 | 5.4 | panggilan virtual inst itf | 8.7 | 8.6 | cast down 3 | 22.0 | 22.0 | daftar jumlah 100000 |
5.4 | 5.4 | panggilan virtual itf ini | 21.5 | 21.4 | daftar jumlah 1000000 |
Penafian: jangan mengambil data ini terlalu harfiah. Pengujian waktu penuh dengan berbahayanya efek urutan kedua yang tidak terduga. Kemungkinan terjadinya mungkin menempatkan kode jitted, atau beberapa data penting, sehingga mencakup baris cache, mengganggu sesuatu yang lain, atau apa yang anda miliki. Ini sedikit seperti Prinsip Ketidakpastian: perbedaan waktu dan waktu 1 nanodetik atau lebih berada pada batas yang dapat diamati.
Penafian lain: data ini hanya berkaitan dengan skenario kode kecil dan data yang sepenuhnya sesuai dalam cache. Jika bagian "panas" dari aplikasi Anda tidak cocok dalam cache on-chip, Anda mungkin memiliki serangkaian tantangan performa yang berbeda. Kami memiliki lebih banyak untuk dikatakan tentang cache di dekat akhir kertas.
Namun penafian lain: salah satu manfaat luhur pengiriman komponen dan aplikasi Anda sebagai rakitan CIL adalah bahwa program Anda dapat secara otomatis menjadi lebih cepat setiap detik, dan menjadi lebih cepat setiap tahun—"lebih cepat setiap detik" karena runtime dapat (secara teori) menyetel ulang kode yang dikompilasi JIT saat program Anda berjalan; dan "tahun yang lebih cepat" karena dengan setiap rilis baru runtime, algoritma yang lebih baik, lebih cerdas, lebih cepat dapat mengambil tusukan baru dalam mengoptimalkan kode Anda. Jadi jika beberapa waktu ini tampaknya kurang optimal di .NET 1.1, berhati-hatilah bahwa mereka harus meningkat dalam rilis produk berikutnya. Ini mengikuti bahwa urutan kode asli kode yang diberikan yang dilaporkan dalam artikel ini dapat berubah dalam rilis .NET Framework di masa mendatang.
Selain penafian, data memang memberikan nuansa usus yang wajar untuk performa berbagai primitif saat ini. Angka-angkanya masuk akal, dan mereka menegaskan pernyataan saya bahwa sebagian besar kode terkelola jitted berjalan "dekat dengan mesin" seperti kode asli yang dikompilasi. Operasi bilangan bulat dan mengambang primitif cepat, panggilan metode dari berbagai jenis kurang, tetapi (percayalah) masih sebanding dengan C/C++asli; namun kita juga melihat bahwa beberapa operasi yang biasanya murah dalam kode asli (transmisi, array dan penyimpanan bidang, penunjuk fungsi (delegasi)) sekarang lebih mahal. Mengapa? Ayo kita lihat.
Operasi Aritmatika
Tabel 2 Waktu Operasi Aritmatika (ns)
Avg | Min | Primitif | Avg | Min | Primitif |
---|---|---|---|---|---|
1.0 | 1.0 | tambahkan int | 1.3 | 1.3 | penambahan float |
1.0 | 1.0 | int sub | 1.4 | 1.4 | sub float |
2.7 | 2.7 | int mul | 2.0 | 2.0 | float mul |
35.9 | 35.7 | int div | 27.7 | 27.6 | float div |
2.1 | 2.1 | pergeseran int | |||
2.1 | 2.1 | penambahan panjang | 1.5 | 1.5 | tambahkan ganda |
2.1 | 2.1 | sub panjang | 1.5 | 1.5 | sub ganda |
34.2 | 34.1 | panjang mul | 2.1 | 2.0 | mul ganda |
50.1 | 50.0 | div panjang | 27.7 | 27.6 | div ganda |
5.1 | 5.1 | pergeseran panjang |
Pada masa lalu, matematika floating-point mungkin urutan besarnya lebih lambat daripada matematika bilangan bulat. Seperti yang ditunjukkan Tabel 2, dengan unit floating-point modern yang disalurkan, tampaknya ada sedikit atau tidak ada perbedaan. Sungguh luar biasa untuk berpikir PC notebook rata-rata adalah mesin kelas gigaflop sekarang (untuk masalah yang cocok dalam cache).
Mari kita lihat baris kode jitted dari bilangan bulat dan floating point add tests:
Disassembly 1 Menambahkan dan mengambang menambahkan
int add a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10 mov edx,dword ptr [esp+10h]
00000050 03 54 24 14 add edx,dword ptr [esp+14h]
00000054 03 54 24 18 add edx,dword ptr [esp+18h]
00000058 03 54 24 1C add edx,dword ptr [esp+1Ch]
0000005c 03 54 24 20 add edx,dword ptr [esp+20h]
00000060 03 D5 add edx,ebp
00000062 03 D6 add edx,esi
00000064 03 D3 add edx,ebx
00000066 03 D7 add edx,edi
00000068 89 54 24 10 mov dword ptr [esp+10h],edx
float add i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld dword ptr ds:[003E6138h]
0000001c D8 05 3C 61 3E 00 fadd dword ptr ds:[003E613Ch]
00000022 D8 05 40 61 3E 00 fadd dword ptr ds:[003E6140h]
00000028 D8 05 44 61 3E 00 fadd dword ptr ds:[003E6144h]
0000002e D8 05 48 61 3E 00 fadd dword ptr ds:[003E6148h]
00000034 D8 05 4C 61 3E 00 fadd dword ptr ds:[003E614Ch]
0000003a D8 05 50 61 3E 00 fadd dword ptr ds:[003E6150h]
00000040 D8 05 54 61 3E 00 fadd dword ptr ds:[003E6154h]
00000046 D8 05 58 61 3E 00 fadd dword ptr ds:[003E6158h]
0000004c D9 1D 58 61 3E 00 fstp dword ptr ds:[003E6158h]
Di sini kita melihat kode jitted mendekati optimal. Dalam kasus int add
, kompilator bahkan mendaftarkan lima variabel lokal. Dalam kasus penambahan float, saya berkewajiban untuk membuat variabel a
melalui statis kelas h
untuk mengalahkan eliminasi subekspresi umum.
Panggilan Metode
Di bagian ini kami memeriksa biaya dan implementasi panggilan metode. Subjek pengujian adalah kelas T
menerapkan antarmuka I
, dengan berbagai jenis metode. Lihat Daftar 1.
Mencantumkan 1 Metode metode uji
interface I { void itf1();… void itf5();… }
public class T : I {
static bool falsePred = false;
static void dummy(int a, int b, int c, …, int p) { }
static void inl_s1() { } … static void s1() { if (falsePred) dummy(1, 2, 3, …, 16); } … void inl_i1() { } … void i1() { if (falsePred) dummy(1, 2, 3, …, 16); } … public virtual void v1() { } … void itf1() { } … virtual void itf5() { } …}
Pertimbangkan Tabel 3. Ini muncul, untuk perkiraan pertama, metode berbaris (abstraksi tidak dikenakan biaya) atau tidak (biaya abstraksi >5X operasi bilangan bulat). Tampaknya tidak ada perbedaan signifikan dalam biaya mentah panggilan statis, panggilan instans, panggilan virtual, atau panggilan antarmuka.
Tabel 3 Waktu Panggilan Metode (ns)
Avg | Min | Primitif | Penerima Panggilan | Avg | Min | Primitif | Penerima Panggilan |
---|---|---|---|---|---|---|---|
0.2 | 0.2 | panggilan statis inlined | inl_s1 |
5.4 | 5.4 | panggilan virtual | v1 |
6.1 | 6.1 | panggilan statis | s1 |
5.4 | 5.4 | panggilan virtual ini | v1 |
1.1 | 1.0 | panggilan instans inlined | inl_i1 |
6.6 | 6.5 | panggilan antarmuka | itf1 |
6.8 | 6.8 | panggilan instans | i1 |
1.1 | 1.0 | panggilan instans inst itf | itf1 |
0.2 | 0.2 | inlined panggilan inst ini | inl_i1 |
0.2 | 0.2 | panggilan instans itf ini | itf1 |
6.2 | 6.2 | panggilan instans ini | i1 |
5.4 | 5.4 | panggilan virtual inst itf | itf5 |
5.4 | 5.4 | panggilan virtual itf ini | itf5 |
Namun, hasil ini tidak terwakili kasus terbaik, efek menjalankan perulangan waktu yang ketat jutaan kali. Dalam kasus pengujian ini, situs panggilan metode virtual dan antarmuka bersifat monomorfik (misalnya per situs panggilan, metode target tidak berubah dari waktu ke waktu), sehingga kombinasi penembolokan metode virtual dan mekanisme pengiriman metode antarmuka (tabel metode dan petunjuk peta antarmuka dan entri) dan prediksi cabang yang disediakan secara spektakuler memungkinkan prosesor untuk melakukan panggilan pekerjaan yang efektif secara tidak realistis melalui ini jika tidak sulit diprediksi, cabang tergantung data. Dalam praktiknya, cache data meleset pada salah satu data mekanisme pengiriman, atau kesalahan prasangka cabang (baik itu kehilangan kapasitas wajib atau situs panggilan polimorfik), dapat dan akan memperlambat panggilan virtual dan antarmuka oleh puluhan siklus.
Mari kita lihat lebih dekat pada setiap waktu panggilan metode ini.
Dalam kasus pertama, panggilan statis sebaris, kami memanggil serangkaian metode statis kosong s1_inl()
dll. Karena pengkompilasi benar-benar sebaris dari semua panggilan, kita akhirnya mengatur waktu perulangan kosong.
Untuk mengukur perkiraan biaya panggilan metode statis , kami membuat metode statis s1()
dll. sangat besar sehingga tidak dapat diprofitkan untuk sebaris ke dalam pemanggil.
Amati kita bahkan harus menggunakan variabel predikat palsu eksplisit falsePred
. Jika kita menulis
static void s1() { if (false) dummy(1, 2, 3, …, 16); }
pengkompilasi JIT akan menghilangkan panggilan mati ke dummy
dan sebaris seluruh isi metode (sekarang kosong) seperti sebelumnya. Ngomong-ngomong, di sini beberapa waktu panggilan 6,1 ns harus dikaitkan dengan tes predikat (salah) dan melompat dalam metode statis yang disebut s1
. (By the way, cara yang lebih baik untuk menonaktifkan inlining adalah atribut CompilerServices.MethodImpl(MethodImplOptions.NoInlining)
.)
Pendekatan yang sama digunakan untuk panggilan instans inlined dan waktu panggilan instans reguler. Namun, karena spesifikasi bahasa C# memastikan bahwa setiap panggilan pada referensi objek null melempar NullReferenceException, setiap situs panggilan harus memastikan bahwa instans tidak null. Ini dilakukan dengan mendereferensikan referensi instans; jika null, itu akan menghasilkan kesalahan yang diubah menjadi pengecualian ini.
Di Disassembly 2 kita menggunakan variabel statis t
sebagai instans, karena ketika kita menggunakan variabel lokal
T t = new T();
pengkompilasi mengikat pemeriksaan instans null dari perulangan.
situs panggilan metode Instans Disassembly 2 dengan instans null "periksa"
t.i1();
00000012 8B 0D 30 21 A4 05 mov ecx,dword ptr ds:[05A42130h]
00000018 39 09 cmp dword ptr [ecx],ecx
0000001a E8 C1 DE FF FF call FFFFDEE0
Kasus menginlinkan panggilan instans ini dan panggilan instans ini sama, kecuali instansnya this
; di sini pemeriksaan null telah dipantulkan.
Pembongkaran 3 Metode instans ini memanggil situs
this.i1();
00000012 8B CE mov ecx,esi
00000014 E8 AF FE FF FF call FFFFFEC8
Panggilan metode virtual berfungsi seperti dalam implementasi C++ tradisional. Alamat setiap metode virtual yang baru diperkenalkan disimpan dalam slot baru dalam tabel metode jenis. Setiap tabel metode jenis turunan sesuai dengan dan memperluas jenis dasarnya, dan setiap penggantian metode virtual menggantikan alamat metode virtual jenis dasar dengan alamat metode virtual jenis turunan di slot yang sesuai dalam tabel metode jenis turunan.
Di situs panggilan, panggilan metode virtual menimbulkan dua beban tambahan dibandingkan dengan panggilan instans, satu untuk mengambil alamat tabel metode (selalu ditemukan di *(this+0)
), dan yang lain untuk mengambil alamat metode virtual yang sesuai dari tabel metode dan memanggilnya. Lihat Pembongkaran 4.
situs panggilan metode virtual Disassembly 4
this.v1();
00000012 8B CE mov ecx,esi
00000014 8B 01 mov eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38 call dword ptr [eax+38h] ; fetch/call method address
Akhirnya, kita datang ke metode antarmuka memanggil (Disassembly 5). Ini tidak setara dengan C++. Jenis tertentu dapat mengimplementasikan sejumlah antarmuka, dan setiap antarmuka secara logis memerlukan tabel metodenya sendiri. Untuk mengirimkan metode antarmuka, kami mencari tabel metode, peta antarmukanya, entri antarmuka di peta tersebut, lalu memanggil secara tidak langsung melalui entri yang sesuai di bagian antarmuka tabel metode.
situs panggilan metode Antarmuka 5 Pembongkaran
i.itf1();
00000012 8B 0D 34 21 A4 05 mov ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01 mov eax,dword ptr [ecx] ; method table addr
0000001a 8B 40 0C mov eax,dword ptr [eax+0Ch] ; interface map addr
0000001d 8B 40 7C mov eax,dword ptr [eax+7Ch] ; itf method table addr
00000020 FF 10 call dword ptr [eax] ; fetch/call meth addr
Sisa waktu primitif, panggilan instans itf inst, panggilan instans itf ini, inst itf panggilan virtual, panggilan virtual itf ini menyoroti gagasan bahwa setiap kali metode jenis turunan mengimplementasikan metode antarmuka, itu tetap dapat dipanggil melalui situs panggilan metode instans.
Misalnya, untuk pengujian panggilan instans itf ini, panggilan pada implementasi metode antarmuka melalui referensi instans (bukan antarmuka), metode antarmuka berhasil di-inlin dan biayanya mencapai 0 ns. Bahkan implementasi metode antarmuka berpotensi sebaris ketika Anda menyebutnya sebagai metode instans.
Panggilan ke Metode Belum di-Jitted
Untuk panggilan metode statis dan instans (tetapi bukan panggilan metode virtual dan antarmuka), kompilator JIT saat ini menghasilkan urutan panggilan metode yang berbeda tergantung pada apakah metode target telah di-jitted pada saat situs panggilannya dihentikan.
Jika callee (metode target) belum di-jitted, compiler memancarkan panggilan secara tidak langsung melalui pointer yang pertama kali diinisialisasi dengan "prejit stub". Panggilan pertama pada metode target tiba di stub, yang memicu kompilasi JIT dari metode, menghasilkan kode asli, dan memperbarui pointer untuk mengatasi kode asli baru.
Jika callee telah di-jitted, alamat kode aslinya diketahui sehingga pengkompilasi memancarkan panggilan langsung ke dalamnya.
Pembuatan Objek Baru
Pembuatan objek baru terdiri dari dua fase: alokasi objek dan inisialisasi objek.
Untuk jenis referensi, objek dialokasikan pada tumpukan sampah yang dikumpulkan. Untuk jenis nilai, baik stack-resident atau disematkan dalam referensi atau jenis nilai lain, objek jenis nilai ditemukan di beberapa offset konstan dari struktur penutup—tidak diperlukan alokasi.
Untuk objek jenis referensi kecil yang khas, alokasi timbunan sangat cepat. Setelah setiap pengumpulan sampah, kecuali di hadapan objek yang disematkan, objek langsung dari tumpukan generasi 0 dipadatkan dan dipromosikan ke generasi 1, dan jadi alokator memori memiliki arena memori bebas berdekatan besar yang bagus untuk dikerjakan. Sebagian besar alokasi objek hanya dikenakan pemeriksaan kenaikan dan batas pointer, yang lebih murah daripada alokator daftar gratis C/C++ biasa (malloc/operator baru). Pengumpul sampah bahkan memperhitungkan ukuran cache komputer Anda untuk mencoba menyimpan objek gen 0 di tempat manis yang cepat dari hierarki cache/memori.
Karena gaya kode terkelola yang disukai adalah mengalokasikan sebagian besar objek dengan masa pakai pendek, dan merebutnya kembali dengan cepat, kami juga menyertakan (dalam biaya waktu) biaya yang diamortisasi dari pengumpulan sampah objek baru ini.
Perhatikan pengumpul sampah tidak menghabiskan waktu berkabung benda mati. Jika objek mati, GC tidak melihatnya, tidak berjalan, tidak memberinya pemikiran nanodetik. GC hanya memperhatikan kesejahteraan orang hidup.
(Pengecualian: objek mati yang dapat diselesaikan adalah kasus khusus. GC melacaknya, dan secara khusus mempromosikan objek mati yang dapat diselesaikan ke finalisasi generasi berikutnya yang tertunda. Ini mahal, dan dalam kasus terburuk dapat secara transitif mempromosikan grafik objek mati besar. Oleh karena itu, jangan membuat objek dapat diselesaikan kecuali benar-benar diperlukan; dan jika Anda harus, pertimbangkan untuk menggunakanPola Buang
Tentu saja, biaya GC yang diamortisasi dari objek berumur pendek yang besar lebih besar dari biaya objek berumur pendek kecil. Setiap alokasi objek membawa kita jauh lebih dekat ke siklus pengumpulan sampah berikutnya; objek yang lebih besar melakukannya lebih cepat yang kecil. Cepat (atau lambat), saat perhitungan akan datang. Siklus GC, terutama koleksi generasi 0, sangat cepat, tetapi tidak gratis, bahkan jika sebagian besar objek baru mati: untuk menemukan (menandai) objek langsung, pertama-tama perlu menjeda utas dan kemudian berjalan tumpukan dan struktur data lainnya untuk mengumpulkan referensi objek akar ke dalam tumpukan.
(Mungkin lebih signifikan, lebih sedikit objek yang lebih besar cocok dalam jumlah cache yang sama seperti objek yang lebih kecil. Efek miss cache dapat dengan mudah mendominasi efek panjang jalur kode.)
Setelah ruang untuk objek dialokasikan, objek tetap menginisialisasinya (membangunnya). CLR menjamin bahwa semua referensi objek telah diinisialisasi ke null, dan semua jenis skalar primitif diinisialisasi ke 0, 0,0, false, dll. (Oleh karena itu tidak perlu melakukannya secara berlebihan di konstruktor yang ditentukan pengguna Anda. Jangan ragu, tentu saja. Tetapi ketahuilah bahwa pengkompilasi JIT saat ini tidak selalu mengoptimalkan penyimpanan redundan Anda.)
Selain nol bidang instans keluar, CLR menginisialisasi (jenis referensi saja) bidang implementasi internal objek: penunjuk tabel metode dan kata header objek, yang mendahului penunjuk tabel metode. Array juga mendapatkan bidang Panjang, dan array objek mendapatkan bidang Panjang dan jenis elemen.
Kemudian CLR memanggil konstruktor objek, jika ada. Setiap konstruktor jenis, baik yang ditentukan pengguna atau pengkompilasi yang dihasilkan, pertama-tama memanggil konstruktor jenis dasarnya, lalu menjalankan inisialisasi yang ditentukan pengguna, jika ada.
Secara teori ini bisa mahal untuk skenario warisan mendalam. Jika E memperluas D memperluas C memperluas B memperluas A (memperluas System.Object) maka menginisialisasi E akan selalu dikenakan lima panggilan metode. Dalam praktiknya, hal-hal tidak terlalu buruk, karena pengkompilasi sebaris jauh (ke ketimpangan) memanggil konstruktor jenis dasar kosong.
Mengacu pada kolom pertama Tabel 4, amati kita dapat membuat dan menginisialisasi D
struct dengan empat bidang int dalam sekitar 8 int-add-times. Disassembly 6 adalah kode yang dihasilkan dari tiga perulangan waktu yang berbeda, membuat A, C, dan E. (Dalam setiap perulangan, kami memodifikasi setiap instans baru, yang membuat pengkompilasi JIT tidak mengoptimalkan semuanya.)
Tabel 4 Nilai dan Waktu Pembuatan Objek Jenis Referensi (ns)
Avg | Min | Primitif | Avg | Min | Primitif | Avg | Min | Primitif |
---|---|---|---|---|---|---|---|---|
2.6 | 2.6 | valtype L1 baru | 22.0 | 20.3 | reftype L1 baru | 22.9 | 20.7 | rt ctor L1 baru |
4.6 | 4.6 | valtype L2 baru | 26.1 | 23.9 | reftype L2 baru | 27.8 | 25.4 | rt ctor L2 baru |
6.4 | 6.4 | valtype L3 baru | 30.2 | 27.5 | reftype L3 baru | 32.7 | 29.9 | rt ctor L3 baru |
8.0 | 8.0 | valtype L4 baru | 34.1 | 30.8 | reftype L4 baru | 37.7 | 34.1 | rt ctor L4 baru |
23.0 | 22.9 | valtype L5 baru | 39.1 | 34.4 | reftype baru L5 | 43.2 | 39.1 | rt ctor L5 baru |
22.3 | 20.3 | baru rt kosong ctor L1 | 28.6 | 26.7 | rt no-inl L1 baru | |||
26.5 | 23.9 | baru rt kosong ctor L2 | 38.9 | 36.5 | rt no-inl L2 baru | |||
38.1 | 34.7 | baru rt kosong ctor L3 | 50.6 | 47.7 | rt no-inl L3 baru | |||
34.7 | 30.7 | baru rt kosong ctor L4 | 61.8 | 58.2 | rt no-inl L4 baru | |||
38.5 | 34.3 | baru rt kosong ctor L5 | 72.6 | 68.5 | rt no-inl L5 baru |
Pembongkaran 6 Nilai jenis konstruksi objek
A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov dword ptr [ebp-4],0
00000027 FF 45 FC inc dword ptr [ebp-4]
C c1 = new C(); ++c1.c;
00000024 8D 7D F4 lea edi,[ebp-0Ch]
00000027 33 C0 xor eax,eax
00000029 AB stos dword ptr [edi]
0000002a AB stos dword ptr [edi]
0000002b AB stos dword ptr [edi]
0000002c FF 45 FC inc dword ptr [ebp-4]
E e1 = new E(); ++e1.e;
00000026 8D 7D EC lea edi,[ebp-14h]
00000029 33 C0 xor eax,eax
0000002b 8D 48 05 lea ecx,[eax+5]
0000002e F3 AB rep stos dword ptr [edi]
00000030 FF 45 FC inc dword ptr [ebp-4]
Lima waktu berikutnya (reftype L1 baru, ... new reftype L5) adalah untuk lima tingkat warisan jenis referensi A
, ..., E
, sans konstruktor yang ditentukan pengguna:
public class A { int a; }
public class B : A { int b; }
public class C : B { int c; }
public class D : C { int d; }
public class E : D { int e; }
Membandingkan waktu jenis referensi dengan waktu jenis nilai, kita melihat bahwa alokasi yang diamortisasi dan membebaskan biaya setiap instans adalah sekitar 20 ns (20X int add time) pada mesin uji. Itu cepat— mengalokasikan, menginisialisasi, dan mengklaim kembali sekitar 50 juta objek berumur pendek per detik, berkelanjutan. Untuk objek sekumpulan lima bidang, alokasi dan pengumpulan hanya menyuguhkan setengah dari waktu pembuatan objek. Lihat Pembongkaran 7.
Membongkar 7 konstruksi objek jenis referensi
new A();
0000000f B9 D0 72 3E 00 mov ecx,3E72D0h
00000014 E8 9F CC 6C F9 call F96CCCB8
new C();
0000000f B9 B0 73 3E 00 mov ecx,3E73B0h
00000014 E8 A7 CB 6C F9 call F96CCBC0
new E();
0000000f B9 90 74 3E 00 mov ecx,3E7490h
00000014 E8 AF CA 6C F9 call F96CCAC8
Tiga set terakhir dari lima waktu menyajikan variasi pada skenario konstruksi kelas yang diwariskan ini.
New rt kosong ctor L1, ..., baru rt kosong ctor L5: Setiap jenis
A
, ...,E
memiliki konstruktor kosong yang ditentukan pengguna. Ini semua inlined away dan kode yang dihasilkan sama dengan yang di atas.New rt ctor L1, ..., rt ctor baru L5: Setiap jenis
A
, ...,E
memiliki konstruktor yang ditentukan pengguna yang mengatur variabel instansnya ke 1:public class A { int a; public A() { a = 1; } } public class B : A { int b; public B() { b = 1; } } public class C : B { int c; public C() { c = 1; } } public class D : C { int d; public D() { d = 1; } } public class E : D { int e; public E() { e = 1; } }
Pengkompilasi menginline setiap set panggilan konstruktor kelas dasar berlapis ke situs new
. (Pembongkaran 8).
Pembongkaran 8 Konstruktor yang diwariskan secara mendalam
new A();
00000012 B9 A0 77 3E 00 mov ecx,3E77A0h
00000017 E8 C4 C7 6C F9 call F96CC7E0
0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1
new C();
00000012 B9 80 78 3E 00 mov ecx,3E7880h
00000017 E8 14 C6 6C F9 call F96CC630
0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1
00000023 C7 40 08 01 00 00 00 mov dword ptr [eax+8],1
0000002a C7 40 0C 01 00 00 00 mov dword ptr [eax+0Ch],1
new E();
00000012 B9 60 79 3E 00 mov ecx,3E7960h
00000017 E8 84 C3 6C F9 call F96CC3A0
0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1
00000023 C7 40 08 01 00 00 00 mov dword ptr [eax+8],1
0000002a C7 40 0C 01 00 00 00 mov dword ptr [eax+0Ch],1
00000031 C7 40 10 01 00 00 00 mov dword ptr [eax+10h],1
00000038 C7 40 14 01 00 00 00 mov dword ptr [eax+14h],1
New rt no-inl L1, ..., rt no-inl L5 baru: Setiap jenis
A
, ...,E
memiliki konstruktor yang ditentukan pengguna yang sengaja ditulis agar terlalu mahal untuk sebaris. Skenario ini mensimulasikan biaya pembuatan objek kompleks dengan hierarki pewarisan mendalam dan konstruktor largish.public class A { int a; public A() { a = 1; if (falsePred) dummy(…); } } public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } } public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } } public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } } public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
Lima waktu terakhir dalam Tabel 4 menunjukkan overhead tambahan untuk memanggil konstruktor dasar berlapis.
Interlude: CLR Profiler Demo
Sekarang untuk demo cepat CLR Profiler. CLR Profiler, yang sebelumnya dikenal sebagai Profiler Alokasi, menggunakan CLR Profiling API untuk mengumpulkan data peristiwa, terutama memanggil, mengembalikan, dan peristiwa alokasi objek dan pengumpulan sampah, saat aplikasi Anda berjalan. (CLR Profiler adalah profiler "invasif", yang berarti sayangnya memperlambat aplikasi yang diprofilkan secara substansial.) Setelah peristiwa dikumpulkan, Anda menggunakan CLR Profiler untuk menjelajahi alokasi memori dan perilaku GC aplikasi Anda, termasuk interaksi antara grafik panggilan hierarkis dan pola alokasi memori Anda.
CLR Profiler layak dipelajari karena untuk banyak aplikasi kode terkelola "tertantang performa", memahami profil alokasi data Anda memberikan wawasan penting yang diperlukan untuk mengurangi set kerja Anda sehingga memberikan komponen dan aplikasi yang cepat dan hemat.
CLR Profiler juga dapat mengungkapkan metode mana yang mengalokasikan lebih banyak penyimpanan daripada yang Anda harapkan, dan dapat mengungkap kasus di mana Anda secara tidak sengaja menyimpan referensi ke grafik objek yang tidak berguna yang dapat diklaim kembali oleh GC. (Pola desain masalah umum adalah cache perangkat lunak atau tabel pencarian item yang tidak lagi diperlukan atau aman untuk direkonstitusi nanti. Ini tragis ketika cache menjaga grafik objek tetap hidup melewati kehidupan yang berguna. Sebagai gantinya, pastikan untuk null out referensi ke objek yang tidak lagi Anda butuhkan.)
Gambar 1 adalah tampilan garis waktu timbunan selama eksekusi driver penguji waktu. Pola sawtooth menunjukkan alokasi ribuan instans objek C
(magenta), D
(ungu), dan E
(biru). Setiap beberapa milidetik, kami mengunyah ~150 KB RAM lainnya di tumpukan objek baru (generasi 0), dan pengumpul sampah berjalan singkat untuk mendaur ulang dan mempromosikan objek langsung apa pun ke gen 1. Sangat luar biasa bahwa bahkan di bawah lingkungan pembuatan profil invasif (lambat) ini, dalam interval 100 ms (2,8 hingga 2,9s), kami menjalani ~ 8 generasi 0 siklus GC. Kemudian pada 2,977 s, membuat ruang untuk instans E
lain, pengumpul sampah melakukan pengumpulan sampah generasi 1, yang mengumpulkan dan memadatkan tumpukan gen 1—dan sehingga sawtooth berlanjut, dari alamat awal yang lebih rendah.
Gambar1 clr profiler tampilan Garis Waktu
Perhatikan bahwa semakin besar objek (E lebih besar dari D lebih besar dari C), semakin cepat tumpukan gen 0 terisi dan semakin sering siklus GC.
Pemeriksaan Jenis Cast dan Instans
Fondasi batuan yang aman, aman, dapat diverifikasi kode terkelola adalah keamanan jenis. Jika mungkin untuk melemparkan objek ke jenis yang tidak, itu akan mudah untuk membahayakan integritas CLR dan begitu juga dengan belas kasihan kode yang tidak tepercaya.
Tabel 5 Cast dan isinst Times (ns)
Avg | Min | Primitif | Avg | Min | Primitif |
---|---|---|---|---|---|
0.4 | 0.4 | cast up 1 | 0.8 | 0.8 | isinst up 1 |
0.3 | 0.3 | cast down 0 | 0.8 | 0.8 | isinst down 0 |
8.9 | 8.8 | cast down 1 | 6.3 | 6.3 | isinst down 1 |
9.8 | 9.7 | cast (naik 2) turun 1 | 10.7 | 10.6 | isinst (naik 2) turun 1 |
8.9 | 8.8 | cast down 2 | 6.4 | 6.4 | isinst down 2 |
8.7 | 8.6 | cast down 3 | 6.1 | 6.1 | isinst down 3 |
Tabel 5 menunjukkan overhead pemeriksaan jenis wajib ini. Transmisi dari jenis turunan ke jenis dasar selalu aman—dan gratis; sedangkan transmisi dari jenis dasar ke jenis turunan harus diperiksa jenisnya.
Cast (dicentang) mengonversi referensi objek ke jenis target, atau melemparkan InvalidCastException
.
Sebaliknya, instruksi CIL isinst
digunakan untuk mengimplementasikan kata kunci C# as
:
bac = ac as B;
Jika ac
tidak B
atau berasal dari B
, hasilnya null
, bukan pengecualian.
Daftar 2 menunjukkan salah satu perulangan waktu casting, dan Disassembly 9 menunjukkan kode yang dihasilkan untuk satu cast ke jenis turunan. Untuk melakukan pemeran, pengkompilasi memancarkan panggilan langsung ke rutinitas pembantu.
Listing 2 Loop untuk menguji waktu pemeran
public static void castUp2Down1(int n) {
A ac = c; B bd = d; C ce = e; D df = f;
B bac = null; C cbd = null; D dce = null; E edf = null;
for (n /= 8; --n >= 0; ) {
bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
}
}
Membongkar 9
bac = (B)ac;
0000002e 8B D5 mov edx,ebp
00000030 B9 40 73 3E 00 mov ecx,3E7340h
00000035 E8 32 A7 4E 72 call 724EA76C
Properti
Dalam kode terkelola, properti adalah sepasang metode, getter properti, dan setter properti, yang bertindak seperti bidang objek. Metode get_ mengambil properti; metode set_ memperbarui properti ke nilai baru.
Selain itu, properti berulah, dan biaya, seperti metode instans reguler dan metode virtual. Jika Anda menggunakan properti untuk hanya mengambil atau menyimpan bidang instans, biasanya di-inlin, seperti halnya metode kecil apa pun.
Tabel 6 menunjukkan waktu yang diperlukan untuk mengambil (dan menambahkan), dan untuk menyimpan, sekumpulan bidang dan properti instans bilangan bulat. Biaya untuk mendapatkan atau mengatur properti memang identik dengan akses langsung ke bidang yang mendasar, kecuali properti dinyatakan virtual, dalam hal ini biayanya kira-kira merupakan panggilan metode virtual. Tidak ada kejutan di sana.
Tabel 6 Bidang dan Waktu Properti (ns)
Avg | Min | Primitif |
---|---|---|
1.0 | 1.0 | dapatkan bidang |
1.2 | 1.2 | dapatkan prop |
1.2 | 1.2 | set bidang |
1.2 | 1.2 | set prop |
6.4 | 6.3 | dapatkan prop virtual |
6.4 | 6.3 | atur prop virtual |
Tulis Penghalang
Pengumpul sampah CLR memanfaatkan "hipotesis generasi"—sebagian besar objek baru mati muda—untuk meminimalkan overhead pengumpulan.
Tumpukan secara logis dipartisi ke generasi. Objek terbaru hidup di generasi 0 (gen 0). Objek-objek ini belum selamat dari koleksi. Selama koleksi gen 0, GC menentukan mana, jika ada, objek gen 0 dapat dijangkau dari set akar GC, yang mencakup referensi objek dalam register mesin, pada tumpukan, referensi objek bidang statis kelas, dll. Objek yang dapat dijangkau secara transitif adalah "langsung" dan dipromosikan (disalin) ke generasi 1.
Karena ukuran tumpukan total mungkin ratusan MB, sementara ukuran tumpukan gen 0 mungkin hanya 256 KB, membatasi sejauh mana grafik objek GC melacak ke tumpukan gen 0 adalah pengoptimalan yang penting untuk mencapai waktu jeda koleksi CLR yang sangat singkat.
Namun, dimungkinkan untuk menyimpan referensi ke objek gen 0 di bidang referensi objek objek gen 1 atau gen 2. Karena kami tidak memindai objek gen 1 atau gen 2 selama koleksi gen 0, jika itu adalah satu-satunya referensi ke objek gen 0 yang diberikan, objek tersebut dapat direklamasi kembali secara keliru oleh GC. Kita tidak bisa membiarkan itu terjadi!
Sebagai gantinya, semua penyimpanan ke semua bidang referensi objek dalam tumpukan menimbulkan penghadang tulis. Ini adalah kode pembukuan yang secara efisien mencatat penyimpanan referensi objek generasi baru ke bidang objek generasi yang lebih lama. Bidang referensi objek lama tersebut ditambahkan ke kumpulan akar GC dari GC berikutnya.
Overhead penghubung tulis per-object-reference-field-store sebanding dengan biaya panggilan metode sederhana (Tabel 7). Ini adalah pengeluaran baru yang tidak ada dalam kode C/C++ asli, tetapi biasanya harga kecil untuk membayar alokasi objek super cepat dan GC, dan banyak manfaat produktivitas dari manajemen memori otomatis.
Tabel 7 Write Barrier Time (ns)
Avg | Min | Primitif |
---|---|---|
6.4 | 6.4 | write barrier |
Penghalang tulis dapat mahal dalam perulangan dalam yang ketat. Tetapi dalam beberapa tahun ke depan kita dapat menantikan teknik kompilasi tingkat lanjut yang mengurangi jumlah hambatan tulis yang diambil dan total biaya yang diamortisasi.
Anda mungkin berpikir penghalang tulis hanya diperlukan pada penyimpanan ke bidang referensi objek jenis referensi. Namun, dalam metode jenis nilai, menyimpan ke bidang referensi objeknya (jika ada) juga dilindungi oleh penghalang tulis. Ini diperlukan karena jenis nilai itu sendiri terkadang dapat disematkan dalam jenis referensi yang berada di tumpukan.
Akses Elemen Array
Untuk mendiagnosis dan menghalangi kesalahan array-out-of-bounds dan kerusakan tumpukan, dan untuk melindungi integritas CLR itu sendiri, beban dan penyimpanan elemen array dicentang, memastikan indeks berada dalam interval [0,array. Panjang-1] inklusif atau melemparkan IndexOutOfRangeException
.
Pengujian kami mengukur waktu untuk memuat atau menyimpan elemen array int[]
dan array A[]
. (Tabel 8).
Tabel 8 Waktu Akses Array (ns)
Avg | Min | Primitif |
---|---|---|
1.9 | 1.9 | muat elem array int |
1.9 | 1.9 | simpan int array elem |
2.5 | 2.5 | load obj array elem |
16.0 | 16.0 | simpan array obj elem |
Pemeriksaan batas memerlukan perbandingan indeks array dengan array implisit. Bidang panjang. Seperti yang ditunjukkan Disassembly 10, hanya dalam dua instruksi kita memeriksa indeks tidak kurang dari 0 atau lebih besar dari atau sama dengan array. Panjang —jika ya, kita bercabang ke urutan keluar garis yang melempar pengecualian. Hal yang sama berlaku untuk banyak elemen array objek, dan untuk penyimpanan ke dalam array ints dan jenis nilai sederhana lainnya. (Load obj array elem waktu (tidak signifikan) lebih lambat karena sedikit perbedaan dalam perulangan dalamnya.)
Membongkar 10 Memuat elemen array int
; i in ecx, a in edx, sum in edi
sum += a[i];
00000024 3B 4A 04 cmp ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19 jae 00000042
00000029 03 7C 8A 08 add edi,dword ptr [edx+ecx*4+8]
… ; throw IndexOutOfRangeException
00000042 33 C9 xor ecx,ecx
00000044 E8 52 78 52 72 call 7252789B
Melalui pengoptimalan kualitas kodenya, kompilator JIT sering menghilangkan pemeriksaan batas redundan.
Mengingat bagian sebelumnya, kita dapat mengharapkan elemen array objek menyimpan jauh lebih mahal. Untuk menyimpan referensi objek ke dalam array referensi objek, runtime harus:
- periksa indeks array berada dalam batas;
- periksa objek adalah instans jenis elemen array;
- lakukan penghubung tulis (mencatat referensi objek intergenerasi dari array ke objek).
Urutan kode ini agak panjang. Daripada memancarkannya di setiap situs penyimpanan array objek, pengkompilasi memancarkan panggilan ke fungsi pembantu bersama, seperti yang ditunjukkan dalam Pembongkaran 11. Panggilan ini, ditambah ketiga tindakan ini memperkirakan waktu tambahan yang diperlukan dalam kasus ini.
elemen array objek Disassembly 11 Store
; objarray in edi
; obj in ebx
objarray[1] = obj;
00000027 53 push ebx
00000028 8B CF mov ecx,edi
0000002a BA 01 00 00 00 mov edx,1
0000002f E8 A3 A0 4A 72 call 724AA0D7 ; store object array element helper
Tinju dan Pembukaan Kotak
Kemitraan antara kompilator .NET dan CLR memungkinkan jenis nilai, termasuk jenis primitif seperti int (System.Int32), untuk berpartisipasi seolah-olah mereka adalah jenis referensi—untuk ditangani sebagai referensi objek. Keterlibatan ini—gula sintaktik ini—memungkinkan jenis nilai untuk diteruskan ke metode sebagai objek, disimpan dalam koleksi sebagai objek, dll.
Untuk "kotak" jenis nilai adalah membuat objek jenis referensi yang menyimpan salinan jenis nilainya. Ini secara konseptual sama dengan membuat kelas dengan bidang instans yang tidak disebutkan namanya dengan jenis yang sama dengan jenis nilai.
Untuk "membuka kotak" jenis nilai berkotak adalah menyalin nilai, dari objek, ke dalam instans baru dari jenis nilai.
Seperti yang ditunjukkan Tabel 9 (dibandingkan dengan Tabel 4), waktu yang diamortisasi diperlukan untuk mengetik int, dan kemudian untuk mengumpulkan sampah, sebanding dengan waktu yang diperlukan untuk membuat instans kelas kecil dengan satu bidang int.
Tabel 9 Kotak dan Waktu Unbox int (ns)
Avg | Min | Primitif |
---|---|---|
29.0 | 21.6 | int kotak |
3.0 | 3.0 | batalkan kotak masuk |
Untuk membuka kotak objek int yang dikotak memerlukan transmisi eksplisit ke int. Ini dikompilasi ke dalam perbandingan jenis objek (diwakili oleh alamat tabel metodenya) dan alamat tabel metode int kotak. Jika sama, nilai disalin dari objek. Jika tidak, pengecualian akan dilemparkan. Lihat Pembongkaran 12.
Membongkar 12 Box dan membuka kotak int
box object o = 0;
0000001a B9 08 07 B9 79 mov ecx,79B90708h
0000001f E8 E4 A5 6C F9 call F96CA608
00000024 8B D0 mov edx,eax
00000026 C7 42 04 00 00 00 00 mov dword ptr [edx+4],0
unbox sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C je 00000055
00000049 8B D6 mov edx,esi
0000004b B9 08 07 B9 79 mov ecx,79B90708h
00000050 E8 A9 BB 4E 72 call 724EBBFE ; no, throw exception
00000055 8D 46 04 lea eax,[esi+4]
00000058 3B 08 cmp ecx,dword ptr [eax]
0000005a 03 38 add edi,dword ptr [eax] ; yes, fetch int field
Delegasi
Di C, penunjuk ke fungsi adalah jenis data primitif yang secara harfiah menyimpan alamat fungsi.
C++ menambahkan penunjuk ke fungsi anggota. Fungsi penunjuk ke anggota (PMF) mewakili pemanggilan fungsi anggota yang ditangguhkan. Alamat fungsi anggota non-virtual mungkin alamat kode sederhana, tetapi alamat fungsi anggota virtual harus mewujudkan panggilan fungsi anggota virtual tertentu—dereferensi PMF seperti itu panggilan fungsi virtual.
Untuk mendereferensikan C++ PMF, Anda harus menyediakan instans:
A* pa = new A;
void (A::*pmf)() = &A::af;
(pa->*pmf)();
Bertahun-tahun yang lalu, di tim pengembangan kompilator Visual C++, kami biasanya bertanya pada diri sendiri, jenis beastie apa ekspresi telanjang pa->*pmf
(operator panggilan fungsi sans)? Kami menyebutnya penunjuk terikat ke fungsi anggota tetapi panggilan fungsi anggota laten sama seperti apt.
Kembali ke lahan kode terkelola, objek delegasi hanyalah itu—panggilan metode laten. Objek delegasi mewakili metode untuk memanggil dan instans untuk memanggilnya—atau untuk delegasi ke metode statis, hanya metode statis untuk memanggil.
(Seperti yang dinyatakan dokumentasi kami: Deklarasi delegasi menentukan jenis referensi yang dapat digunakan untuk merangkum metode dengan tanda tangan tertentu. Instans delegasi merangkum metode statis atau instans. Delegasi kira-kira mirip dengan penunjuk fungsi di C++; namun, delegasi berjenis aman dan aman.)
Jenis delegasi dalam C# adalah jenis turunan dari MulticastDelegate. Jenis ini menyediakan semantik kaya termasuk kemampuan untuk membangun daftar pemanggilan pasangan (objek,metode) yang akan dipanggil saat Anda memanggil delegasi.
Delegasi juga menyediakan fasilitas untuk pemanggilan metode asinkron. Setelah Anda menentukan jenis delegasi dan membuat instans, diinisialisasi dengan panggilan metode laten, Anda dapat memanggilnya secara sinkron (sintaks panggilan metode) atau secara asinkron, melalui BeginInvoke
. Jika BeginInvoke
dipanggil, runtime mengantre panggilan dan segera kembali ke pemanggil. Metode target dipanggil nanti, pada utas kumpulan utas.
Semua semantik kaya ini tidak murah. Membandingkan Tabel 10 dan Tabel 3, perhatikan bahwa pemanggilan delegasi adalah ** sekitar delapan kali lebih lambat daripada panggilan metode. Harapkan itu meningkat dari waktu ke waktu.
Tabel 10 Delegasikan waktu pemanggilan (ns)
Avg | Min | Primitif |
---|---|---|
41.1 | 40.9 | delegasikan pemanggilan |
Cache Misses, Kesalahan Halaman, dan Arsitektur Komputer
Kembali ke "hari tua yang baik", sekitar 1983, prosesor lambat (~.5 juta instruksi/ s), dan relatif berbicara, RAM cukup cepat tetapi kecil (~ 300 ns waktu akses pada 256 KB DRAM), dan disk lambat dan besar (~ 25 ms waktu akses pada disk 10 MB). Mikroprosektor PC adalah CISC skalar, sebagian besar titik mengambang berada dalam perangkat lunak, dan tidak ada cache.
Setelah dua puluh tahun lagi Hukum Moore, pada tahun 2003, prosesor cepat (mengeluarkan hingga tiga operasi per siklus pada 3 GHz), RAM relatif sangat lambat (~100 ns waktu akses pada 512 MB DRAM), dan disk secara gletsal lambat dan sangat besar (~10 ms waktu akses pada disk 100 GB). Mikroprosesor PC sekarang berada di luar urutan aliran data superscalar hyperthreading trace-cache RISCs (menjalankan instruksi CISC yang didekodekan) dan ada beberapa lapisan cache—misalnya, microprocessor berorientasi server tertentu memiliki cache data tingkat 1 32 KB (mungkin 2 siklus latensi), cache data L2 512 KB, dan cache data 2 MB L3 (mungkin selusin siklus latensi), semua pada chip.
Di masa lalu yang baik, Anda bisa, dan kadang-kadang melakukannya, menghitung byte kode yang Anda tulis, dan menghitung jumlah siklus yang diperlukan kode untuk dijalankan. Beban atau penyimpanan mengambil jumlah siklus yang sama dengan penambahan. Prosesor modern menggunakan prediksi cabang, spekulasi, dan eksekusi out-of-order (aliran data) di beberapa unit fungsi untuk menemukan paralelisme tingkat instruksi dan jadi buat kemajuan pada beberapa front sekaligus.
Sekarang PC tercepat kami dapat mengeluarkan hingga ~ 9000 operasi per mikro detik, tetapi dalam mikrosekon yang sama, hanya memuat atau menyimpan ke DRAM ~ 10 baris cache. Dalam lingkaran arsitektur komputer, ini dikenal sebagai memukul memoridinding. Cache menyembunyikan latensi memori, tetapi hanya ke titik. Jika kode atau data tidak cocok dalam cache, dan/atau menunjukkan lokalitas referensi yang buruk, jet supersonik 9000 operation-per-microsecond kami berdegenerasi menjadi 10 tricycle load-per-microsecond.
Dan (jangan biarkan ini terjadi pada Anda) jika rangkaian kerja program melebihi RAM fisik yang tersedia, dan program mulai mengambil kesalahan halaman keras, maka di setiap layanan kesalahan halaman 10.000 mikrodetik (akses disk), kami melewatkan kesempatan untuk membawa pengguna hingga 90 juta operasi lebih dekat dengan jawaban mereka. Itu sangat mengerikan sehingga saya percaya bahwa Anda akan mulai hari ini ke depan berhati-hati untuk mengukur set kerja Anda (vadump) dan menggunakan alat seperti CLR Profiler untuk menghilangkan alokasi yang tidak perlu dan retensi grafik objek yang tidak disengaja.
Tetapi apa hubungannya semua ini dengan mengetahui biaya primitif kode terkelola?Semuanya*.*
Recalling Table 1, daftar omnibus waktu primitif kode terkelola, yang diukur pada P-III 1,1 GHz, mengamati bahwa setiap kali, bahkan biaya yang diamortisasi untuk mengalokasikan, menginisialisasi, dan merebut kembali lima objek bidang dengan lima tingkat panggilan konstruktor eksplisit, lebih cepat daripada akses DRAM tunggal. Hanya satu beban yang melewatkan semua tingkat cache on-chip dapat memakan waktu lebih lama untuk layanan daripada hampir semua operasi kode terkelola.
Jadi, jika Anda bersemangat tentang kecepatan kode Anda, sangat penting bagi Anda untuk mempertimbangkan dan mengukur hierarki cache/memori saat Anda merancang dan menerapkan algoritma dan struktur data Anda.
Waktu untuk demonstrasi sederhana: Apakah lebih cepat untuk menjumlahkan array ints, atau menjumlahkan daftar ints yang ditautkan yang setara? Yang, berapa banyak begitu, dan mengapa?
Pikirkan tentang hal ini selama satu menit. Untuk item kecil seperti ints, jejak memori per elemen array adalah seperempat dari daftar yang ditautkan. (Setiap simpul daftar tertaut memiliki dua kata overhead objek dan dua kata bidang (tautan berikutnya dan item int).) Itu akan merusak pemanfaatan cache. Skor satu untuk pendekatan array.
Tetapi traversal array mungkin menimbulkan pemeriksaan batas array per item. Anda baru saja melihat bahwa pemeriksaan batas membutuhkan sedikit waktu. Mungkin itu tips menskalakan mendukung daftar yang ditautkan?
Membongkar array int 13 Jumlah versus jumlah daftar tertaut int
sum int array: sum += a[i];
00000024 3B 4A 04 cmp ecx,dword ptr [edx+4] ; bounds check
00000027 73 19 jae 00000042
00000029 03 7C 8A 08 add edi,dword ptr [edx+ecx*4+8] ; load array elem
for (int i = 0; i < m; i++)
0000002d 41 inc ecx
0000002e 3B CE cmp ecx,esi
00000030 7C F2 jl 00000024
sum int linked list: sum += l.item; l = l.next;
0000002a 03 70 08 add esi,dword ptr [eax+8]
0000002d 8B 40 04 mov eax,dword ptr [eax+4]
sum += l.item; l = l.next;
00000030 03 70 08 add esi,dword ptr [eax+8]
00000033 8B 40 04 mov eax,dword ptr [eax+4]
sum += l.item; l = l.next;
00000036 03 70 08 add esi,dword ptr [eax+8]
00000039 8B 40 04 mov eax,dword ptr [eax+4]
sum += l.item; l = l.next;
0000003c 03 70 08 add esi,dword ptr [eax+8]
0000003f 8B 40 04 mov eax,dword ptr [eax+4]
for (m /= 4; --m >= 0; ) {
00000042 49 dec ecx
00000043 85 C9 test ecx,ecx
00000045 79 E3 jns 0000002A
Mengacu pada Disassembly 13, saya telah menumpuk dek demi traversal daftar yang ditautkan, membukanya empat kali, bahkan menghapus pemeriksaan akhir daftar pointer null yang biasa. Setiap item dalam perulangan array memerlukan enam instruksi, sedangkan setiap item dalam perulangan daftar yang ditautkan hanya memerlukan instruksi 11/4 = 2,75. Sekarang menurutmu mana yang lebih cepat?
Kondisi pengujian: pertama, buat array satu juta ints, dan daftar tertaut tradisional sederhana dari satu juta ints (1 node daftar M). Kemudian waktu berapa lama, per item, diperlukan untuk menambahkan 1.000, 10.000, 100.000, dan 1.000.000 item pertama. Ulangi setiap perulangan berkali-kali, untuk mengukur perilaku cache yang paling menyanjung untuk setiap kasus.
Mana yang lebih cepat? Setelah Anda menebak, lihat jawaban: delapan entri terakhir dalam Tabel 1.
Menarik! Waktu menjadi jauh lebih lambat karena data yang dirujuk tumbuh lebih besar dari ukuran cache berturut-turut. Versi array selalu lebih cepat daripada versi daftar yang ditautkan, meskipun menjalankan instruksi dua kali lebih banyak; untuk 100.000 item, versi array tujuh kali lebih cepat!
Mengapa begitu? Pertama, lebih sedikit item daftar tertaut yang pas di tingkat cache tertentu. Semua header objek dan tautan ruang limbah. Kedua, prosesor aliran data modern kami yang tidak berurutan berpotensi memperbesar tampilan dan membuat kemajuan pada beberapa item dalam array secara bersamaan. Sebaliknya, dengan daftar tertaut, hingga simpul daftar saat ini berada dalam cache, prosesor tidak dapat mulai mengambil tautan berikutnya ke simpul setelah itu.
Dalam kasus 100.000 item, prosesor menghabiskan (rata-rata) sekitar (22-3,5)/22 = 84% waktunya memutar ibu jarinya menunggu beberapa baris cache simpul daftar dibaca dari DRAM. Kedengarannya buruk, tapi hal-hal bisa jauh lebih buruk. Karena item daftar tertaut kecil, banyak dari mereka yang pas pada baris cache. Karena kami melintasi daftar dalam urutan alokasi, dan karena pengumpul sampah mempertahankan urutan alokasi bahkan karena memadamkan objek mati dari tumpukan, kemungkinan, setelah mengambil satu simpul pada baris cache, bahwa beberapa node berikutnya sekarang juga berada di cache. Jika node lebih besar, atau jika node daftar berada dalam urutan alamat acak, maka setiap simpul yang dikunjungi mungkin merupakan nol cache penuh. Menambahkan 16 byte ke setiap node daftar menggandakan waktu traversal per item menjadi 43 ns; +32 byte, 67 ns/item; dan menambahkan 64 byte menggandakannya lagi, ke 146 ns/item, mungkin latensi DRAM rata-rata pada mesin uji.
Jadi apa pelajaran takeaway di sini? Hindari daftar tertaut 100.000 simpul? Tidak ada. Pelajarannya adalah bahwa efek cache dapat mendominasi pertimbangan efisiensi tingkat rendah kode terkelola versus kode asli. Jika Anda menulis kode terkelola kritis performa, terutama kode yang mengelola struktur data besar, ingatlah efek cache, pikirkan pola akses struktur data Anda, dan upayakan jejak data yang lebih kecil dan lokalitas referensi yang baik.
Ngomong-ngomong, trennya adalah bahwa dinding memori, rasio waktu akses DRAM yang dibagi dengan waktu operasi CPU, akan terus tumbuh lebih buruk dari waktu ke waktu.
Berikut adalah beberapa aturan praktis "desain sadar cache":
- Bereksperimen dengan, dan mengukur, skenario Anda karena sulit untuk memprediksi efek urutan kedua dan karena aturan praktis tidak sepadan dengan kertas yang dicetak.
- Beberapa struktur data, yang dicontohkan oleh array, memanfaatkan kedekatan implisit untuk mewakili hubungan antara data. Lainnya, dicontohkan oleh daftar yang ditautkan, menggunakan pointer eksplisit (referensi) untuk mewakili hubungan. Kedekatan implisit umumnya lebih disukai—"implisitness" menghemat ruang dibandingkan dengan pointer; dan kedekatan memberikan lokalitas referensi yang stabil, dan dapat memungkinkan prosesor memulai lebih banyak pekerjaan sebelum mengejar pointer berikutnya.
- Beberapa pola penggunaan mendukung struktur hibrid—daftar array kecil, array array, atau pohon B.
- Mungkin algoritma penjadwalan sensitif akses disk, yang dirancang kembali ketika akses disk hanya memerlukan biaya 50.000 instruksi CPU, harus didaur ulang sekarang setelah akses DRAM dapat mengambil ribuan operasi CPU.
- Karena pengumpul sampah mark-and-compact CLR mempertahankan urutan relatif objek, objek yang dialokasikan bersama pada waktunya (dan pada utas yang sama) cenderung tetap bersama di ruang. Anda mungkin dapat menggunakan fenomena ini untuk mengalokasikan data klisquish dengan cermat pada baris cache umum.
- Anda mungkin ingin mempartisi data Anda menjadi bagian panas yang sering dilalui dan harus pas dalam cache, dan bagian dingin yang jarang digunakan dan dapat "di-cache".
Do-It-Yourself Eksperimen Waktu
Untuk pengukuran waktu dalam makalah ini, saya menggunakan penghitung kinerja resolusi tinggi Win32 QueryPerformanceCounter
(dan QueryPerformanceFrequency
).
Mereka mudah dipanggil melalui P/Invoke:
[System.Runtime.InteropServices.DllImport("KERNEL32")]
private static extern bool QueryPerformanceCounter(
ref long lpPerformanceCount);
[System.Runtime.InteropServices.DllImport("KERNEL32")]
private static extern bool QueryPerformanceFrequency(
ref long lpFrequency);
Anda memanggil QueryPerformanceCounter
tepat sebelum dan tepat setelah perulangan waktu Anda, mengurangi jumlah, mengalikan dengan 1,0e9, membagi berdasarkan frekuensi, membagi dengan jumlah perulangan, dan itu perkiraan waktu Anda per iterasi dalam ns.
Karena pembatasan ruang dan waktu, kami tidak mencakup penguncian, penanganan pengecualian, atau sistem keamanan akses kode. Anggap saja latihan untuk pembaca.
Ngomong-ngomong, saya memproduksi pembongkaran dalam artikel ini menggunakan Jendela Pembongkaran pada VS.NET 2003. Namun, ada trik untuk itu. Jika Anda menjalankan aplikasi Anda di debugger VS.NET, bahkan sebagai executable yang dioptimalkan yang dibangun dalam mode Rilis, aplikasi akan dijalankan dalam "mode debug" di mana pengoptimalan seperti inlining dinonaktifkan. Satu-satunya cara saya menemukan untuk mengintip kode asli yang dioptimalkan yang dipancarkan pengkompilasi JIT adalah dengan meluncurkan aplikasi pengujian saya di luar debugger, lalu melampirkannya menggunakan Debug.Processes.Attach.
Model Biaya Ruang?
Ironisnya, pertimbangan ruang menghalangi diskusi ruang secara menyeluruh. Beberapa paragraf singkat, kemudian.
Pertimbangan tingkat rendah (beberapa adalah C# (TypeAttributes.SequentialLayout default) dan x86 spesifik):
- Ukuran jenis nilai umumnya adalah ukuran total bidangnya, dengan bidang 4-byte atau lebih kecil yang selaras dengan batas alaminya.
- Dimungkinkan untuk menggunakan atribut
[StructLayout(LayoutKind.Explicit)]
dan[FieldOffset(n)]
untuk menerapkan serikat. - Ukuran jenis referensi adalah 8 byte ditambah ukuran total bidangnya, dibulatkan ke batas 4 byte berikutnya, dan dengan bidang 4 byte atau lebih kecil selaras dengan batas alaminya.
- Dalam C#, deklarasi enum dapat menentukan jenis dasar integral arbitrer (kecuali karakter)—sehingga dimungkinkan untuk menentukan enum 8-bit, 16-bit, 32-bit, dan 64-bit.
- Seperti dalam C/C++, Anda sering dapat mencukur beberapa puluh persen ruang dari objek yang lebih besar dengan mengukur bidang integral Anda dengan tepat.
- Anda dapat memeriksa ukuran jenis referensi yang dialokasikan dengan CLR Profiler.
- Objek besar (banyak lusinan KB atau lebih) dikelola dalam tumpukan objek besar terpisah, untuk menghalangi penyalinan mahal.
- Objek yang dapat diselesaikan mengambil generasi GC tambahan untuk diklaim kembali—gunakan dengan hemat dan pertimbangkan untuk menggunakan Pola Buang.
Pertimbangan gambaran besar:
- Setiap AppDomain saat ini menimbulkan overhead ruang yang substansial. Banyak struktur runtime dan Kerangka Kerja tidak dibagikan di seluruh AppDomains.
- Dalam proses, kode jitted biasanya tidak dibagikan di seluruh AppDomains. Jika runtime dihosting secara khusus, dimungkinkan untuk mengambil alih perilaku ini. Lihat dokumentasi untuk
CorBindToRuntimeEx
dan benderaSTARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN
. - Dalam peristiwa apa pun, kode jitted tidak dibagikan di seluruh proses. Jika Anda memiliki komponen yang akan dimuat ke dalam banyak proses, pertimbangkan untuk melakukan prakompilasi dengan NGEN untuk berbagi kode asli.
Refleksi
Telah dikatakan bahwa "jika Anda harus bertanya berapa biaya Refleksi, Anda tidak mampu membelinya". Jika Anda telah membaca sejauh ini, Anda tahu betapa pentingnya menanyakan biaya hal-hal apa, dan untuk mengukur biaya tersebut.
Refleksi berguna dan kuat, tetapi dibandingkan dengan kode asli jitted, itu tidak cepat atau kecil. Anda telah diperingatkan. Ukur sendiri.
Kesimpulan
Sekarang Anda tahu (kurang lebih) biaya kode terkelola apa pada tingkat terendah. Anda sekarang memiliki pemahaman dasar yang diperlukan untuk membuat trade-off implementasi yang lebih cerdas dan menulis kode terkelola yang lebih cepat.
Kita telah melihat bahwa kode terkelola jitted dapat menjadi "pedal ke logam" sebagai kode asli. Tantangan Anda adalah membuat kode dengan bijak dan memilih dengan bijaksana di antara banyak fasilitas yang kaya dan mudah digunakan dalam Kerangka Kerja
Ada pengaturan di mana performa tidak penting, dan pengaturan di mana itu adalah fitur terpenting dari produk. Pengoptimalan prematur akar semua kejahatan. Tapi begitu juga niat ceroboh terhadap efisiensi. Kau seorang profesional, seorang seniman, seorang pengrajin. Jadi pastikan Anda tahu biaya hal-hal. Jika Anda tidak tahu atau bahkan jika Anda berpikir Anda melakukannya—ukur—ukur—secara teratur.
Adapun tim CLR, kami terus bekerja untuk menyediakan platform yang secara substansial lebih produktif daripada kode asli dan namun lebih cepat daripada kode asli. Mengharapkan hal-hal menjadi lebih baik dan lebih baik. Menantikan.
Ingat janjimu.
Sumber daya
- David Stutz dkk, Shared Source CLI Essentials. O'Reilly dan Assoc., 2003. ISBN 059600351X.
- Jan Gray, C++: Di bawah Hood.
- Gregor Noriskin, Menulis Aplikasi Terkelola High-Performance : Primer, MSDN.
- Rico Mariani, Dasar-Dasar Pengumpul Sampah dan Petunjuk Performa, MSDN.
- Emmanuel Schanzer, Tips dan Trik Performa di Aplikasi .NET, MSDN.
- Emmanuel Schanzer, Pertimbangan Performa untuk Teknologi Run-Time dalam .NET Framework, MSDN.
- vadump (Platform SDK Tools), MSDN.
- .NET Show, [Managed] Code Optimization, Sept. 10, 2002, MSDN.