Tại sao ứng dụng của tôi dành 24% thời gian để thực hiện kiểm tra rỗng?


104

Tôi có một cây quyết định nhị phân quan trọng về hiệu suất và tôi muốn tập trung câu hỏi này vào một dòng mã. Dưới đây là mã cho trình lặp cây nhị phân với các kết quả từ việc chạy phân tích hiệu suất dựa trên nó.

        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 là một trường, không phải một thuộc tính. Tôi đã làm điều này để ngăn chặn nguy cơ nó không được nội tuyến.

Lớp BranchNodeData như sau:

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;
}

Như bạn có thể thấy, kiểm tra vòng lặp while / null là một tác động lớn đến hiệu suất. Cái cây rất lớn, vì vậy tôi hy vọng việc tìm kiếm một chiếc lá sẽ mất một khoảng thời gian, nhưng tôi muốn hiểu khoảng thời gian không tương xứng dành cho một đường đó.

Tôi đã thử:

  • Tách séc Null khỏi while - séc Null mới là hit.
  • Thêm một trường boolean vào đối tượng và kiểm tra đối tượng đó, nó không có gì khác biệt. Điều gì được so sánh không quan trọng, chính sự so sánh mới là vấn đề.

Đây có phải là vấn đề dự đoán nhánh không? Nếu vậy, tôi có thể làm gì với nó? Nếu bất cứ điều gì?

Tôi sẽ không giả vờ hiểu CIL , nhưng tôi sẽ đăng nó cho bất kỳ ai hiểu được để họ có thể cố gắng thu thập một số thông tin từ nó.

.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

Chỉnh sửa: Tôi quyết định thực hiện kiểm tra dự đoán nhánh, tôi đã thêm một nếu giống hệt nhau nếu trong thời gian ngắn, vì vậy chúng tôi có

while (node.BranchData != null)

if (node.BranchData != null)

bên trong đó. Sau đó, tôi đã chạy phân tích hiệu suất so với điều đó và phải mất sáu lần lâu hơn để thực hiện phép so sánh đầu tiên như khi thực hiện phép so sánh thứ hai luôn trả về true. Vì vậy, có vẻ như nó thực sự là một vấn đề dự đoán nhánh - và tôi đoán rằng tôi không thể làm gì với nó ?!

Chỉnh sửa khác

Kết quả trên cũng sẽ xảy ra nếu node.BranchData phải được tải từ RAM trong quá trình kiểm tra trong khi - sau đó nó sẽ được lưu vào bộ nhớ đệm cho câu lệnh if.


Đây là câu hỏi thứ ba của tôi về một chủ đề tương tự. Lần này tôi đang tập trung vào một dòng mã. Các câu hỏi khác của tôi về chủ đề này là:


3
Vui lòng cho thấy việc thực hiện BranchNodetài sản. Hãy thử thay thế node.BranchData != null ReferenceEquals(node.BranchData, null). Liệu nó có bất kỳ sự khác biệt?
Daniel Hilgarth

4
Bạn có chắc chắn rằng 24% không dành cho câu lệnh while và không phải là biểu thức điều kiện mà một phần của câu lệnh while
Rune FS

2
Một thử nghiệm: Cố gắng viết lại vòng lặp while của bạn như thế này: while(true) { /* current body */ if(node.BranchData == null) return node; }. Nó có thay đổi gì không?
Daniel Hilgarth

2
Một chút tối ưu hóa sẽ như sau: while(true) { BranchNodeData b = node.BranchData; if(ReferenceEquals(b, null)) return node; node = b.Child2; if (inputs[b.SplitInputIndex] <= b.SplitValue) node = b.Child1; }Điều này sẽ node. BranchDatachỉ truy xuất một lần.
Daniel Hilgarth

2
Vui lòng cộng tổng số lần hai dòng tiêu thụ thời gian lớn nhất được thực hiện.
Daniel Hilgarth

Câu trả lời:


180

Cây rất lớn

Cho đến nay, điều đắt giá nhất mà một bộ xử lý từng làm là không thực hiện các lệnh, đó là truy cập bộ nhớ. Cốt lõi thực hiện một hiện đại CPUnhiều nhanh hơn so với bus bộ nhớ lần. Một vấn đề liên quan đến khoảng cách , một tín hiệu điện càng phải di chuyển xa hơn, thì càng khó có thể nhận được tín hiệu đó đến đầu kia của dây mà không bị hỏng. Cách chữa duy nhất cho vấn đề đó là làm cho nó diễn ra chậm hơn. Một vấn đề lớn với dây kết nối CPU với RAM trong máy của bạn, bạn có thể mở hộp và xem dây.

Bộ xử lý có một biện pháp đối phó cho vấn đề này, họ sử dụng bộ nhớ đệm, bộ đệm lưu trữ bản sao của các byte trong RAM. Một điều quan trọng là bộ đệm L1 , thường là 16 kilobyte cho dữ liệu và 16 kilobyte cho hướng dẫn. Nhỏ, cho phép nó gần với động cơ thực thi. Việc đọc các byte từ bộ nhớ đệm L1 thường mất 2 hoặc 3 chu kỳ CPU. Tiếp theo là bộ nhớ đệm L2, lớn hơn và chậm hơn. Bộ xử lý cao cấp cũng có bộ nhớ đệm L3, lớn hơn và chậm hơn. Khi công nghệ quy trình được cải thiện, những bộ đệm đó sẽ chiếm ít không gian hơn và tự động trở nên nhanh hơn khi chúng đến gần lõi hơn, một lý do lớn khiến các bộ xử lý mới hơn tốt hơn và cách chúng quản lý để sử dụng ngày càng nhiều bóng bán dẫn.

Tuy nhiên, những bộ nhớ đệm đó không phải là một giải pháp hoàn hảo. Bộ xử lý sẽ vẫn ngừng truy cập bộ nhớ nếu dữ liệu không có sẵn trong một trong các bộ nhớ đệm. Nó không thể tiếp tục cho đến khi bus bộ nhớ rất chậm đã cung cấp dữ liệu. Có thể mất hàng trăm chu kỳ CPU chỉ với một lệnh duy nhất.

Cấu trúc cây là một vấn đề, chúng không thân thiện với bộ nhớ cache. Các nút của chúng có xu hướng nằm rải rác trong không gian địa chỉ. Cách nhanh nhất để truy cập bộ nhớ là đọc từ các địa chỉ tuần tự. Đơn vị lưu trữ cho bộ đệm L1 là 64 byte. Hay nói cách khác, khi bộ xử lý đọc một byte, 63 tiếp theo sẽ rất nhanh vì chúng sẽ hiện diện trong bộ nhớ đệm.

Điều này làm cho một mảng trở thành cấu trúc dữ liệu hiệu quả nhất. Cũng là lý do mà lớp .NET List <> không phải là một danh sách, nó sử dụng một mảng để lưu trữ. Tương tự đối với các loại tập hợp khác, như Từ điển, về mặt cấu trúc không tương tự như một mảng, nhưng được triển khai bên trong với các mảng.

Vì vậy, câu lệnh while () của bạn rất có thể bị treo CPU vì nó đang tham chiếu đến một con trỏ để truy cập vào trường BranchData. Câu lệnh tiếp theo rất rẻ vì câu lệnh while () đã thực hiện việc truy xuất giá trị từ bộ nhớ. Việc gán biến cục bộ là rẻ, bộ xử lý sử dụng bộ đệm để ghi.

Không phải là một vấn đề đơn giản để giải quyết, việc làm phẳng cây của bạn thành các mảng rất có thể là không thực tế. Ít nhất thì không vì bạn thường không thể dự đoán thứ tự các nút của cây sẽ được truy cập. Một cây đỏ đen có thể giúp ích, điều đó không rõ ràng trong câu hỏi. Vì vậy, một kết luận đơn giản để rút ra là nó đã chạy nhanh như bạn có thể hy vọng. Và nếu bạn cần nó chạy nhanh hơn thì bạn sẽ cần phần cứng tốt hơn với bus bộ nhớ nhanh hơn. DDR4 sẽ trở thành xu hướng chủ đạo trong năm nay.


1
Có lẽ. Chúng rất có thể đã nằm liền kề trong bộ nhớ và do đó trong bộ nhớ cache, vì bạn đã cấp phát cái này đến cái khác. Với thuật toán thu gọn đống GC nếu không sẽ có ảnh hưởng không thể đoán trước đến điều đó. Tốt nhất đừng để tôi đoán lúc này, hãy đo lường để bạn biết một sự thật.
Hans Passant

11
Chủ đề không giải quyết được vấn đề này. Cung cấp cho bạn nhiều lõi hơn, bạn vẫn chỉ có một bus bộ nhớ.
Hans Passant

2
Có thể việc sử dụng b-tree sẽ giới hạn chiều cao của cây, vì vậy bạn sẽ cần truy cập vào ít con trỏ hơn, vì mỗi nút là một cấu trúc duy nhất nên nó có thể được lưu trữ hiệu quả trong bộ nhớ cache. Xem thêm câu hỏi này .
MatthieuBizien

4
giải thích sâu sắc với nhiều loại thông tin liên quan, như thường lệ. +1
Tigran

1
Nếu bạn biết mẫu truy cập vào cây và nó tuân theo quy tắc 80/20 (80% truy cập luôn nằm trên cùng 20% ​​số nút), thì một cây tự điều chỉnh như cây splay cũng có thể nhanh hơn. en.wikipedia.org/wiki/Splay_tree
Jens Timmerman

10

Để bổ sung cho câu trả lời tuyệt vời của Hans về các hiệu ứng bộ nhớ đệm, tôi thêm phần thảo luận về bộ nhớ ảo để dịch bộ nhớ vật lý và các hiệu ứng NUMA.

Với máy tính bộ nhớ ảo (tất cả các máy tính hiện tại), khi thực hiện truy cập bộ nhớ, mỗi địa chỉ bộ nhớ ảo phải được dịch sang một địa chỉ bộ nhớ vật lý. Điều này được thực hiện bởi phần cứng quản lý bộ nhớ sử dụng bảng dịch. Bảng này được quản lý bởi hệ điều hành cho mỗi quá trình và bản thân nó được lưu trữ trong RAM. Đối với mỗi trang của bộ nhớ ảo, có một mục trong bảng dịch này ánh xạ một trang ảo với một trang vật lý. Hãy nhớ thảo luận của Hans về việc truy cập bộ nhớ rất tốn kém: nếu mỗi bản dịch từ ảo sang vật lý cần tra cứu bộ nhớ, thì tất cả các truy cập bộ nhớ sẽ tốn kém gấp đôi. Giải pháp là có một bộ nhớ cache cho bảng dịch được gọi là bộ đệm xem xét dịch(Viết tắt là TLB). TLB không lớn (12 đến 4096 mục nhập) và kích thước trang điển hình trên kiến ​​trúc x86-64 chỉ là 4 KB, có nghĩa là có tối đa 16 MB có thể truy cập trực tiếp với số lần truy cập TLB (có thể còn ít hơn thế, Sandy Cầu có kích thước TLB là 512 mục ). Để giảm số lần bỏ lỡ TLB, bạn có thể để hệ điều hành và ứng dụng hoạt động cùng nhau để sử dụng kích thước trang lớn hơn như 2 MB, dẫn đến không gian bộ nhớ lớn hơn nhiều có thể truy cập được với số lần truy cập TLB. Trang này giải thích cách sử dụng các trang lớn với Java có thể tăng tốc độ truy xuất bộ nhớ .

Nếu máy tính của bạn có nhiều ổ cắm, nó có thể là một kiến trúc NUMA . NUMA có nghĩa là Truy cập Bộ nhớ Không thống nhất. Trong các kiến ​​trúc này, một số chi phí truy cập bộ nhớ nhiều hơn khác. Ví dụ, với một máy tính 2 socket với 32 GB RAM, mỗi socket có thể có 16 GB RAM. Trên máy tính ví dụ này, truy cập bộ nhớ cục bộ rẻ hơn truy cập vào bộ nhớ của ổ cắm khác (truy cập từ xa chậm hơn từ 20 đến 100%, thậm chí có thể hơn). Nếu trên máy tính như vậy, cây của bạn sử dụng 20 GB RAM, ít nhất 4 GB dữ liệu của bạn nằm trên nút NUMA khác và nếu truy cập chậm hơn 50% đối với bộ nhớ từ xa, truy cập NUMA làm chậm truy cập bộ nhớ của bạn 10%. Ngoài ra, nếu bạn chỉ có bộ nhớ trống trên một nút NUMA duy nhất, thì tất cả các tiến trình cần bộ nhớ trên nút bị đói sẽ được cấp phát bộ nhớ từ nút khác mà việc truy cập đắt hơn. Thậm chí tệ nhất, hệ điều hành có thể nghĩ rằng việc hoán đổi một phần bộ nhớ của nút bị đói là một ý tưởng hay,điều này sẽ gây ra việc truy cập bộ nhớ thậm chí còn tốn kém hơn . Điều này được giải thích chi tiết hơn trong Vấn đề "hoán đổi điên rồ" trong MySQL và ảnh hưởng của kiến ​​trúc NUMA trong đó một số giải pháp được đưa ra cho Linux (trải rộng truy cập bộ nhớ trên tất cả các nút NUMA, cắn dấu đầu dòng trên các truy cập NUMA từ xa để tránh hoán đổi). Tôi cũng có thể nghĩ đến việc phân bổ nhiều RAM hơn cho một ổ cắm (24 và 8 GB thay vì 16 và 16 GB) và đảm bảo chương trình của bạn được lên lịch trên nút NUMA lớn hơn, nhưng điều này cần quyền truy cập vật lý vào máy tính và tuốc nơ vít ;-) .


4

Đây không phải là câu trả lời mà là sự nhấn mạnh vào những gì Hans Passant đã viết về sự chậm trễ trong hệ thống bộ nhớ.

Phần mềm thực sự hiệu suất cao - chẳng hạn như trò chơi máy tính - không chỉ được viết để thực hiện chính trò chơi, nó còn được điều chỉnh sao cho mã và cấu trúc dữ liệu tận dụng tối đa hệ thống bộ nhớ cache và bộ nhớ, tức là coi chúng như một tài nguyên hạn chế. Khi tôi giải quyết các vấn đề về bộ nhớ cache, tôi thường giả định rằng L1 sẽ phân phối trong 3 chu kỳ nếu dữ liệu có ở đó. Nếu không và tôi phải chuyển sang L2, tôi giả sử 10 chu kỳ. Đối với L3 30 chu kỳ và đối với bộ nhớ RAM 100.

Có một hành động bổ sung liên quan đến bộ nhớ - nếu bạn cần sử dụng nó - áp dụng hình phạt thậm chí còn lớn hơn và đó là khóa xe buýt. Khóa xe buýt được gọi là phần quan trọng nếu bạn sử dụng chức năng Windows NT. Nếu bạn sử dụng một giống cây trồng tại nhà, bạn có thể gọi nó là một loại cây quay. Dù tên là gì, nó sẽ đồng bộ hóa với thiết bị làm chủ bus chậm nhất trong hệ thống trước khi khóa được đặt. Thiết bị làm chủ bus chậm nhất có thể là thẻ PCI 32 bit cổ điển được kết nối @ 33MHz. 33MHz là một phần trăm tần số của một CPU x86 điển hình (@ 3,3 GHz). Tôi giả định không ít hơn 300 chu kỳ để hoàn thành một khóa xe buýt nhưng tôi biết họ có thể mất nhiều thời gian như vậy nên nếu tôi thấy 3000 chu kỳ, tôi sẽ không ngạc nhiên.

Các nhà phát triển phần mềm đa luồng mới làm quen sẽ sử dụng khóa xe buýt ở khắp nơi và sau đó tự hỏi tại sao mã của họ chậm. Bí quyết - cũng như mọi thứ liên quan đến bộ nhớ - là tiết kiệm quyền truy cập.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.