boost :: flat_map และประสิทธิภาพเมื่อเทียบกับแผนที่และ unordered_map


103

เป็นความรู้ทั่วไปในการเขียนโปรแกรมว่าตำแหน่งหน่วยความจำช่วยเพิ่มประสิทธิภาพได้มากเนื่องจากแคชฮิต เมื่อเร็ว ๆ นี้ฉันได้ค้นพบเกี่ยวกับboost::flat_mapการใช้งานแผนที่แบบเวกเตอร์ ดูเหมือนจะไม่ได้รับความนิยมเท่าของคุณทั่วไปmap/ unordered_mapดังนั้นฉันจึงไม่สามารถหาการเปรียบเทียบประสิทธิภาพใด ๆ ได้ เปรียบเทียบอย่างไรและอะไรคือกรณีการใช้งานที่ดีที่สุดสำหรับมัน?

ขอบคุณ!


สิ่งสำคัญที่ควรทราบว่าboost.org/doc/libs/1_70_0/doc/html/boost/container/…การอ้างว่าการแทรกแบบสุ่มใช้เวลาลอการิทึมซึ่งหมายความว่าการเติม Boost :: flat_map (โดยการใส่ n องค์ประกอบแบบสุ่ม) ใช้ O (n log n ) เวลา มันโกหกดังที่เห็นได้ชัดจากกราฟในคำตอบของ @ v.oddou ด้านล่าง: การแทรกแบบสุ่มคือ O (n) และ n ของพวกเขาใช้เวลา O (n ^ 2)
Don Hatch

@ DonHatch ลองรายงานสิ่งนี้ที่นี่: github.com/boostorg/container/issues ? (อาจเป็นการนับจำนวนการเปรียบเทียบ แต่นั่นเป็นการทำให้เข้าใจผิดอย่างแท้จริงหากไม่ได้มาพร้อมกับการนับจำนวนการเคลื่อนไหว)
Marc Glisse

คำตอบ:


188

เมื่อเร็ว ๆ นี้ฉันได้ใช้เกณฑ์มาตรฐานเกี่ยวกับโครงสร้างข้อมูลที่แตกต่างกันที่ บริษัท ของฉันดังนั้นฉันจึงรู้สึกว่าจำเป็นต้องพูดอะไรสักอย่าง การเปรียบเทียบบางสิ่งให้ถูกต้องมีความซับซ้อนมาก

การเปรียบเทียบ

บนเว็บเราแทบไม่พบ (ถ้าเคย) เกณฑ์มาตรฐานที่ออกแบบมาอย่างดี จนถึงวันนี้ฉันพบเพียงเกณฑ์มาตรฐานที่ทำแบบนักข่าวเท่านั้น (ค่อนข้างเร็วและกวาดตัวแปรหลายสิบตัวไว้ใต้พรม)

1)คุณต้องพิจารณาเกี่ยวกับการอุ่นแคช

คนส่วนใหญ่ที่ใช้เกณฑ์มาตรฐานมักกลัวความคลาดเคลื่อนของตัวจับเวลาดังนั้นพวกเขาจึงเรียกใช้สิ่งต่างๆเป็นพัน ๆ ครั้งและใช้เวลาตลอดเวลาพวกเขาเพียงแค่ระมัดระวังที่จะใช้เวลาหลายพันเท่าสำหรับการดำเนินการทุกครั้งจากนั้นจึงพิจารณาสิ่งที่เทียบเคียงได้

ความจริงก็คือในโลกแห่งความเป็นจริงมันมีเหตุผลเล็กน้อยเพราะแคชของคุณจะไม่อุ่นและการทำงานของคุณจะถูกเรียกเพียงครั้งเดียว ดังนั้นคุณต้องเปรียบเทียบโดยใช้ RDTSC และเวลาเรียกสิ่งเหล่านี้เพียงครั้งเดียว Intel ได้จัดทำเอกสารอธิบายวิธีการใช้ RDTSC (โดยใช้คำสั่ง cpuid เพื่อล้างไปป์ไลน์และเรียกใช้อย่างน้อย 3 ครั้งที่จุดเริ่มต้นของโปรแกรมเพื่อทำให้เสถียร)

2) การวัดความถูกต้องของ RDTSC

ฉันขอแนะนำให้ทำสิ่งนี้:

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

นี่เป็นตัววัดความคลาดเคลื่อนและจะใช้ค่าต่ำสุดของค่าที่วัดได้ทั้งหมดเพื่อหลีกเลี่ยงการได้รับ a -10 ** 18 (ค่าเชิงลบ 64 บิตแรก) เป็นครั้งคราว

สังเกตการใช้อินทรินซิคไม่ใช่การประกอบแบบอินไลน์ คอมไพเลอร์แบบอินไลน์แรกแทบไม่ได้รับการสนับสนุนในปัจจุบัน แต่ที่แย่กว่านั้นคือคอมไพเลอร์สร้างอุปสรรคในการสั่งซื้อเต็มรูปแบบรอบ ๆ แอสเซมบลีแบบอินไลน์เนื่องจากไม่สามารถวิเคราะห์ด้านในแบบคงที่ได้ดังนั้นนี่จึงเป็นปัญหาในการเปรียบเทียบสิ่งต่าง ๆ ในโลกแห่งความเป็นจริงโดยเฉพาะอย่างยิ่งเมื่อเรียกสิ่งต่างๆ ครั้งเดียว. ดังนั้น intrinsic จึงเหมาะกับที่นี่เพราะมันไม่ทำลายคอมไพเลอร์ที่สั่งซื้อใหม่ฟรี

3)พารามิเตอร์

ปัญหาสุดท้ายคือผู้คนมักจะทดสอบสถานการณ์ที่แตกต่างกันน้อยเกินไป ประสิทธิภาพของคอนเทนเนอร์ได้รับผลกระทบจาก:

  1. ผู้จัดสรร
  2. ขนาดของประเภทที่มีอยู่
  3. ค่าใช้จ่ายในการดำเนินการคัดลอกการดำเนินการมอบหมายการย้ายการดำเนินการก่อสร้างประเภทที่มีอยู่
  4. จำนวนองค์ประกอบในคอนเทนเนอร์ (ขนาดของปัญหา)
  5. ประเภทมี 3 การดำเนินการเล็กน้อย
  6. ประเภทคือ POD

จุดที่ 1 มีความสำคัญเนื่องจากคอนเทนเนอร์จะจัดสรรเป็นครั้งคราวและจะมีความสำคัญมากหากพวกเขาจัดสรรโดยใช้ CRT "ใหม่" หรือการดำเนินการที่ผู้ใช้กำหนดเองเช่นการจัดสรรพูลหรือฟรีลิสต์หรืออื่น ๆ ...

( สำหรับผู้ที่สนใจเกี่ยวกับ pt 1 เข้าร่วมเธรดลึกลับใน gamedevเกี่ยวกับผลกระทบด้านประสิทธิภาพของตัวจัดสรรระบบ )

จุดที่ 2 เป็นเพราะคอนเทนเนอร์บางตัว (พูดว่า A) จะเสียเวลาในการคัดลอกสิ่งต่างๆรอบตัวและยิ่งประเภทใหญ่ขึ้นค่าใช้จ่ายก็จะยิ่งใหญ่ขึ้น ปัญหาคือเมื่อเปรียบเทียบกับคอนเทนเนอร์ B อื่น A อาจชนะ B สำหรับประเภทเล็กและแพ้สำหรับประเภทที่ใหญ่กว่า

จุดที่ 3 เหมือนกับจุดที่ 2 ยกเว้นว่าจะคูณต้นทุนด้วยปัจจัยถ่วงน้ำหนัก

จุดที่ 4 เป็นคำถามเกี่ยวกับ O ขนาดใหญ่ที่ผสมกับปัญหาแคช คอนเทนเนอร์ที่มีความซับซ้อนไม่ดีบางส่วนสามารถทำงานได้ดีกว่าคอนเทนเนอร์ที่มีความซับซ้อนต่ำสำหรับประเภทจำนวนน้อย (เช่นmapเทียบกับvectorเนื่องจากตำแหน่งของแคชเป็นสิ่งที่ดี แต่mapทำให้หน่วยความจำแยกส่วน) จากนั้นเมื่อถึงจุดตัดบางจุดพวกเขาจะสูญเสียเนื่องจากขนาดโดยรวมที่มีอยู่เริ่ม "รั่ว" ไปยังหน่วยความจำหลักและทำให้แคชขาดหายไปบวกกับความจริงที่ว่าความซับซ้อนของ asymptotic สามารถเริ่มสัมผัสได้

จุดที่ 5 เป็นเรื่องเกี่ยวกับคอมไพเลอร์ที่สามารถกำจัดสิ่งที่ว่างเปล่าหรือไม่สำคัญในเวลาคอมไพล์ สิ่งนี้สามารถเพิ่มประสิทธิภาพการดำเนินการบางอย่างได้อย่างมากเนื่องจากคอนเทนเนอร์เป็นเทมเพลตดังนั้นแต่ละประเภทจะมีโปรไฟล์ประสิทธิภาพของตัวเอง

จุดที่ 6 เช่นเดียวกับจุดที่ 5 POD จะได้รับประโยชน์จากข้อเท็จจริงที่ว่าการสร้างสำเนาเป็นเพียง memcpy และคอนเทนเนอร์บางส่วนอาจมีการใช้งานเฉพาะสำหรับกรณีเหล่านี้โดยใช้ความเชี่ยวชาญพิเศษของเทมเพลตบางส่วนหรือ SFINAE เพื่อเลือกอัลกอริทึมตามลักษณะของ T.

เกี่ยวกับแผนที่แบน

เห็นได้ชัดว่าแผนที่แบบแบนเป็นเวคเตอร์ที่เรียงลำดับเช่น Loki AssocVector แต่ด้วยความทันสมัยเพิ่มเติมบางอย่างที่มาพร้อมกับ C ++ 11 การใช้ประโยชน์จากความหมายการเคลื่อนที่เพื่อเร่งการแทรกและลบองค์ประกอบเดี่ยว

นี่ยังคงเป็นตู้คอนเทนเนอร์ตามสั่ง unordered..คนส่วนใหญ่มักจะไม่จำเป็นต้องสั่งซื้อส่วนหนึ่งดังนั้นการดำรงอยู่ของ

คุณคิดว่าบางทีคุณอาจต้องการflat_unorderedmap? ซึ่งจะเป็นแบบนั้นgoogle::sparse_mapหรือแบบนั้น - แผนที่แฮชที่อยู่แบบเปิด

ปัญหาของแผนที่แฮชที่อยู่แบบเปิดคือในขณะที่rehashพวกเขาต้องคัดลอกทุกอย่างรอบ ๆ ไปยังพื้นที่ราบที่ขยายใหม่ในขณะที่แผนที่ที่ไม่มีการเรียงลำดับมาตรฐานจะต้องสร้างดัชนีแฮชขึ้นใหม่ในขณะที่ข้อมูลที่จัดสรรจะยังคงอยู่ ข้อเสียของหลักสูตรคือความทรงจำที่กระจัดกระจายเหมือนนรก

เกณฑ์ของการ rehash ในแผนที่แฮชแอดเดรสแบบเปิดคือเมื่อความจุเกินขนาดของเวกเตอร์ที่เก็บข้อมูลคูณด้วยโหลดแฟกเตอร์

ปัจจัยโหลดทั่วไปคือ0.8; ดังนั้นคุณต้องใส่ใจในเรื่องนี้หากคุณสามารถปรับขนาดแผนที่แฮชของคุณล่วงหน้าก่อนที่จะเติมได้ให้กำหนดขนาดล่วงหน้าไว้ที่: intended_filling * (1/0.8) + epsilonสิ่งนี้จะทำให้คุณรับประกันได้ว่าจะไม่ต้องทำการซ่อมแซมและคัดลอกทุกอย่างอย่างปลอมแปลงในระหว่างการเติม

ข้อดีของแผนที่ที่อยู่แบบปิด ( std::unordered..) คือคุณไม่ต้องสนใจพารามิเตอร์เหล่านั้น

แต่boost::flat_mapเป็นเวกเตอร์สั่ง ดังนั้นมันจะมีความซับซ้อนของ asymptotic log (N) เสมอซึ่งดีน้อยกว่าแผนที่แฮชแอดเดรสที่เปิดอยู่ (เวลาคงที่ที่ตัดจำหน่าย) คุณควรพิจารณาเช่นกัน

ผลการเปรียบเทียบ

นี่คือการทดสอบที่เกี่ยวข้องกับแผนที่ที่แตกต่าง (มีintที่สำคัญและ__int64/ somestructเป็นค่า) std::vectorและ

ข้อมูลประเภททดสอบ:

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

การแทรก

แก้ไข:

ผลลัพธ์ก่อนหน้าของฉันมีจุดบกพร่อง: พวกเขาทดสอบการแทรกตามคำสั่งซึ่งแสดงพฤติกรรมที่รวดเร็วมากสำหรับแผนที่แบบแบน
ฉันทิ้งผลลัพธ์เหล่านั้นไว้ในหน้านี้ในภายหลังเพราะมันน่าสนใจ
นี่คือการทดสอบที่ถูกต้อง: แทรกสุ่ม 100

แทรกสุ่ม 10,000

ฉันได้ตรวจสอบการใช้งานแล้วไม่มีสิ่งที่เรียกว่าการจัดเรียงรอการตัดบัญชีที่ใช้งานในแผนที่แบบแบนที่นี่ การแทรกแต่ละครั้งจัดเรียงอย่างรวดเร็วดังนั้นเกณฑ์มาตรฐานนี้จึงแสดงแนวโน้มที่ไม่แสดงอาการ:

แผนที่:
แฮชแมป O (N * log (N)) :
เวกเตอร์O (N) และแฟลตแมป: O (N * N)

คำเตือน : ปรโลก 2 การทดสอบstd::mapและทั้งสองflat_mapถูกจัดรถทดสอบจริงและได้รับคำสั่งแทรก (VS แทรกสุ่มสำหรับภาชนะอื่น ๆ ใช่มันสับสนเสียใจ.)
เม็ดมีดผสม 100 ชิ้นโดยไม่ต้องจอง

เราจะเห็นว่าการแทรกตามลำดับส่งผลให้เกิดการดันกลับและรวดเร็วมาก อย่างไรก็ตามจากผลลัพธ์ที่ไม่ได้จัดทำแผนภูมิของเกณฑ์มาตรฐานของฉันฉันยังสามารถพูดได้ว่านี่ไม่ได้อยู่ใกล้กับความเหมาะสมที่แท้จริงสำหรับการแทรกกลับ ที่องค์ประกอบ 10k การเพิ่มประสิทธิภาพการแทรกกลับที่สมบูรณ์แบบจะได้รับบนเวกเตอร์ที่จองไว้ล่วงหน้า ซึ่งทำให้เรา 3 ล้านล้านรอบ; เราสังเกต 4.8M ที่นี่สำหรับการแทรกคำสั่งลงในflat_map(ดังนั้น 160% ของที่ดีที่สุด)

เม็ดมีดผสม 10,000 ชิ้นโดยไม่ต้องจอง การวิเคราะห์: จำไว้ว่านี่คือ 'การแทรกแบบสุ่ม' สำหรับเวกเตอร์ดังนั้น 1 พันล้านรอบขนาดใหญ่จึงมาจากการต้องเลื่อนข้อมูลครึ่งหนึ่ง (โดยเฉลี่ย) ขึ้นไป (หนึ่งองค์ประกอบต่อหนึ่งองค์ประกอบ) ในการแทรกแต่ละครั้ง

การค้นหา 3 องค์ประกอบแบบสุ่ม (เปลี่ยนนาฬิกาเป็น 1)

ขนาด = 100

ค้นหาแรนด์ภายในคอนเทนเนอร์ 100 องค์ประกอบ

ขนาด = 10000

ค้นหาแรนด์ภายในคอนเทนเนอร์ 10,000 องค์ประกอบ

การทำซ้ำ

มากกว่าขนาด 100 (เฉพาะประเภท MediumPod)

การทำซ้ำมากกว่า 100 ฝักขนาดกลาง

มากกว่าขนาด 10,000 (เฉพาะประเภท MediumPod)

การทำซ้ำมากกว่า 10,000 ฝักขนาดกลาง

เกลือเม็ดสุดท้าย

ในที่สุดฉันก็อยากกลับมาที่ "Benchmarking §3 Pt1" (ตัวจัดสรรระบบ) ในการทดลองล่าสุดฉันกำลังทำเกี่ยวกับประสิทธิภาพของแผนที่แฮชที่อยู่แบบเปิดที่ฉันพัฒนาขึ้นฉันวัดช่องว่างประสิทธิภาพมากกว่า 3000% ระหว่าง Windows 7 และ Windows 8 ในบางstd::unordered_mapกรณีการใช้งาน ( อธิบายที่นี่ )
ซึ่งทำให้ฉันต้องการเตือนผู้อ่านเกี่ยวกับผลลัพธ์ข้างต้น (พวกเขาทำบน Win7): ระยะทางของคุณอาจแตกต่างกันไป

ขอแสดงความนับถืออย่างสูง


1
โอ้ในกรณีนี้มันก็สมเหตุสมผล เวลาตัดจำหน่ายคงที่ของเวกเตอร์รับประกันเฉพาะเมื่อใส่ที่ส่วนท้ายเท่านั้น การแทรกในตำแหน่งสุ่มควรมีค่าเฉลี่ย O (n) ต่อเม็ดมีดเนื่องจากทุกอย่างหลังจากจุดแทรกจะต้องเคลื่อนไปข้างหน้า ดังนั้นเราจึงคาดว่าพฤติกรรมกำลังสองในเกณฑ์มาตรฐานของคุณซึ่งจะระเบิดได้ค่อนข้างเร็วแม้สำหรับ N ขนาดเล็กการใช้งานสไตล์ AssocVector อาจจะเลื่อนการเรียงลำดับไปจนกว่าจะต้องมีการค้นหาตัวอย่างเช่นแทนที่จะจัดเรียงหลังจากการแทรกทุกครั้ง ยากที่จะพูดโดยไม่เห็นเกณฑ์มาตรฐานของคุณ
Billy ONeal

1
@BillyONeal: อ่าเราตรวจสอบโค้ดกับเพื่อนร่วมงานแล้วพบผู้กระทำผิดการแทรก "สุ่ม" ของฉันได้รับคำสั่งเพราะฉันใช้ std :: set เพื่อให้แน่ใจว่าคีย์ที่แทรกนั้นไม่ซ้ำกัน นี่เป็นความไม่เหมาะสมธรรมดา แต่ฉันแก้ไขแล้วด้วย random_shuffle ฉันกำลังสร้างใหม่ตอนนี้และผลลัพธ์ใหม่บางส่วนจะปรากฏขึ้นทันทีที่มีการแก้ไข ดังนั้นการทดสอบในสถานะปัจจุบันจึงพิสูจน์ได้ว่า "การแทรกคำสั่ง" นั้นเร็วมาก
v.oddou

3
"Intel มีกระดาษ" ←และนี่คือ
isomorphismes

5
บางทีฉันอาจพลาดบางอย่างที่ชัดเจน แต่ฉันไม่เข้าใจว่าทำไมการค้นหาแบบสุ่มจึงช้ากว่าflat_mapเมื่อเทียบกับstd::map- มีใครสามารถอธิบายผลลัพธ์นี้ได้บ้าง
boycy

1
ฉันจะอธิบายว่ามันเป็นค่าใช้จ่ายที่เฉพาะเจาะจงของการเพิ่มประสิทธิภาพในครั้งนี้ไม่ใช่ลักษณะที่แท้จริงของflat_mapas a container เนื่องจากAska::เวอร์ชันเร็วกว่าการstd::mapค้นหา พิสูจน์ว่ามีที่ว่างสำหรับการเพิ่มประสิทธิภาพ ประสิทธิภาพที่คาดหวังจะเหมือนกันโดยไม่มีอาการ แต่อาจดีกว่าเล็กน้อยเนื่องจากตำแหน่งของแคช ด้วยชุดขนาดสูงควรมาบรรจบกัน
v.oddou

6

จากเอกสารดูเหมือนว่านี่จะคล้ายคลึงกับLoki::AssocVectorที่ฉันเป็นผู้ใช้งานค่อนข้างหนัก เนื่องจากมันขึ้นอยู่กับเวกเตอร์จึงมีลักษณะของเวกเตอร์กล่าวคือ:

  • Iterators ได้รับการยกเลิกเมื่อใดก็ตามที่เติบโตเกินsizecapacity
  • เมื่อมันเติบโตเกินกว่าที่capacityจะต้องจัดสรรใหม่และย้ายวัตถุไปนั่นคือการแทรกจะไม่รับประกันเวลาคงที่ยกเว้นกรณีพิเศษของการแทรกendเมื่อcapacity > size
  • การค้นหานั้นเร็วกว่าstd::mapเนื่องจากตำแหน่งแคชการค้นหาแบบไบนารีซึ่งมีลักษณะการทำงานเหมือนstd::mapอย่างอื่น
  • ใช้หน่วยความจำน้อยลงเนื่องจากไม่ใช่ต้นไม้ไบนารีที่เชื่อมโยง
  • มันไม่เคยย่อขนาดเว้นแต่คุณจะบังคับให้บอก (เนื่องจากจะทำให้เกิดการจัดสรรใหม่)

การใช้งานที่ดีที่สุดคือเมื่อคุณทราบจำนวนองค์ประกอบล่วงหน้า (เพื่อให้คุณสามารถreserveล่วงหน้าได้) หรือเมื่อการแทรก / ลบเป็นเรื่องที่หายาก แต่การค้นหาบ่อยครั้ง การไม่ถูกต้องของตัวทำซ้ำทำให้มันยุ่งยากเล็กน้อยในบางกรณีการใช้งานดังนั้นจึงไม่สามารถใช้แทนกันได้ในแง่ของความถูกต้องของโปรแกรม


1
เท็จ :) การวัดด้านบนแสดงว่าแผนที่เร็วกว่า flat_map สำหรับการค้นหาการดำเนินการฉันเดาว่าการเพิ่มประสิทธิภาพ ppl จำเป็นต้องแก้ไขการใช้งาน แต่ในทางทฤษฎีคุณพูดถูก
NoSenseEtAl
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.