Dalam C #, ketika pengguna membuat List<byte>
dan menambahkan byte ke dalamnya, ada kemungkinan kehabisan ruang dan perlu mengalokasikan lebih banyak ruang. Ini mengalokasikan dua kali lipat (atau beberapa pengganda lainnya) ukuran array sebelumnya, menyalin byte lebih dan membuang referensi ke array lama. Saya tahu bahwa daftar tumbuh secara eksponensial karena setiap alokasi mahal dan ini membatasi O(log n)
alokasi, di mana hanya menambahkan 10
item tambahan setiap kali akan menghasilkan O(n)
alokasi.
Namun untuk ukuran array besar bisa ada banyak ruang yang terbuang, mungkin hampir setengah dari array. Untuk mengurangi memori saya menulis kelas serupa NonContiguousArrayList
yang digunakan List<byte>
sebagai backing store jika ada kurang dari 4MB dalam daftar, maka akan mengalokasikan tambahan byte array 4MB seiring NonContiguousArrayList
bertambahnya ukuran.
Tidak seperti List<byte>
array ini tidak bersebelahan sehingga tidak ada penyalinan data di sekitar, hanya alokasi 4M tambahan. Ketika suatu item dilihat, indeks dibagi dengan 4M untuk mendapatkan indeks array yang mengandung item tersebut, kemudian modulo 4M untuk mendapatkan indeks dalam array.
Bisakah Anda menunjukkan masalah dengan pendekatan ini? Ini daftar saya:
- Array yang tidak berdampingan tidak memiliki lokalitas cache yang menghasilkan kinerja yang buruk. Namun pada ukuran blok 4M sepertinya akan ada cukup tempat untuk caching yang baik.
- Mengakses item tidak sesederhana itu, ada tingkat tipuan ekstra. Apakah ini akan dioptimalkan? Apakah itu menyebabkan masalah cache?
- Karena ada pertumbuhan linier setelah batas 4M tercapai, Anda dapat memiliki alokasi lebih banyak daripada yang biasanya (katakanlah, maks 250 alokasi untuk memori 1GB). Tidak ada memori tambahan yang disalin setelah 4M, namun saya tidak yakin apakah alokasi tambahan lebih mahal daripada menyalin potongan memori besar.
TrimExcess
hanya akan membantu ketika daftar sudah dibuat, dan itupun masih membutuhkan ruang yang cukup untuk menyalin.Jawaban:
Pada skala yang Anda sebutkan, kekhawatiran sama sekali berbeda dari yang Anda sebutkan.
Lokalitas cache
Pola akses elemen data
YourList[k]
danYourList[k+1]
memiliki probabilitas tinggi berturut-turut (satu dari empat juta kemungkinan tidak), fakta itu tidak akan membantu kinerja jika Anda mengakses daftar Anda sepenuhnya secara acak, atau dalam langkah besar yang tidak dapat diprediksi misalnyawhile { index += random.Next(1024); DoStuff(YourList[index]); }
Interaksi dengan sistem GC
Overhead perhitungan offset alamat
Untuk mengilustrasikan alasannya:
Langkah terakhir masih mengambil bagian terbesar dari waktu.
Saran pribadi
CopyRange
fungsi, yang akan berperilaku sepertiArray.Copy
fungsi tetapi akan beroperasi di antara dua instance dari AndaNonContiguousByteArray
, atau antara satu instance dan normal lainnyabyte[]
. fungsi-fungsi ini dapat menggunakan kode SIMD (C ++ atau C #) untuk memaksimalkan pemanfaatan bandwidth memori, dan kemudian kode C # Anda dapat beroperasi pada rentang yang disalin tanpa overhead dari beberapa dereferencing atau perhitungan alamat.Masalah kegunaan dan interoperabilitas
NonContiguousByteArray
dengan pustaka C #, C ++ atau bahasa asing apa pun yang mengharapkan array byte yang berdekatan, atau array byte yang dapat disematkan.(3 * 1024 * 1024)
dan berakhir pada(5 * 1024 * 1024 - 1)
, ini berarti akses akan menjangkauchunk[0]
danchunk[1]
. Anda kemudian dapat membangun array (ukuran 2) dari byte array (ukuran 4M), pin alamat chunk ini dan meneruskannya ke kode yang mendasarinya.IList<byte>
antarmuka secara efisien:Insert
danRemove
hanya akan memakan waktu terlalu lama untuk diproses karena mereka akan membutuhkanO(N)
waktu.IEnumerable<byte>
, yaitu dapat dipindai secara berurutan dan hanya itu.sumber
Perlu dicatat bahwa C ++ sudah memiliki struktur yang setara dengan Standar
std::deque
,. Saat ini, ini direkomendasikan sebagai pilihan default untuk memerlukan urutan akses acak.Kenyataannya adalah bahwa memori yang bersebelahan hampir sepenuhnya tidak perlu begitu data melewati ukuran tertentu - garis cache hanya 64 byte dan ukuran halaman hanya 4-8KB (nilai khas saat ini). Setelah Anda mulai berbicara tentang beberapa MB itu benar-benar keluar jendela sebagai masalah. Hal yang sama berlaku untuk biaya alokasi. Harga pemrosesan semua data itu — bahkan hanya membacanya saja — mengecilkan harga alokasi itu.
Satu-satunya alasan lain untuk mengkhawatirkannya adalah untuk berinteraksi dengan C API. Tapi Anda tetap tidak bisa mendapatkan pointer ke buffer Daftar sehingga tidak ada masalah di sini.
sumber
deque
memiliki implementasi yang samastd::deque
sebenarnya sangat berkecil hati, sebagian karena implementasi perpustakaan standar MS sangat buruk.Ketika potongan memori dialokasikan pada titik waktu yang berbeda, seperti pada sub-array dalam struktur data Anda, mereka dapat ditempatkan jauh dari satu sama lain dalam memori. Apakah ini masalah atau tidak tergantung pada CPU dan sangat sulit untuk diprediksi lagi. Anda harus mengujinya.
Ini adalah ide yang bagus, dan ini sudah pernah saya gunakan di masa lalu. Tentu saja Anda hanya boleh menggunakan kekuatan dua untuk ukuran sub-array Anda dan pengalihan bit untuk divisi (dapat terjadi sebagai bagian dari optimasi). Saya menemukan jenis struktur ini sedikit lebih lambat, di mana kompiler dapat mengoptimalkan tipuan array tunggal lebih mudah. Anda harus menguji, karena jenis optimasi ini berubah setiap saat.
Keuntungan utama adalah Anda dapat berjalan lebih dekat ke batas atas memori di sistem Anda, selama Anda menggunakan jenis struktur ini secara konsisten. Selama Anda membuat struktur data Anda lebih besar, dan tidak menghasilkan sampah, Anda menghindari pengumpulan sampah tambahan yang akan terjadi untuk Daftar biasa. Untuk daftar raksasa, itu bisa membuat perbedaan besar: perbedaan antara terus berjalan, dan kehabisan memori.
Alokasi tambahan adalah masalah hanya jika potongan sub-array Anda kecil, karena ada overhead memori di setiap alokasi array.
Saya telah membuat struktur serupa untuk kamus (tabel hash). Kamus yang disediakan oleh .net framework memiliki masalah yang sama dengan Daftar. Kamus lebih sulit karena Anda harus menghindari pengulangan juga.
sumber
Dengan ukuran blok 4M bahkan satu blok tidak dijamin bersebelahan dalam memori fisik; ini lebih besar dari ukuran halaman VM biasa. Lokalitas tidak berarti pada skala itu.
Anda harus khawatir tentang tumpukan fragmentasi: jika alokasi terjadi sedemikian sehingga sebagian besar blok Anda tidak bersebelahan di tumpukan, maka ketika mereka direklamasi oleh GC, Anda akan berakhir dengan tumpukan yang mungkin terlalu terfragmentasi agar tidak sesuai dengan alokasi selanjutnya. Itu biasanya situasi yang lebih buruk karena kegagalan akan terjadi di tempat-tempat yang tidak terkait dan mungkin memaksa restart aplikasi.
sumber
List
.Saya memutar beberapa bagian paling pusat dari basis kode saya (mesin ECS) di sekitar jenis struktur data yang Anda uraikan, meskipun menggunakan blok bersebelahan yang lebih kecil (lebih seperti 4 kilobyte daripada 4 megabyte).
Ia menggunakan daftar bebas ganda untuk mencapai penyisipan dan pemindahan waktu-konstan dengan satu daftar gratis untuk blok gratis yang siap dimasukkan (blok yang tidak penuh) dan daftar sub-bebas di dalam blok untuk indeks di blok itu siap untuk direklamasi saat penyisipan.
Saya akan membahas pro dan kontra dari struktur ini. Mari kita mulai dengan beberapa kontra karena ada beberapa di antaranya:
Cons
std::vector
(struktur yang bersebelahan murni). Dan saya cukup baik dalam optimasi mikro tetapi secara konseptual ada lebih banyak pekerjaan yang harus dilakukan karena kasus umum harus terlebih dahulu memeriksa blok gratis di bagian atas daftar blok bebas, kemudian mengakses blok dan mengeluarkan indeks gratis dari blok daftar bebas, tulis elemen pada posisi bebas, dan kemudian periksa apakah blok sudah penuh dan pop blok dari daftar bebas blok jika demikian. Ini masih merupakan operasi waktu konstan tetapi dengan konstanta jauh lebih besar daripada mendorong kembali kestd::vector
.std::vector
kecuali Anda memadatkannyavector
untuk menghilangkan kelebihan kapasitas yang dihematnya. Juga saya biasanya tidak menggunakannya untuk menyimpan elemen kecil seperti itu.Pro
for_each
fungsi yang mengambil rentang pemrosesan elemen callback dalam blok hampir menyaingi kecepatan akses sekuensial denganstd::vector
(hanya seperti perbedaan 10%), jadi tidak jauh lebih efisien dalam kasus penggunaan yang paling kritis terhadap kinerja bagi saya ( sebagian besar waktu yang dihabiskan dalam mesin ECS berada dalam akses berurutan).Sekarang salah satu kelebihan terbesar bagi saya adalah menjadi sepele untuk membuat versi yang tidak dapat diubah dari struktur data ini, seperti ini:
Sejak saat itu, yang membuka semua jenis pintu untuk menulis lebih banyak fungsi tanpa efek samping yang membuatnya lebih mudah untuk mencapai pengecualian-keselamatan, keselamatan-thread, dll struktur data ini di belakang dan secara tidak sengaja, tetapi bisa dibilang salah satu manfaat terbaik yang didapatnya karena membuat mempertahankan basis kode jauh lebih mudah.
Lokalitas referensi bukan sesuatu yang menjadi perhatian Anda pada balok sebesar itu, apalagi 4 blok kilobita. Garis cache biasanya hanya 64 byte. Jika Anda ingin mengurangi kesalahan cache, maka hanya fokus pada menyelaraskan blok-blok itu dengan benar dan mendukung pola akses yang lebih berurutan bila memungkinkan.
Cara yang sangat cepat untuk mengubah pola memori akses-acak menjadi yang berurutan adalah dengan menggunakan bitset. Katakanlah Anda memiliki banyak indeks dan mereka berada dalam urutan acak. Anda bisa membajaknya dan menandai bit di bitset. Kemudian Anda dapat beralih melalui bitset dan memeriksa byte mana yang tidak nol, memeriksa, katakanlah, 64-bit pada suatu waktu. Setelah Anda menemukan satu set 64-bit yang setidaknya satu bit diatur, Anda dapat menggunakan instruksi FFS untuk dengan cepat menentukan bit apa yang ditetapkan. Bit memberi tahu Anda apa indeks yang harus Anda akses, kecuali sekarang Anda mendapatkan indeks diurutkan dalam urutan berurutan.
Ini memiliki beberapa overhead tetapi bisa menjadi pertukaran yang bermanfaat dalam beberapa kasus, terutama jika Anda akan berulang kali mengulangi indeks ini.
Tidak, itu tidak dapat dioptimalkan jauh. Akses acak, setidaknya, akan selalu lebih mahal dengan struktur ini. Itu sering tidak akan meningkatkan cache Anda meleset sebanyak itu karena Anda akan cenderung mendapatkan lokalitas temporal tinggi dengan array pointer ke blok, terutama jika jalur eksekusi kasus umum Anda menggunakan pola akses berurutan.
Dalam prakteknya penyalinan sering lebih cepat karena ini merupakan kasus yang jarang, hanya terjadi sesuatu seperti
log(N)/log(2)
kali total sementara secara bersamaan menyederhanakan kasus umum yang murah di mana Anda hanya dapat menulis elemen ke array berkali-kali sebelum menjadi penuh dan perlu dialokasikan kembali. Jadi biasanya Anda tidak akan mendapatkan penyisipan yang lebih cepat dengan jenis struktur ini karena kerja kasus umum lebih mahal bahkan jika itu tidak harus berurusan dengan kasus langka yang mahal untuk realokasi array besar.Daya tarik utama dari struktur ini bagi saya terlepas dari semua kontra adalah mengurangi penggunaan memori, tidak harus khawatir tentang OOM, mampu menyimpan indeks dan pointer yang tidak menjadi batal, konkurensi, dan tidak dapat diubah. Sangat menyenangkan untuk memiliki struktur data di mana Anda dapat menyisipkan dan menghapus hal-hal dalam waktu yang konstan sementara itu membersihkan sendiri untuk Anda dan tidak membatalkan pointer dan indeks ke dalam struktur.
sumber