[c#] 내 애플리케이션이 널 검사를 수행하는 데 24 %의 시간을 소비하는 이유는 무엇입니까?

성능에 중요한 이진 의사 결정 트리가 있으며이 질문을 한 줄의 코드에 집중하고 싶습니다. 이진 트리 반복기에 대한 코드는 성능 분석을 실행 한 결과와 함께 아래에 있습니다.

        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 루프 / 널 검사는 성능에 큰 타격을줍니다. 나무는 거대하기 때문에 잎을 찾는 데 시간이 걸릴 것으로 예상하지만 그 한 줄에 너무 많은 시간이 소요되는 것을 이해하고 싶습니다.

난 노력 했어:

  • Null 검사와 동안 분리-히트 인 것은 Null 검사입니다.
  • 객체에 부울 필드를 추가하고 이에 대해 확인해도 아무런 차이가 없습니다. 무엇을 비교하는지는 중요하지 않습니다. 비교가 문제입니다.

분기 예측 문제입니까? 그렇다면 어떻게해야합니까? 만약 있다면?

나는 CIL 을 이해하는 척 하지는 않겠지 만, 누구든지 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)

그 안에. 그런 다음 이에 대해 성능 분석을 실행했는데, 항상 참을 반환하는 두 번째 비교를 실행하는 것보다 첫 번째 비교를 실행하는 데 6 배 더 오래 걸렸습니다. 그래서 그것은 실제로 분기 예측 문제인 것 같습니다. 그리고 그것에 대해 내가 할 수있는 것이 아무것도 없다고 생각합니다?!

또 다른 편집

위의 결과는 node.BranchData가 while 검사를 위해 RAM에서로드되어야하는 경우에도 발생합니다. 그러면 if 문에 대해 캐시됩니다.


이것은 비슷한 주제에 대한 세 번째 질문입니다. 이번에는 한 줄의 코드에 집중하고 있습니다. 이 주제에 대한 다른 질문은 다음과 같습니다.



답변

나무는 방대하다

지금까지 프로세서가 수행하는 가장 비싼 일은 명령을 실행하지 않고 메모리에 액세스하는 것입니다. 현대의 실행 코어 CPU가 있습니다 많은 배 빠른 메모리 버스보다. 거리 와 관련된 문제 는 전기 신호가 더 멀리 이동해야할수록 신호가 손상되지 않고 와이어의 다른 쪽 끝으로 전달되기가 더 어려워집니다. 그 문제에 대한 유일한 해결책은 속도를 늦추는 것입니다. CPU를 컴퓨터의 RAM에 연결하는 전선의 큰 문제는 케이스를 열고 전선을 볼 수 있다는 것입니다.

프로세서는이 문제에 대한 대책을 가지고 있으며 RAM에 바이트 사본을 저장하는 버퍼 인 캐시 를 사용 합니다 . 중요한 것은 L1 캐시로 , 일반적으로 데이터의 경우 16KB, 명령어의 경우 16KB입니다. 작기 때문에 실행 엔진에 가깝습니다. L1 캐시에서 바이트를 읽는 데 일반적으로 2 ~ 3 CPU주기가 걸립니다. 다음은 더 크고 느린 L2 캐시입니다. 고급 프로세서에는 L3 캐시가 있지만 더 크고 느립니다. 프로세스 기술이 향상됨에 따라 이러한 버퍼는 공간을 덜 차지하고 코어에 가까워 질수록 자동으로 더 빨라집니다. 이는 새로운 프로세서가 더 나은 이유와 점점 더 많은 트랜지스터를 사용하는 방법입니다.

그러나 이러한 캐시는 완벽한 솔루션이 아닙니다. 캐시 중 하나에서 데이터를 사용할 수없는 경우 프로세서는 여전히 메모리 액세스를 중단합니다. 매우 느린 메모리 버스가 데이터를 제공 할 때까지 계속할 수 없습니다. 하나의 명령으로 수백 개의 CPU 사이클을 잃는 것이 가능합니다.

트리 구조는 문제이며 캐시 친화적 이지 않습니다 . 그들의 노드는 주소 공간 전체에 흩어져있는 경향이 있습니다. 메모리에 액세스하는 가장 빠른 방법은 순차 주소에서 읽는 것입니다. L1 캐시의 저장 단위는 64 바이트입니다. 즉, 프로세서가 1 바이트를 읽으면 다음 63 개는 캐시에 있기 때문에 매우 빠릅니다.

이것은 배열 을 가장 효율적인 데이터 구조로 만듭니다. 또한 .NET List <> 클래스가 목록이 아닌 이유는 스토리지에 배열을 사용합니다. Dictionary와 같은 다른 컬렉션 유형에 대해서도 동일하며 구조적으로는 배열과 원격으로 유사하지 않지만 내부적으로 배열로 구현됩니다.

따라서 while () 문은 BranchData 필드에 액세스하기위한 포인터를 역 참조하기 때문에 CPU 지연으로 고통받을 가능성이 높습니다. 다음 문은 while () 문이 이미 메모리에서 값을 검색하는 무거운 작업을 수행했기 때문에 매우 저렴합니다. 지역 변수를 할당하는 것은 저렴하며 프로세서는 쓰기를 위해 버퍼를 사용합니다.

풀어야 할 간단한 문제는 아니지만 트리를 배열로 평면화하는 것은 실용적이지 않을 가능성이 높습니다. 일반적으로 트리의 노드가 방문 할 순서를 예측할 수 없기 때문에 최소한은 아닙니다. 빨강-검정 나무가 도움이 될 수 있지만 질문에서 명확하지 않습니다. 따라서 간단한 결론은 이미 원하는만큼 빠르게 실행되고 있다는 것입니다. 더 빠른 속도를 원한다면 더 빠른 메모리 버스와 함께 더 나은 하드웨어가 필요합니다. DDR4 는 올해 주류가되고 있습니다.


답변

메모리 캐시 효과에 대한 Hans의 훌륭한 답변을 보완하기 위해 물리적 메모리 변환 및 NUMA 효과에 가상 메모리에 대한 설명을 추가합니다.

가상 메모리 컴퓨터 (현재의 모든 컴퓨터)에서 메모리 액세스를 수행 할 때 각 가상 메모리 주소는 실제 메모리 주소로 변환되어야합니다. 이것은 변환 테이블을 사용하여 메모리 관리 하드웨어에 의해 수행됩니다. 이 테이블은 각 프로세스의 운영 체제에서 관리하며 자체적으로 RAM에 저장됩니다. 가상 메모리의 각 페이지 에 대해이 변환 테이블에 가상을 물리적 페이지에 매핑하는 항목이 있습니다. 비용이 많이 드는 메모리 액세스에 대한 Hans의 논의를 기억하십시오. 각 가상에서 물리적으로의 변환에 메모리 조회가 필요한 경우 모든 메모리 액세스 비용은 두 배가됩니다. 해결책은 번역 lookaside 버퍼 라고하는 번역 테이블에 대한 캐시를 갖는 것입니다.(줄여서 TLB). TLB는 크지 (12 4096 개 항목)이며, x86-64의 아키텍처에 대한 일반적인 페이지 크기는, 어떤 수단 4KB 만있다 가 TLB 히트에 직접 액세스 할 가장 16메가바이트에서 (아마 더 적은의 그보다 샌디 TLB 크기가 512 개 항목 인 브리지 ). TLB 누락 수를 줄이려면 운영 체제와 애플리케이션이 함께 작동하여 2MB와 같은 더 큰 페이지 크기를 사용하여 TLB 적중으로 액세스 할 수있는 훨씬 더 큰 메모리 공간을 사용할 수 있습니다. 이 페이지 는 메모리 액세스 속도를 크게 높일 수 있는 Java로 대형 페이지를 사용 하는 방법 설명합니다 .

컴퓨터에 소켓이 많은 경우 NUMA 아키텍처 일 수 있습니다. NUMA는 비 균일 메모리 액세스를 의미합니다. 이러한 아키텍처에서 일부 메모리 액세스 는 다른 것보다 비용이 많이 듭니다.. 예를 들어, 32GB RAM이있는 2 소켓 컴퓨터의 경우 각 소켓에는 16GB RAM이있을 수 있습니다. 이 예제 컴퓨터에서 로컬 메모리 액세스는 다른 소켓의 메모리에 액세스하는 것보다 저렴합니다 (원격 액세스는 20-100 % 더 느릴 수 있습니다). 이러한 컴퓨터에서 트리가 20GB의 RAM을 사용하고 최소 4GB의 데이터가 다른 NUMA 노드에 있으며 원격 메모리에 대한 액세스가 50 % 더 느리면 NUMA 액세스로 인해 메모리 액세스가 10 % 느려집니다. 또한 단일 NUMA 노드에 사용 가능한 메모리 만있는 경우 부족한 노드의 메모리를 필요로하는 모든 프로세스는 액세스 비용이 더 많이 드는 다른 노드에서 메모리를 할당받습니다. 최악의 경우에도 운영 체제는 고갈 된 노드의 메모리 일부를 교체하는 것이 좋다고 생각할 수 있습니다.이는 훨씬 더 비싼 메모리 액세스를 유발합니다 . 이것은 MySQL “스왑 광기”문제와 일부 솔루션이 Linux 용으로 제공되는 NUMA 아키텍처의 영향 (모든 NUMA 노드에서 메모리 액세스 확산, 스왑을 피하기 위해 원격 NUMA 액세스에 대한 총알 깨기) 에서 자세히 설명됩니다 . 또한 소켓에 더 많은 RAM을 할당하고 (16GB 및 16GB 대신 24GB 및 8GB) 프로그램이 더 큰 NUMA 노드에서 일정이 잡히도록 할 수 있지만 컴퓨터와 드라이버에 대한 물리적 액세스가 필요합니다 😉 .


답변

이것은 그 자체로 대답이 아니라 Hans Passant가 기억 시스템의 지연에 대해 쓴 내용을 강조한 것입니다.

컴퓨터 게임과 같은 고성능 소프트웨어는 게임 자체를 구현하기 위해 작성되었을뿐만 아니라 코드와 데이터 구조가 캐시와 메모리 시스템을 최대한 활용하도록 조정됩니다. 즉, 제한된 리소스로 취급합니다. 캐시 문제를 다룰 때 일반적으로 데이터가 있으면 L1이 3 주기로 제공된다고 가정합니다. 그렇지 않고 L2로 이동해야한다면 10주기를 가정합니다. L3 30 사이클 및 RAM 메모리 100.

필요한 경우 더 큰 페널티를 부과하는 추가 메모리 관련 작업이 있으며 이는 버스 잠금입니다. Windows NT 기능을 사용하는 경우 버스 잠금을 중요 섹션이라고합니다. 집에서 재배 한 품종을 사용하는 경우 스핀 락이라고 부를 수 있습니다. 이름이 무엇이든간에 잠금이 설정되기 전에 시스템에서 가장 느린 버스 마스터 링 장치로 동기화됩니다. 가장 느린 버스 마스터 링 장치는 33MHz에서 연결된 클래식 32 비트 PCI 카드 일 수 있습니다. 33MHz는 일반적인 x86 CPU (@ 3.3GHz) 주파수의 100 분의 1입니다. 나는 버스 잠금을 완료하는 데 300 사이클 이상이라고 가정하지만 그렇게 오래 걸릴 수 있다는 것을 알고 있으므로 3000 사이클을 보면 놀라지 않을 것입니다.

초보 멀티 스레딩 소프트웨어 개발자는 모든 곳에서 버스 잠금을 사용하고 코드가 느린 이유를 궁금해 할 것입니다. 메모리와 관련된 모든 것과 마찬가지로 비결은 액세스를 절약하는 것입니다.


답변