เหตุใดแอปพลิเคชันของฉันจึงใช้เวลา 24% ของชีวิตในการตรวจสอบค่าว่าง


104

ฉันมีโครงสร้างการตัดสินใจไบนารีที่สำคัญด้านประสิทธิภาพและฉันต้องการเน้นคำถามนี้ไปที่โค้ดบรรทัดเดียว โค้ดสำหรับตัววนซ้ำต้นไม้ไบนารีอยู่ด้านล่างพร้อมผลลัพธ์จากการรันการวิเคราะห์ประสิทธิภาพเทียบกับมัน

        public ScTreeNode GetNodeForState(int rootIndex, float[] inputs)
        {
0.2%        ScTreeNode node = RootNodes[rootIndex].TreeNode;

24.6%       while (node.BranchData != null)
            {
0.2%            BranchNodeData b = node.BranchData;
0.5%            node = b.Child2;
12.8%           if (inputs[b.SplitInputIndex] <= b.SplitValue)
0.8%                node = b.Child1;
            }

0.4%        return node;
        }

BranchData เป็นฟิลด์ไม่ใช่คุณสมบัติ ฉันทำสิ่งนี้เพื่อป้องกันความเสี่ยงที่มันจะไม่อินไลน์

คลาส BranchNodeData เป็นดังนี้:

public sealed class BranchNodeData
{
    /// <summary>
    /// The index of the data item in the input array on which we need to split
    /// </summary>
    internal int SplitInputIndex = 0;

    /// <summary>
    /// The value that we should split on
    /// </summary>
    internal float SplitValue = 0;

    /// <summary>
    /// The nodes children
    /// </summary>
    internal ScTreeNode Child1;
    internal ScTreeNode Child2;
}

อย่างที่คุณเห็นการตรวจสอบ while loop / null เป็นผลดีอย่างมากต่อประสิทธิภาพ ต้นไม้มีขนาดใหญ่ดังนั้นฉันคาดว่าการค้นหาใบไม้จะต้องใช้เวลาสักพัก แต่ฉันต้องการเข้าใจระยะเวลาที่ไม่สมส่วนที่ใช้ไปกับบรรทัดนั้น

ฉันพยายามแล้ว:

  • การแยกการตรวจสอบ Null ออกจากขณะ - เป็นการตรวจสอบ Null ที่เป็น Hit
  • การเพิ่มฟิลด์บูลีนให้กับออบเจ็กต์และตรวจสอบกับสิ่งนั้นมันไม่ได้สร้างความแตกต่าง ไม่สำคัญว่าจะมีการเปรียบเทียบอะไร แต่การเปรียบเทียบที่เป็นปัญหา

นี่เป็นปัญหาการทำนายสาขาหรือไม่? ถ้าเป็นเช่นนั้นฉันจะทำอย่างไร ถ้าทุกอย่าง?

ฉันจะไม่แสร้งทำเป็นเข้าใจCILแต่ฉันจะโพสต์ให้ใครก็ตามที่ทำเพื่อให้พวกเขาพยายามดึงข้อมูลบางส่วนออกมา

.method public hidebysig
instance class OptimalTreeSearch.ScTreeNode GetNodeForState (
    int32 rootIndex,
    float32[] inputs
) cil managed
{
    // Method begins at RVA 0x2dc8
    // Code size 67 (0x43)
    .maxstack 2
    .locals init (
        [0] class OptimalTreeSearch.ScTreeNode node,
        [1] class OptimalTreeSearch.BranchNodeData b
    )

    IL_0000: ldarg.0
    IL_0001: ldfld class [mscorlib]System.Collections.Generic.List`1<class OptimalTreeSearch.ScRootNode> OptimalTreeSearch.ScSearchTree::RootNodes
    IL_0006: ldarg.1
    IL_0007: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<class OptimalTreeSearch.ScRootNode>::get_Item(int32)
    IL_000c: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.ScRootNode::TreeNode
    IL_0011: stloc.0
    IL_0012: br.s IL_0039
    // loop start (head: IL_0039)
        IL_0014: ldloc.0
        IL_0015: ldfld class OptimalTreeSearch.BranchNodeData OptimalTreeSearch.ScTreeNode::BranchData
        IL_001a: stloc.1
        IL_001b: ldloc.1
        IL_001c: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.BranchNodeData::Child2
        IL_0021: stloc.0
        IL_0022: ldarg.2
        IL_0023: ldloc.1
        IL_0024: ldfld int32 OptimalTreeSearch.BranchNodeData::SplitInputIndex
        IL_0029: ldelem.r4
        IL_002a: ldloc.1
        IL_002b: ldfld float32 OptimalTreeSearch.BranchNodeData::SplitValue
        IL_0030: bgt.un.s IL_0039

        IL_0032: ldloc.1
        IL_0033: ldfld class OptimalTreeSearch.ScTreeNode OptimalTreeSearch.BranchNodeData::Child1
        IL_0038: stloc.0

        IL_0039: ldloc.0
        IL_003a: ldfld class OptimalTreeSearch.BranchNodeData OptimalTreeSearch.ScTreeNode::BranchData
        IL_003f: brtrue.s IL_0014
    // end loop

    IL_0041: ldloc.0
    IL_0042: ret
} // end of method ScSearchTree::GetNodeForState

แก้ไข:ฉันตัดสินใจที่จะทำการทดสอบการทำนายสาขาฉันได้เพิ่มสิ่งที่เหมือนกันหากในขณะที่เรามี

while (node.BranchData != null)

และ

if (node.BranchData != null)

ข้างในนั้น จากนั้นฉันก็ทำการวิเคราะห์ประสิทธิภาพกับสิ่งนั้นและใช้เวลานานกว่าหกเท่าในการดำเนินการเปรียบเทียบครั้งแรกเหมือนกับที่ดำเนินการเปรียบเทียบครั้งที่สองซึ่งจะคืนค่าจริงเสมอ ดูเหมือนว่าจะเป็นปัญหาการทำนายสาขา - และฉันเดาว่าไม่มีอะไรที่ฉันสามารถทำได้?!

แก้ไขอื่น

ผลลัพธ์ข้างต้นจะเกิดขึ้นเช่นกันหากต้องโหลด node.BranchData จาก RAM สำหรับการตรวจสอบ while - จากนั้นจะถูกแคชสำหรับคำสั่ง if


นี่เป็นคำถามที่สามของฉันในหัวข้อที่คล้ายกัน คราวนี้ฉันมุ่งเน้นไปที่โค้ดบรรทัดเดียว คำถามอื่น ๆ ของฉันเกี่ยวกับเรื่องนี้คือ:


3
โปรดแสดงการดำเนินการของBranchNodeคุณสมบัติ node.BranchData != null ReferenceEquals(node.BranchData, null)กรุณาพยายามที่จะเข้ามาแทนที่ มันสร้างความแตกต่างหรือไม่?
Daniel Hilgarth

4
คุณแน่ใจหรือไม่ว่า 24% ไม่ใช่สำหรับคำสั่ง while และไม่ใช่นิพจน์เงื่อนไขที่เป็นส่วนหนึ่งของคำสั่ง while
Rune FS

2
การทดสอบอื่น: while(true) { /* current body */ if(node.BranchData == null) return node; }พยายามที่จะเขียนใหม่ห่วงขณะที่ของคุณเช่นนี้ มันเปลี่ยนแปลงอะไรไหม?
Daniel Hilgarth

2
การเพิ่มประสิทธิภาพเล็กน้อยจะเป็นดังต่อไปนี้: while(true) { BranchNodeData b = node.BranchData; if(ReferenceEquals(b, null)) return node; node = b.Child2; if (inputs[b.SplitInputIndex] <= b.SplitValue) node = b.Child1; }สิ่งนี้จะดึงข้อมูลnode. BranchDataเพียงครั้งเดียว
Daniel Hilgarth

2
โปรดเพิ่มจำนวนครั้งที่สองบรรทัดที่มีการใช้เวลามากที่สุดในการดำเนินการทั้งหมด
Daniel Hilgarth

คำตอบ:


180

ต้นไม้มีขนาดใหญ่

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

โปรเซสเซอร์มีมาตรการตอบโต้สำหรับปัญหานี้โดยใช้แคชบัฟเฟอร์ที่เก็บสำเนาไบต์ไว้ใน RAM สิ่งที่สำคัญคือแคช L1โดยทั่วไปจะเป็น 16 กิโลไบต์สำหรับข้อมูลและ 16 กิโลไบต์สำหรับคำแนะนำ มีขนาดเล็กทำให้สามารถใกล้เคียงกับเครื่องประมวลผล การอ่านไบต์จากแคช L1 โดยทั่วไปจะใช้เวลา 2 หรือ 3 รอบ CPU ถัดไปคือแคช L2 ซึ่งใหญ่ขึ้นและช้าลง โปรเซสเซอร์ระดับสูงยังมีแคช L3 ซึ่งใหญ่กว่าและช้ากว่า เมื่อเทคโนโลยีการผลิตดีขึ้นบัฟเฟอร์เหล่านั้นจะใช้พื้นที่น้อยลงและเร็วขึ้นโดยอัตโนมัติเมื่อเข้าใกล้แกนหลักเหตุผลใหญ่ที่โปรเซสเซอร์รุ่นใหม่ดีกว่าและวิธีจัดการเพื่อใช้ทรานซิสเตอร์ที่เพิ่มขึ้นเรื่อย ๆ

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

โครงสร้างต้นไม้เป็นปัญหาไม่เป็นมิตรกับแคช โหนดของพวกเขามักจะกระจัดกระจายไปทั่วพื้นที่ที่อยู่ วิธีที่เร็วที่สุดในการเข้าถึงหน่วยความจำคือการอ่านจากที่อยู่ตามลำดับ หน่วยเก็บข้อมูลสำหรับแคช L1 คือ 64 ไบต์ หรือกล่าวอีกนัยหนึ่งคือเมื่อโปรเซสเซอร์อ่านหนึ่งไบต์ 63 ถัดไปจะเร็วมากเนื่องจากจะปรากฏในแคช

ซึ่งทำให้อาร์เรย์เป็นโครงสร้างข้อมูลที่มีประสิทธิภาพสูงสุด นอกจากนี้สาเหตุที่คลาส. NET List <> ไม่ใช่รายการเลยใช้อาร์เรย์สำหรับจัดเก็บข้อมูล เช่นเดียวกับคอลเลกชันประเภทอื่น ๆ เช่นพจนานุกรมไม่ได้มีโครงสร้างจากระยะไกลคล้ายกับอาร์เรย์ แต่ใช้งานภายในด้วยอาร์เรย์

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

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


1
อาจจะ. พวกเขามีแนวโน้มที่จะอยู่ติดกันในหน่วยความจำแล้วดังนั้นในแคชเนื่องจากคุณจัดสรรทีละรายการ ด้วยอัลกอริธึมการบีบอัดฮีป GC มิฉะนั้นจะมีผลกระทบที่คาดเดาไม่ได้ ดีที่สุดที่จะไม่ให้ฉันเดาในเรื่องนี้วัดเพื่อให้คุณรู้ข้อเท็จจริง
Hans Passant

11
เธรดไม่ช่วยแก้ปัญหานี้ ให้คุณมีคอร์มากขึ้นคุณยังมีบัสหน่วยความจำเพียงตัวเดียว
Hans Passant

2
บางทีการใช้ b-tree จะจำกัดความสูงของต้นไม้ดังนั้นคุณจะต้องเข้าถึงพอยน์เตอร์น้อยลงเนื่องจากแต่ละโหนดเป็นโครงสร้างเดียวจึงสามารถจัดเก็บในแคชได้อย่างมีประสิทธิภาพ ดูคำถามนี้ด้วย
MatthieuBizien

4
อธิบายอย่างลึกซึ้งพร้อมข้อมูลที่เกี่ยวข้องมากมายตามปกติ +1
Tigran

1
หากคุณทราบรูปแบบการเข้าถึงต้นไม้และเป็นไปตามกฎ 80/20 (80% ของการเข้าถึงมักจะอยู่บน 20% ของโหนดเดียวกันเสมอ) ต้นไม้ที่ปรับแต่งเองเช่นต้นไม้สเปรย์ก็อาจพิสูจน์ได้เร็วขึ้นเช่นกัน en.wikipedia.org/wiki/Splay_tree
Jens Timmerman

10

เพื่อเติมเต็มคำตอบที่ยอดเยี่ยมของ Hans เกี่ยวกับเอฟเฟกต์แคชหน่วยความจำฉันเพิ่มการอภิปรายเกี่ยวกับหน่วยความจำเสมือนในการแปลหน่วยความจำกายภาพและเอฟเฟกต์ NUMA

ด้วยคอมพิวเตอร์หน่วยความจำเสมือน (คอมพิวเตอร์ปัจจุบันทั้งหมด) เมื่อทำการเข้าถึงหน่วยความจำที่อยู่หน่วยความจำเสมือนแต่ละรายการจะต้องถูกแปลเป็นที่อยู่หน่วยความจำกายภาพ สิ่งนี้ทำได้โดยฮาร์ดแวร์การจัดการหน่วยความจำโดยใช้ตารางการแปล ตารางนี้ได้รับการจัดการโดยระบบปฏิบัติการสำหรับแต่ละกระบวนการและจะถูกเก็บไว้ใน RAM สำหรับแต่ละเพจของหน่วยความจำเสมือนมีรายการในตารางการแปลนี้ที่แมปเสมือนกับเพจฟิสิคัล จำคำอภิปรายของ Hans เกี่ยวกับการเข้าถึงหน่วยความจำที่มีราคาแพง: หากการแปลเสมือนจริงแต่ละครั้งจำเป็นต้องมีการค้นหาหน่วยความจำการเข้าถึงหน่วยความจำทั้งหมดจะมีค่าใช้จ่ายเพิ่มขึ้นสองเท่า วิธีแก้ปัญหาคือต้องมีแคชสำหรับตารางการแปลซึ่งเรียกว่าบัฟเฟอร์การแปล lookaside(TLB สั้น ๆ ). TLB มีขนาดไม่ใหญ่ (12 ถึง 4096 รายการ) และขนาดหน้าโดยทั่วไปในสถาปัตยกรรม x86-64 มีเพียง 4 KB ซึ่งหมายความว่ามี Hit TLB ที่เข้าถึงได้โดยตรงสูงสุด 16 MB (อาจน้อยกว่านั้นด้วยซ้ำได้มากแซนดี้ สะพานที่มีขนาด TLB 512 รายการ). เพื่อลดจำนวน TLB ที่พลาดคุณสามารถให้ระบบปฏิบัติการและแอปพลิเคชันทำงานร่วมกันเพื่อใช้ขนาดเพจที่ใหญ่ขึ้นเช่น 2 MB ซึ่งจะทำให้พื้นที่หน่วยความจำมีขนาดใหญ่ขึ้นมากซึ่งสามารถเข้าถึงได้ด้วยการเข้าชม TLB หน้านี้อธิบายวิธีใช้เพจขนาดใหญ่กับ Java ซึ่งสามารถเร่งความเร็วในการเข้าถึงหน่วยความจำได้มาก

หากคอมพิวเตอร์ของคุณมีซ็อกเก็ตจำนวนมากอาจเป็นสถาปัตยกรรมNUMA NUMA หมายถึง Non-Uniform Memory Access ในสถาปัตยกรรมเหล่านี้หน่วยความจำบางส่วนจะเข้าถึงต้นทุนมากกว่าแบบอื่น. ตัวอย่างเช่นเมื่อใช้คอมพิวเตอร์ซ็อกเก็ต 2 ตัวพร้อม RAM 32 GB แต่ละซ็อกเก็ตอาจมี RAM 16 GB ในคอมพิวเตอร์ตัวอย่างนี้การเข้าถึงหน่วยความจำภายในมีราคาถูกกว่าการเข้าถึงหน่วยความจำของซ็อกเก็ตอื่น (การเข้าถึงระยะไกลช้าลง 20 ถึง 100% หรืออาจมากกว่านั้น) หากในคอมพิวเตอร์เครื่องดังกล่าวทรีของคุณจะใช้ RAM 20 GB ข้อมูลของคุณอย่างน้อย 4 GB อยู่ในโหนด NUMA อื่นและหากการเข้าถึงช้าลง 50% สำหรับหน่วยความจำระยะไกลการเข้าถึง NUMA จะทำให้การเข้าถึงหน่วยความจำของคุณช้าลง 10% นอกจากนี้หากคุณมีหน่วยความจำว่างบนโหนด NUMA เดียวกระบวนการทั้งหมดที่ต้องการหน่วยความจำบนโหนดที่หิวโหยจะได้รับการจัดสรรหน่วยความจำจากโหนดอื่นซึ่งการเข้าถึงมีราคาแพงกว่า ที่แย่ที่สุดคือระบบปฏิบัติการอาจคิดว่าเป็นความคิดที่ดีที่จะเปลี่ยนส่วนหนึ่งของหน่วยความจำของโหนดที่หิวโหยซึ่งจะทำให้การเข้าถึงหน่วยความจำมีราคาแพงขึ้น นี่คือรายละเอียดเพิ่มเติมในปัญหา "swap insanity" ของ MySQL และผลกระทบของสถาปัตยกรรม NUMAที่มีการให้โซลูชันบางอย่างสำหรับ Linux (การแพร่กระจายการเข้าถึงหน่วยความจำบนโหนด NUMA ทั้งหมดกัดสัญลักษณ์แสดงหัวข้อย่อยในการเข้าถึง NUMA ระยะไกลเพื่อหลีกเลี่ยงการแลกเปลี่ยน) ฉันสามารถคิดถึงการจัดสรร RAM เพิ่มเติมให้กับซ็อกเก็ต (24 และ 8 GB แทนที่จะเป็น 16 และ 16 GB) และตรวจสอบให้แน่ใจว่าโปรแกรมของคุณมีกำหนดเวลาในโหนด NUMA ที่ใหญ่กว่า แต่สิ่งนี้ต้องการการเข้าถึงคอมพิวเตอร์และไขควงทางกายภาพ ;-) .


4

นี่ไม่ใช่คำตอบ แต่เป็นการเน้นสิ่งที่ Hans Passant เขียนเกี่ยวกับความล่าช้าในระบบหน่วยความจำ

ซอฟต์แวร์ที่มีประสิทธิภาพสูงเช่นเกมคอมพิวเตอร์ไม่เพียง แต่เขียนขึ้นเพื่อใช้งานเกมเท่านั้น แต่ยังได้รับการดัดแปลงเพื่อให้โค้ดและโครงสร้างข้อมูลใช้ประโยชน์สูงสุดจากระบบแคชและหน่วยความจำเช่นถือว่าเป็นทรัพยากรที่ จำกัด เมื่อฉันจัดการกับปัญหาแคชฉันมักจะคิดว่า L1 จะส่งใน 3 รอบหากมีข้อมูลอยู่ที่นั่น ถ้าไม่ใช่และฉันต้องไปที่ L2 ฉันถือว่า 10 รอบ สำหรับ L3 30 รอบและสำหรับหน่วยความจำ RAM 100

มีการดำเนินการที่เกี่ยวข้องกับหน่วยความจำเพิ่มเติมซึ่ง - หากคุณจำเป็นต้องใช้ - กำหนดโทษที่ยิ่งใหญ่กว่าและนั่นคือการล็อกบัส การล็อกบัสเรียกว่าส่วนสำคัญถ้าคุณใช้ฟังก์ชัน Windows NT หากคุณใช้พันธุ์ที่ปลูกเองในบ้านคุณอาจเรียกมันว่าสปินล็อค ไม่ว่าชื่อจะซิงโครไนซ์กับอุปกรณ์ควบคุมบัสที่ช้าที่สุดในระบบก่อนที่จะล็อคเข้าที่ อุปกรณ์ควบคุมบัสที่ช้าที่สุดอาจเป็นการ์ด PCI 32 บิตแบบคลาสสิกที่เชื่อมต่อ @ 33MHz 33MHz คือหนึ่งในร้อยของความถี่ของ CPU x86 ทั่วไป (@ 3.3 GHz) ฉันถือว่าไม่น้อยกว่า 300 รอบในการล็อคบัสให้เสร็จสมบูรณ์ แต่ฉันรู้ว่ามันอาจใช้เวลานานกว่านั้นหลายเท่าดังนั้นถ้าฉันเห็น 3000 รอบฉันจะไม่แปลกใจเลย

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

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.