引擎盖下的C#索引器:索引比道琼斯更好

大家好 在本文中,我建议结识各种类型的索引器。 让我们看一下这些索引器的汇编语言代码,以及每条指令在其速度方面的特征。 我还将提供一些显而易见的结论。 但是,要在特定情况下使用什么完全取决于您是否牺牲便利性来提高速度,反之亦然。

图片

指标


给出了针对64位系统的汇编语言代码。 选择以下度量作为每条指令的度量:合并的微操作数,微操作总数,延迟,吞吐量,当然还有指令数。 我没有为索引器提供任何数字,因为 根据您使用索引类型的方式以及对缓存的不同影响,情况可能会有所不同。

以下是术语的简要概述,而没有深入探讨更深的概念概念。 我的目标是为了达成共识而尽可能简单地描述一切。

微操作(uop)是每条指令组成的特定操作。 微操作的概念用于优化,例如合并,缓存和重新排序。 因此,例如,MOV指令由1个微操作组成,而两个寄存器上的XCHG指令由3个微操作组成(方法是通过“临时变量”,即内部寄存器,感谢leotsarev的更新),即XCHG指令寄存器和存储器上的8个微操作组成。

合并的微操作(融合的微指令) -如上所述,合并的微操作是最优化之一。 它包括用一个复杂的组件替换两个微操作。

延迟是度量值的数量,在此之后,该指令中使用的数据将可供另一条指令使用。

吞吐量(互惠吞吐量) -执行一条指令所需的时钟周期数,前提是要执行一系列相同的指令,并且它们使用独立的数据进行操作。

根据这些指标,您可以评估一组特定指令的性能。 请注意,我们只能“评估”,实际性能取决于许多因素,例如命中或未命中缓存,数据依赖性等。

这些数字是针对英特尔处理器架构Skylake-X的。 这对应于我的Intel Core i7-6700处理器。

还应该记住,对于64位系统的快速调用在寄存器(rcx,rdx,r8,r9)中提供的不是2而是4个参数的传输。

数字索引器


1.数组索引器


我们将以以下方法为例:

public int IndexerArray(int index, int[] array) { return array[index]; } 

考虑此代码段的汇编语言代码。

 1. cmp edx,dword ptr [r8+8] 2. jae 00007ff9`07288c78 3. movsxd rax,edx 4. mov eax,dword ptr [r8+rax*4+10h] 

第一行检查索引是否超出数组的范围。 如果第二行退出,则会引发异常。 接下来,我们计算元素在数组中的位置。 数组中的第一个字段是服务信息,因此我们需要跳过它们(附加的10h = 16个字节)。

有关说明的信息:
不行融合微片总计延迟时间互惠吞吐量
1个1个21个0.5
21个1个1-2
31个1个1个0.25
41个1个20.5



2.收藏夹列表<>


墨水代码:
 public int IndexerList(int index, List<int> list) { return list[index]; } 


汇编语言代码:

 1. cmp edx,dword ptr [r8+10h] 2. jae M00_L00 3. mov rax,qword ptr [r8+8] 4. cmp edx,dword ptr [rax+8] 5. jae 00007ff9`07268f56 6. movsxd rdx,edx 7. mov eax,dword ptr [rax+rdx*4+10h] ret M00_L00 call System.ThrowHelper.ThrowArgumentOutOfRange_IndexException() 

显然这里有更多说明。 可以清楚地看到,工作表索引器包装了数组索引器。 有趣的一点是,要检查两次超出数组的边界。 因此,第一条指令将检查索引是否超出工作表的边界。 如果是这样,那么我们跳到(指令2)一个非常明显的调用,如果它超出数组的边界,则抛出异常。 此边界检查使用工作表的内部字段,该字段的顺序为第二个字段(从类型的开头偏移10h(16)个字节,第8个字段指向方法表的指针,第8个字段指向内部数组的链接-第一个字段)。 在第三行中,我们在rax寄存器中放置内部数组的地址-第一个字段(类推,偏移量为8个字节是指向方法表的指针)。 接下来是已经熟悉的部分-数组的索引引用(第4-7行)。 在这里,为了检查边界,使用了数组的内部字段。
我试图删除与索引没有直接关系的内容,但是这里值得保留ret,这样看来在每次调用工作表元素的末尾都不会出现异常:D

顺便说一句,为了避免使您感到困惑,请通过参考熟悉该表的实现。 类型转换为无符号整数用于减少比较次数。

结果,我们获得了7条成功访问索引的指令,这比数组中的索引多了3条。

有关说明的信息:
不行融合微片总计延迟时间互惠吞吐量
1个1个21个0.5
21个1个1-2
31个1个20.5
41个21个0.5
51个1个1-2
61个1个1个0.25
71个1个20.5


新增-跨度<>


光盘:

 public int IndexerSpan(int index, Span<int> span) { return span[index]; } 

并使用汇编语言:

 1. cmp edx,dword ptr [r8+8] 2. jae 00007ff9`07278f69 3. mov rax,qword ptr [r8] 4. movsxd rdx,edx 5. mov eax,dword ptr [rax+rdx*4] 

宣布跨度时,他们向我们保证,在运行时的支持下,它们是明智的。 而且他们也没有说谎。 实际上,它与经典数组的区别仅在于一条指令,这是访问地址的另一步骤。 通过此代码判断,内存位置的地址隐藏在元素所在的跨度内,这在第3行中得到。它可以是数组,行或堆栈中一块内存中特定位置的地址。
单击此处以获取有趣的Span索引器的介绍。 您可能会注意到,有两种不同的实现,具体取决于环境变量。 PROJECTN是用于UWP的.NET Native的第一个版本的代号。 因此,我们对索引器的第二版更感兴趣。 她被标记为[本征] 。 此外,如果查看此索引器的实现中使用的静态Unsafe类,则可以找到信息,该文件中大多数方法的实现都表示为Intrinsic

在运行时支持方法调用或对带有[Intrinsic]属性标记的字段的引用。

CoreCLR中 ,将此类方法的主体替换为带有不安全代码(unsafe)的EE(执行引擎)。 如果需要更多详细信息,可以开始使用getILIntrinsicImplementationForUnsafe方法进行挖掘。

有关它在CoreRT中如何工作的信息 (这让我有些兴趣),
您可以开始查看Internal.IL.Stubs.UnsafeIntrinsics

有了下雨天的这种支持,为了了解幕后究竟会发生什么,有必要查看一下我们所做的汇编语言说明。

有关说明的信息:
不行融合微片总计延迟时间互惠吞吐量
1个1个21个0.5
21个1个1-2
31个1个20.5
41个1个1个0.25
51个1个20.5


所有索引器都高度依赖数据:指令使用先前索引的结果。 这里没有异常结果,应该没有。 但是现在在这种情况下出现的开销已经很清楚了。 一些明显的发现。 如果该算法涉及按索引进行的非常频繁的访问,则考虑使用数组替换工作表是有意义的。 如果调用不是很频繁,则使用提供了非常方便的api并且没有太大开销的工作表可能会更方便(我提醒您监视内部数组的扩展)。

现在让我们来看一下指定二维数组的不同方法:数组数组(锯齿状数组)和多维数组(多维数组)。

4.多维数组


清晰的代码:

 public int IndexerDimensionalArray(int index1, int index2, int[,] demensionalArray) { return demensionalArray[index1, index2]; } 

汇编语言:

 1. mov eax,edx 2. sub eax,dword ptr [r9+18h] 3. cmp eax,dword ptr [r9+10h] 4. jae 00007ff9`00098fe6 5. mov edx,r8d 6. sub edx,dword ptr [r9+1Ch] 7. cmp edx,dword ptr [r9+14h] 8. jae 00007ff9`00098fe6 9. mov ecx,dword ptr [r9+14h] 10. imul rcx,rax 11. mov rax,rdx 12. add rax,rcx 13. mov eax,dword ptr [r9+rax*4+20h] 

原则上一切都是可以理解的-2检查数组的边界,然后计算索引并反转。 该数组以一个片段的形式存储在内存中。

有关说明的信息:
不行融合微片总计延迟时间互惠吞吐量
1个1个1个0-10.25
21个20.5
31个21个0.5
41个1个1-2
51个1个0-10.25
61个20.5
71个21个0.5
81个1个1-2
91个1个20.5
101个1个31个
111个1个0-10.25
121个1个1个0.25
131个1个20.5



5.数组数组(锯齿数组)


 public int IndexerJaggedArray(int index, int index2, int[][] jaggedArray) { return jaggedArray[index][index2]; } 

汇编器:

 1. cmp edx,dword ptr [r9+8] 2. jae 00007ff9`00098f95 3. movsxd rax,edx 4. mov rax,qword ptr [r9+rax*8+10h] 5. cmp r8d,dword ptr [rax+8] 6. jae 00007ff9`00098f95 7. movsxd rdx,r8d 8. mov eax,dword ptr [rax+rdx*4+10h] 

最有趣的是-与针对多维的特制类型相比,我们的指令更少。

有关说明的信息:
不行融合微片总计延迟时间互惠吞吐量
1个1个21个0.5
21个1个1-2
31个1个1个0.25
41个1个20.5
51个21个0.5
61个1个1-2
71个1个1个0.25
81个1个20.5


但是,关于最后两个示例,我建议您不要急于下结论。 由于二维数组是单一类型的,并且初始化了1次,因此整个数组的内存分配在一个大片段中。 这将提供更好的缓存,从根本上可以改变情况。 在一个数组数组中,每个数组的内存都将单独分配,因此很有可能会将数组分配到内存中并输入最适合它们的位置。

但是,对于某些人来说,这种行为可能会更容易接受。 也许在某些情况下,已知该样品的寿命会很短。 而且为了不掉入一大堆物体(这是垃圾收集器的第二代),在那里有一个很长的停留时间,比我们想要的要多得多。 或者过了一段时间,我们只想处理某些行,其他所有内容都可以清除。 另外,当缓存无法正常工作时,计划通过引用随机不一致的元素来使用该类型。

同样,当使用数组数组时,很有可能不是促使垃圾收集器进行压缩,而是进行清除。 提醒:碎片化内存时,可用空间的总量可能足以容纳一个新对象,但没有所需数量的连续可用空间。 在这种情况下,执行压缩-以碎片整理为目标移动对象。 如果我们能够为新对象获取连续的空闲内存,则只需将对象输入此空闲空间即可。 这称为扫掠。

我希望这些信息可以帮助您得出正确的结论,并在有关使用什么的讨论中证实您的意见。

Source: https://habr.com/ru/post/zh-CN450428/


All Articles