boost :: flat_map dan kinerjanya dibandingkan dengan map dan unordered_map

103

Sudah menjadi pengetahuan umum dalam pemrograman bahwa lokalitas memori meningkatkan kinerja banyak karena cache ditemukan. Baru-baru ini saya menemukan tentang boost::flat_mapimplementasi peta berbasis vektor. Tampaknya tidak sepopuler biasanya map/ unordered_mapjadi saya belum bisa menemukan perbandingan kinerja apa pun. Bagaimana cara membandingkannya dan apa kasus penggunaan terbaik untuk itu?

Terima kasih!

naumcho
sumber
Penting untuk dicatat bahwa boost.org/doc/libs/1_70_0/doc/html/boost/container/… mengklaim penyisipan acak membutuhkan waktu logaritmik, menyiratkan mengisi boost :: flat_map (dengan memasukkan n elemen acak) membutuhkan O (n log n ) waktu. Itu bohong, terbukti dari grafik dalam jawaban @ v.oddou di bawah ini: sisipan acak adalah O (n), dan n di antaranya membutuhkan waktu O (n ^ 2).
Don Hatch
@DonHatch Bagaimana jika melaporkan ini di sini: github.com/boostorg/container/issues ? (Ini mungkin memberikan hitungan jumlah perbandingan, tapi itu memang menyesatkan jika tidak disertai dengan hitungan jumlah gerakan)
Marc Glisse

Jawaban:

188

Saya baru-baru ini menjalankan tolok ukur pada struktur data yang berbeda di perusahaan saya, jadi saya merasa perlu berhenti bicara. Sangat rumit untuk mengukur sesuatu dengan benar.

Pembandingan

Di web kita jarang menemukan (jika pernah) patokan yang direkayasa dengan baik. Sampai saat ini saya hanya menemukan tolok ukur yang dilakukan dengan cara jurnalis (cukup cepat dan menyapu puluhan variabel di bawah karpet).

1) Anda perlu mempertimbangkan tentang pemanasan cache

Kebanyakan orang yang menjalankan benchmark takut akan ketidaksesuaian pengatur waktu, oleh karena itu mereka menjalankan barang mereka ribuan kali dan menghabiskan seluruh waktu, mereka berhati-hati untuk mengambil ribuan kali yang sama untuk setiap operasi, dan kemudian menganggapnya sebanding.

Sebenarnya, di dunia nyata ini tidak masuk akal, karena cache Anda tidak akan hangat, dan operasi Anda kemungkinan besar hanya akan dipanggil sekali. Oleh karena itu Anda perlu melakukan benchmark menggunakan RDTSC, dan mengatur waktu panggilan mereka hanya sekali. Intel telah membuat makalah yang menjelaskan cara menggunakan RDTSC (menggunakan instruksi cpuid untuk membersihkan pipeline, dan memanggilnya setidaknya 3 kali di awal program untuk menstabilkannya).

2) Pengukuran akurasi RDTSC

Saya juga merekomendasikan melakukan ini:

u64 g_correctionFactor;  // number of clocks to offset after each measurement to remove the overhead of the measurer itself.
u64 g_accuracy;

static u64 const errormeasure = ~((u64)0);

#ifdef _MSC_VER
#pragma intrinsic(__rdtsc)
inline u64 GetRDTSC()
{
    int a[4];
    __cpuid(a, 0x80000000);  // flush OOO instruction pipeline
    return __rdtsc();
}

inline void WarmupRDTSC()
{
    int a[4];
    __cpuid(a, 0x80000000);  // warmup cpuid.
    __cpuid(a, 0x80000000);
    __cpuid(a, 0x80000000);

    // measure the measurer overhead with the measurer (crazy he..)
    u64 minDiff = LLONG_MAX;
    u64 maxDiff = 0;   // this is going to help calculate our PRECISION ERROR MARGIN
    for (int i = 0; i < 80; ++i)
    {
        u64 tick1 = GetRDTSC();
        u64 tick2 = GetRDTSC();
        minDiff = std::min(minDiff, tick2 - tick1);   // make many takes, take the smallest that ever come.
        maxDiff = std::max(maxDiff, tick2 - tick1);
    }
    g_correctionFactor = minDiff;

    printf("Correction factor %llu clocks\n", g_correctionFactor);

    g_accuracy = maxDiff - minDiff;
    printf("Measurement Accuracy (in clocks) : %llu\n", g_accuracy);
}
#endif

Ini adalah pengukur perbedaan, dan ini akan membutuhkan nilai minimum dari semua nilai terukur, untuk menghindari mendapatkan -10 ** 18 (64 bit nilai negatif pertama) dari waktu ke waktu.

Perhatikan penggunaan intrinsik dan bukan perakitan inline. Perakitan inline pertama jarang didukung oleh kompiler saat ini, tetapi yang lebih buruk dari semuanya, compiler membuat penghalang pemesanan penuh di sekitar perakitan inline karena tidak dapat menganalisis bagian dalam secara statis, jadi ini adalah masalah untuk membandingkan hal-hal dunia nyata, terutama saat memanggil barang-barang hanya sekali. Jadi intrinsik cocok di sini, karena tidak merusak pengurutan ulang instruksi compiler.

3) parameter

Masalah terakhir adalah orang biasanya menguji terlalu sedikit variasi skenario. Kinerja penampung dipengaruhi oleh:

  1. Alokator
  2. ukuran tipe yang terkandung
  3. biaya pelaksanaan operasi penyalinan, operasi penugasan, operasi pemindahan, operasi konstruksi, dari jenis yang terkandung.
  4. jumlah elemen dalam wadah (ukuran masalah)
  5. jenis memiliki operasi 3.-sepele
  6. jenisnya adalah POD

Poin 1 penting karena container memang mengalokasikan dari waktu ke waktu, dan sangat penting jika mereka mengalokasikan menggunakan CRT "baru" atau operasi yang ditentukan pengguna, seperti alokasi kumpulan atau freelist atau lainnya ...

( untuk orang yang tertarik tentang pt 1, bergabunglah dengan utas misteri di gamedev tentang dampak kinerja pengalokasi sistem )

Poin 2 adalah karena beberapa kontainer (katakanlah A) akan kehilangan waktu untuk menyalin barang-barang, dan semakin besar jenisnya semakin besar overhead. Masalahnya adalah ketika membandingkan dengan kontainer B lain, A mungkin menang atas B untuk tipe kecil, dan kalah untuk tipe yang lebih besar.

Poin 3 sama dengan poin 2, hanya saja biaya dikalikan dengan beberapa faktor pembobotan.

Poin 4 adalah pertanyaan tentang O besar yang dicampur dengan masalah cache. Beberapa container dengan kompleksitas buruk sebagian besar dapat mengungguli container dengan kompleksitas rendah untuk sejumlah kecil jenis (seperti mapvs. vector, karena lokalitas cache-nya bagus, tetapi mapmemecah memori). Dan kemudian di beberapa titik persimpangan, mereka akan kehilangan, karena ukuran keseluruhan yang terkandung mulai "bocor" ke memori utama dan menyebabkan cache hilang, ditambah fakta bahwa kompleksitas asimtotik dapat mulai dirasakan.

Poin 5 adalah tentang kompiler yang dapat menghilangkan hal-hal yang kosong atau sepele pada waktu kompilasi. Ini dapat sangat mengoptimalkan beberapa operasi, karena penampung memiliki kerangka, oleh karena itu setiap jenis akan memiliki profil kinerjanya sendiri.

Poin 6 sama dengan poin 5, POD dapat mengambil keuntungan dari fakta bahwa konstruksi salinan hanyalah sebuah memcpy, dan beberapa container dapat memiliki implementasi khusus untuk kasus ini, menggunakan spesialisasi template parsial, atau SFINAE untuk memilih algoritme sesuai dengan ciri-ciri T.

Tentang peta datar

Rupanya peta datar adalah pembungkus vektor yang diurutkan, seperti Loki AssocVector, tetapi dengan beberapa modernisasi tambahan yang hadir dengan C ++ 11, memanfaatkan semantik gerakan untuk mempercepat penyisipan dan penghapusan elemen tunggal.

Ini masih merupakan wadah yang dipesan. Kebanyakan orang biasanya tidak membutuhkan bagian pemesanan, oleh karena itu keberadaan unordered...

Pernahkah Anda mempertimbangkan bahwa mungkin Anda membutuhkan flat_unorderedmap? yang akan menjadi sesuatu seperti google::sparse_mapatau sesuatu seperti itu — peta hash alamat terbuka.

Masalah dari peta hash alamat terbuka adalah bahwa pada saat rehashmereka harus menyalin semua yang ada di sekitar ke tanah datar baru yang diperluas, sedangkan peta standar yang tidak berurutan hanya harus membuat ulang indeks hash, sementara data yang dialokasikan tetap di tempatnya. Kerugiannya tentu saja adalah bahwa memori terfragmentasi seperti neraka.

Kriteria pengulangan dalam peta hash alamat terbuka adalah jika kapasitas melebihi ukuran vektor keranjang dikalikan dengan faktor beban.

Faktor beban tipikal adalah 0.8; oleh karena itu, Anda perlu memperhatikan hal itu, jika Anda dapat membuat ukuran awal peta hash Anda sebelum mengisinya, selalu lakukan pra-ukuran ke: intended_filling * (1/0.8) + epsilonini akan memberi Anda jaminan bahwa Anda tidak perlu mengulang dan menyalin ulang semuanya secara palsu selama pengisian.

Keuntungan dari peta alamat tertutup ( std::unordered..) adalah Anda tidak perlu peduli dengan parameter tersebut.

Tapi itu boost::flat_mapadalah vektor yang dipesan; Oleh karena itu, ia akan selalu memiliki kompleksitas asimtotik log (N), yang kurang baik daripada peta hash alamat terbuka (waktu konstan diamortisasi). Anda harus mempertimbangkan itu juga.

Hasil benchmark

Ini adalah tes yang melibatkan peta yang berbeda (dengan intkunci dan __int64/ somestructsebagai nilai) dan std::vector.

jenis informasi yang diuji:

typeid=__int64 .  sizeof=8 . ispod=yes
typeid=struct MediumTypePod .  sizeof=184 . ispod=yes

Insersi

EDIT:

Hasil saya sebelumnya menyertakan bug: mereka benar-benar menguji penyisipan yang dipesan, yang menunjukkan perilaku yang sangat cepat untuk peta datar.
Saya meninggalkan hasil itu nanti di halaman ini karena mereka menarik.
Ini tes yang benar: masukkan acak 100

masukkan acak 10000

Saya telah memeriksa implementasinya, tidak ada yang namanya ditangguhkan yang diterapkan di peta datar di sini. Setiap penyisipan diurutkan dengan cepat, oleh karena itu tolok ukur ini menunjukkan kecenderungan asimtotik:

peta: O (N * log (N))
hashmaps: O (N)
vektor dan flatmaps: O (N * N)

Peringatan : akhirat 2 tes untuk std::mapdan kedua flat_maps kereta uji dan benar-benar memerintahkan penyisipan (vs penyisipan acak untuk wadah lainnya ya itu membingungkan maaf.):
sisipan campuran dari 100 elemen tanpa syarat

Kita dapat melihat bahwa penyisipan berurutan, menghasilkan dorongan ke belakang, dan sangat cepat. Namun, dari hasil benchmark saya yang tidak dipetakan, saya juga dapat mengatakan bahwa ini tidak mendekati optimalitas absolut untuk penyisipan belakang. Pada elemen 10k, optimalitas penyisipan balik yang sempurna diperoleh pada vektor yang telah dipesan sebelumnya. Yang memberi kita 3 juta siklus; kami mengamati 4,8 juta di sini untuk penyisipan yang dipesan ke fileflat_map (karena itu 160% dari optimal).

sisipan campuran dari 10.000 elemen tanpa reservasi Analisis: ingat ini adalah 'sisipan acak' untuk vektor, jadi 1 miliar siklus besar berasal dari keharusan menggeser setengah (rata-rata) data ke atas (satu elemen demi satu elemen) pada setiap penyisipan.

Pencarian acak dari 3 elemen (jam dinormalisasi menjadi 1)

dalam ukuran = 100

rand mencari dalam wadah 100 elemen

dalam ukuran = 10000

rand mencari dalam wadah 10.000 elemen

Pengulangan

lebih dari ukuran 100 (hanya tipe MediumPod)

Iterasi lebih dari 100 polong sedang

lebih dari ukuran 10000 (hanya tipe MediumPod)

Iterasi lebih dari 10.000 polong sedang

Butir garam terakhir

Pada akhirnya saya ingin kembali ke "Benchmarking §3 Pt1" (pengalokasi sistem). Dalam percobaan baru-baru ini yang saya lakukan seputar kinerja peta hash alamat terbuka yang saya kembangkan , saya mengukur kesenjangan kinerja lebih dari 3000% antara Windows 7 dan Windows 8 pada beberapa std::unordered_mapkasus penggunaan ( dibahas di sini ).
Yang membuat saya ingin memperingatkan pembaca tentang hasil di atas (dibuat di Win7): jarak tempuh Anda mungkin berbeda.

salam Hormat

v.oddou
sumber
1
oh, kalau begitu masuk akal. Jaminan waktu diamortisasi konstan Vector hanya berlaku saat memasukkan di akhir. Memasukkan pada posisi acak harus rata-rata O (n) per penyisipan karena segala sesuatu setelah titik penyisipan harus dipindahkan ke depan. Jadi kami mengharapkan perilaku kuadrat dalam tolok ukur Anda yang meledak cukup cepat, bahkan untuk N. kecil Implementasi gaya AssocVector mungkin menunda pengurutan hingga pencarian diperlukan, misalnya, daripada mengurutkan setelah setiap penyisipan. Sulit dikatakan tanpa melihat patokan Anda.
Billy ONeal
1
@BillyONeal: Ah, kami memeriksa kode dengan rekan kerja, dan menemukan pelakunya, penyisipan "acak" saya dipesan karena saya menggunakan std :: set untuk memastikan kunci yang dimasukkan unik. Ini adalah kebodohan biasa, tapi saya memperbaikinya dengan random_shuffle, saya membangun kembali sekarang dan beberapa hasil baru akan segera muncul setelah pengeditan. Jadi, pengujian dalam keadaan saat ini membuktikan bahwa "penyisipan yang teratur" sangat cepat.
v.oddou
3
"Intel memiliki kertas" ← dan di sini itu
isomorphismes
5
Mungkin saya melewatkan sesuatu yang jelas, tetapi saya tidak mengerti mengapa pencarian acak lebih lambat flat_mapdibandingkan dengan std::map- adakah yang bisa menjelaskan hasil ini?
boycy
1
Saya akan menjelaskannya sebagai overhead spesifik dari implementasi boost kali ini, dan bukan karakter intrinsik flat_mapsebagai wadah. Karena Aska::versinya lebih cepat dari pada std::maplookup. Membuktikan bahwa masih ada ruang untuk pengoptimalan. Performa yang diharapkan secara asimtotik sama, tetapi mungkin sedikit lebih baik berkat lokalitas cache. Dengan set ukuran tinggi, mereka harus bertemu.
v.oddou
6

Dari dokumen, sepertinya ini analogi Loki::AssocVectoryang saya pengguna cukup berat. Karena didasarkan pada vektor, ia memiliki karakteristik vektor, yaitu:

  • Iterator menjadi tidak valid setiap kali sizeberkembang capacity.
  • Ketika tumbuh melampaui capacityitu perlu dialokasikan kembali dan memindahkan objek ke atas, yaitu penyisipan tidak dijamin waktu konstan kecuali untuk kasus khusus memasukkan pada endsaatcapacity > size
  • Pencarian lebih cepat daripada std::mapkarena lokalitas cache, pencarian biner yang memiliki karakteristik kinerja yang sama seperti yang std::maplainnya
  • Menggunakan lebih sedikit memori karena ini bukan pohon biner yang ditautkan
  • Itu tidak pernah menyusut kecuali Anda secara paksa menyuruhnya (karena itu memicu realokasi)

Penggunaan terbaik adalah saat Anda mengetahui jumlah elemen sebelumnya (sehingga Anda bisa reservedimuka), atau saat penyisipan / penghapusan jarang terjadi tetapi pencarian sering dilakukan. Pembatalan Iterator membuatnya sedikit rumit dalam beberapa kasus penggunaan sehingga tidak dapat dipertukarkan dalam hal ketepatan program.

Ylisar
sumber
1
false :) pengukuran di atas menunjukkan peta yang lebih cepat daripada flat_map untuk operasi pencarian, saya rasa perlu meningkatkan ppl untuk memperbaiki implementasinya, tetapi secara teori Anda benar.
NoSenseEtAl