为什么我的应用程序会花费其生命的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循环/空值检查对性能造成了巨大影响。那棵树很大,所以我希望寻找一片叶子需要一段时间,但是我想了解在那条线上花费的时间不成比例。

我试过了:

  • 将Null检查与一会儿分开-正是Null检查才是命中。
  • 向对象添加一个布尔字段并进行检查,这没有什么区别。所比较的内容无关紧要,而是比较是问题所在。

这是分支预测问题吗?如果是这样,我该怎么办?如果有什么?

我不会假装理解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)

在里面。然后,我对此进行了性能分析,执行第一次比较所需的时间是执行总是返回true的第二次比较的时间的六倍。因此,看来这确实是分支预测的问题-我猜我对此无能为力吗?

另一个编辑

如果必须从RAM加载while.check的node.BranchData,则也会发生上述结果-然后将其缓存为if语句。


这是我关于类似主题的第三个问题。这次,我只关注一行代码。关于这个问题,我的其他问题是:


3
请显示该BranchNode属性的实现。请尝试更换node.BranchData != null ReferenceEquals(node.BranchData, null)。有什么区别吗?
Daniel Hilgarth

4
您确定24%的内容不是while语句,也不是while语句一部分的条件表达式
Rune FS

2
另一个测试:尝试像这样重新编写while循环:while(true) { /* current body */ if(node.BranchData == null) return node; }。它会改变什么吗?
丹尼尔·希尔加斯

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

Answers:


180

那棵树很大

到目前为止,处理器执行的最昂贵的操作是不执行指令,而是访问内存。现代的执行核心CPU很多比内存总线速度更快倍。与距离有关的问题是,电信号必须传播得越,越难将信号传递到电线的另一端而不会被破坏。解决该问题的唯一方法是使其变慢。将CPU连接到计算机中RAM的电线存在很大问题,您可以弹出机箱并查看电线。

处理器有一个针对此问题的对策,它们使用缓存,将字节的副本存储在RAM中的缓冲区。一个重要的缓存L1缓存,通常用于数据的16 KB和用于指令的16 KB。很小,允许它靠近执行引擎。从L1缓存读取字节通常需要2或3个CPU周期。接下来是更大更慢的L2缓存。高档处理器还具有L3高速缓存,更大,更慢。随着工艺技术的改进,这些缓冲区占用的空间更少,并且随着它们靠近内核而自动变快,这是更新的处理器更好,如何使用越来越多的晶体管的主要原因。

但是,这些缓存不是完美的解决方案。如果其中一个缓存中的数据不可用,则处理器仍将在内存访问上停顿。直到非常慢的内存总线提供了数据,它才能继续。一条指令可能会丢失100个CPU周期。

树形结构是一个问题,他们是不是缓存友好。它们的节点倾向于分散在整个地址空间中。访问内存的最快方法是通过读取顺序地址。L1高速缓存的存储单位为64字节。换句话说,一旦处理器读取一个字节,下一个63就会非常快,因为它们将出现在缓存中。

这使数组成为最有效的数据结构。也是.NET List <>类根本不是列表的原因,它使用数组进行存储。其他集合类型(如Dictionary)的结构相同,在结构上与数组在远程上并不相似,但在内部使用数组实现。

因此,您的while()语句很可能会遭受CPU停顿的困扰,因为它正在取消引用访问BranchData字段的指针。下一条语句非常便宜,因为while()语句已经完成了从内存中检索值的繁重工作。分配局部变量很便宜,处理器使用缓冲区进行写操作。

要解决这个简单的问题不是很简单,将树扁平化为阵列很不切实际。至少因为您通常无法预测树的节点将以什么顺序访问。红黑树可能会有所帮助,这个问题尚不清楚。得出的一个简单结论是,它已经以您希望的速度运行。而且,如果您需要更快的速度,那么您将需要具有更快内存总线的更好硬件。DDR4今年将成为主流。


1
也许。它们很可能已经在内存中相邻,因此在缓存中也相邻,因为您是一个接一个地分配的。对于GC堆压缩算法,否则将对此产生不可预测的影响。最好不要让我对此猜测,请测量一下以便知道一个事实。
汉斯·帕桑

11
线程不能解决这个问题。给您更多的内核,您仍然只有一条内存总线。
汉斯·帕桑

2
也许使用b树会限制树的高度,所以您将需要访问更少的指针,因为每个节点都是单个结构,因此可以将其有效地存储在缓存中。另请参阅此问题
MatthieuBizien

4
像往常一样深入解释,并提供广泛的相关信息。+1
提格伦

1
如果您知道对树的访问模式,并且遵循80/20(80%的访问总是在相同的20%节点上)规则,那么像调整树之类的自调整树也可能会更快。 zh.wikipedia.org/wiki/Splay_tree
詹斯·蒂默曼

10

为了补充Hans关于内存缓存效果的出色答案,我添加了有关虚拟内存到物理内存转换和NUMA效果的讨论。

对于虚拟内存计算机(当前所有计算机),在进行内存访问时,每个虚拟内存地址都必须转换为物理内存地址。这是由内存管理硬件使用转换表完成的。该表由操作系统针对每个进程进行管理,它本身存储在RAM中。对于虚拟内存的每个页面,此转换表中都有一个条目,用于将虚拟页面映射到物理页面。请记住,汉斯关于内存访问的讨论很昂贵:如果每个虚拟到物理转换都需要进行内存查找,那么所有内存访问的成本将是原来的两倍。解决方案是为转换表提供一个缓存,称为转换后备缓冲区(TLB的简称)。TLB并不大(12到4096个条目),x86-64体系结构上的典型页面大小仅为4 KB,这意味着TLB命中最多可直接访问16 MB(它可能比Sandy还要小)TLB大小为512项的桥)。为了减少TLB丢失的次数,您可以使操作系统和应用程序一起使用更大的页面大小(例如2 MB),从而可以通过TLB命中访问访问更大的内存空间。该页面说明了如何在Java中使用大页面, 这可以大大加快内存访问

如果您的计算机有许多插槽,则可能是NUMA体系结构。NUMA表示非统一内存访问。在这些体系结构中,某些内存访问比其他访问花费更多。例如,对于具有32 GB RAM的2插槽计算机,每个插槽可能具有16 GB RAM。在此示例计算机上,本地内存访问比访问另一个套接字的内存便宜(远程访问要慢20%到100%,甚至更多)。如果在这样的计算机上,树使用20 GB的RAM,另一个NUMA节点上至少有4 GB的数据,并且如果远程内存访问速度降低50%,则NUMA访问会使您的内存访问速度降低10%。此外,如果单个NUMA节点上只有空闲内存,则饥饿节点上所有需要内存的进程将从其他节点分配内存,而其他节点的访问成本更高。甚至更糟的是,操作系统可能认为最好换掉饥饿节点的部分内存,这将导致更昂贵的内存访问。这在MySQL“交换疯狂”问题和NUMA体系结构的影响中有更详细的说明,其中给出了针对Linux的一些解决方案(在所有NUMA节点上分布内存访问,在远程NUMA访问中使用子句以免交换)。我还可以考虑为插槽分配更多的RAM(24 GB和8 GB而不是16 GB和16 GB),并确保将程序安排在较大的NUMA节点上,但这需要物理访问计算机和螺丝刀;-) 。


4

这本身并不是答案,而是重点在于汉斯·帕桑特(Hans Passant)关于存储系统延迟的论述。

真正高性能的软件-例如计算机游戏-不仅是为实现游戏本身而编写的,还经过修改,使得代码和数据结构充分利用了缓存和内存系统,即将它们视为有限的资源。当我处理高速缓存问题时,我通常假设L1将在3个周期内交付(如果那里存在数据)。如果不是,我必须去L2,我假设有10个周期。对于L3 30个周期,对于RAM存储器100。

还有一个与内存相关的附加操作,如果需要使用,则会加重罚款,这是总线锁定。如果使用Windows NT功能,则总线锁定称为关键部分。如果您使用本地品种,则可以称其为自旋锁。无论使用什么名称,在锁定到位之前,它都会同步到系统中最慢的总线主控设备。最慢的总线主控设备可能是在33MHz下连接的经典32位PCI卡。33MHz是典型x86 CPU(@ 3.3 GHz)频率的百分之一。我假定完成总线锁定的周期不少于300个,但是我知道它们可能花费很多倍的时间,因此,如果我看到3000个周期,我不会感到惊讶。

新手多线程软件开发人员将在各处使用总线锁,然后想知道为什么他们的代码很慢。窍门-与所有与内存有关的事情-都是为了节省访问量。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.