为什么我的应用程序要花24%的时间做空检查?

本文关键字:时间 检查 我的 应用程序 为什么 | 更新日期: 2023-09-27 18:13:53

我有一个性能关键的二叉决策树,我想把这个问题集中在一行代码上。下面是二叉树迭代器的代码,以及对它运行性能分析的结果。

        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检查从while中分离出来-这是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内添加了一个相同的if,所以我们有

while (node.BranchData != null)

if (node.BranchData != null)

内。然后我对它进行性能分析,执行第一次比较所花费的时间是执行总是返回true的第二次比较所花费的时间的6倍。所以看起来这确实是一个分支预测问题-我猜我对此无能为力?!

另一个编辑

上述结果也会发生,如果节点。BranchData必须从RAM中加载用于while检查-然后它将被缓存用于if语句。


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

  • 我可以使用比树更快的数据结构吗?
  • c#中遍历树的微优化

为什么我的应用程序要花24%的时间做空检查?

树是巨大的

到目前为止,处理器所做的最昂贵的事情不是执行指令,而是访问内存。现代CPU的执行核心比内存总线快很多倍。一个与距离有关的问题,电信号必须传播得越远,就越难将信号传递到电线的另一端而不被破坏。解决这个问题的唯一方法就是让它慢下来。连接CPU和RAM的电线有一个大问题,你可以打开机箱,看到电线。 对于这个问题,处理器有一个对策,它们使用缓存,在RAM中存储字节副本的缓冲区。一个重要的缓存是L1缓存,通常为16千字节的数据和16千字节的指令。小,允许它接近执行引擎。从L1缓存读取字节通常需要2或3个CPU周期。下一个是二级缓存,更大更慢。高档处理器也有L3缓存,更大更慢。随着工艺技术的进步,这些缓冲器占用的空间更少,并且在靠近核心时自动变得更快,这是新处理器更好以及它们如何设法使用越来越多的晶体管的重要原因。 然而,这些缓存并不是一个完美的解决方案。如果数据在其中一个缓存中不可用,处理器仍然会在内存访问时停止。它不能继续,直到非常慢的内存总线提供了数据。在一条指令上损失多达100个CPU周期是可能的。

树结构是一个问题,它们是缓存友好的。它们的节点往往分散在整个地址空间中。访问内存最快的方法是从顺序地址中读取。L1缓存的存储单元是64字节。或者换句话说,一旦处理器读取了一个字节,接下来的63个字节就会非常快,因为它们会存在于缓存中。

使得数组成为目前为止最有效的数据结构。此外,. net list <>类根本不是列表的原因是,它使用数组进行存储。对于其他集合类型(如Dictionary)也是如此,在结构上与数组一点也不相似,但在内部用数组实现。

所以你的while()语句很可能会遭受CPU停滞,因为它正在解引用一个指针来访问BranchData字段。下一条语句非常便宜,因为while()语句已经完成了从内存中检索值的繁重工作。给局部变量赋值很便宜,处理器使用缓冲区进行写操作。

不是一个简单的问题来解决,扁化你的树成数组很可能是不切实际的。一点也不,因为您通常无法预测访问树的节点的顺序。从问题上看,红黑树可能会有帮助。因此,一个简单的结论是,它的运行速度已经达到了你所希望的速度。如果你想让它运行得更快,那么你就需要更好的硬件和更快的内存总线。DDR4今年将成为主流。

为了补充Hans关于内存缓存效果的精彩回答,我在物理内存转换和NUMA效果中添加了虚拟内存的讨论。

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

如果你的计算机有很多套接字,它可能是一个NUMA架构。NUMA是指非统一内存访问。在这些体系结构中,一些内存访问的成本比其他高。举个例子,一个有32 GB RAM的2插槽计算机,每个插槽可能有16 GB RAM。在这个示例计算机上,本地内存访问比访问另一个套接字的内存便宜(远程访问要慢20%到100%,甚至更多)。如果在这样的计算机上,您的树使用20 GB的RAM,那么至少有4 GB的数据在另一个NUMA节点上,并且如果对远程内存的访问速度慢50%,则NUMA访问将使内存访问速度慢10%。此外,如果在单个NUMA节点上只有空闲内存,那么所有需要耗尽节点上内存的进程都将从访问成本更高的另一个节点分配内存。更糟糕的是,操作系统可能认为交换出饥渴节点的部分内存是一个好主意,这将导致更昂贵的内存访问。这在MySQL"交换疯狂"问题和NUMA架构的影响中有更详细的解释,其中给出了Linux的一些解决方案(在所有NUMA节点上扩展内存访问,在远程NUMA访问上采取措施以避免交换)。我还可以考虑为套接字分配更多的RAM(24和8 GB而不是16和16 GB),并确保您的程序在更大的NUMA节点上调度,但这需要对计算机的物理访问和螺丝刀;-)。

这不是一个答案本身,而是对汉斯·帕桑所写的关于内存系统延迟的强调。

真正的高性能软件——比如电脑游戏——不仅是为了实现游戏本身而编写的,而且是为了使代码和数据结构充分利用缓存和内存系统而改编的,而不是将它们视为有限的资源。当我处理缓存问题时,我通常假设如果数据存在L1中,那么L1将在3个周期内交付。如果它不是,我必须到达L2,我假设是10个循环。L3为30个周期,RAM为100个周期。

有一个额外的与内存相关的操作,如果你需要使用它,它会带来更大的惩罚,那就是总线锁。如果您使用Windows NT功能,则总线锁称为临界区。如果你用的是本土品种,你可以称之为自旋锁。无论名称是什么,它都会在锁定到位之前同步到系统中最慢的总线控制设备。最慢的总线控制设备可能是连接在33MHz的经典32位PCI卡。33MHz是典型x86 CPU (@ 3.3 GHz)频率的百分之一。我假设不少于300个周期来完成一个总线锁,但我知道它们可能需要很多倍的时间,所以如果我看到3000个周期,我不会感到惊讶。

新手多线程软件开发人员会到处使用总线锁,然后想知道为什么他们的代码很慢。这个技巧——和所有与内存有关的事情一样——是为了节省访问。