我有一些高性能代码。使用SSEn和AVX的SIMD实现需要约30条指令,而使用4096字节查找表的版本只需要约8条指令。在微基准测试中,查找表快了40%。如果我进行缓存无效化测试,每100次迭代,它们看起来差不多。在我的实际程序中,似乎非加载版本更快,但很难得到可靠的测量结果,而我的测量结果两种方式都有过。
我想知道是否有一些好的方法来考虑哪个更好使用,或者这种决策的标准基准测试技术。
我有一些高性能代码。使用SSEn和AVX的SIMD实现需要约30条指令,而使用4096字节查找表的版本只需要约8条指令。在微基准测试中,查找表快了40%。如果我进行缓存无效化测试,每100次迭代,它们看起来差不多。在我的实际程序中,似乎非加载版本更快,但很难得到可靠的测量结果,而我的测量结果两种方式都有过。
我想知道是否有一些好的方法来考虑哪个更好使用,或者这种决策的标准基准测试技术。
Cody Gray已经涵盖了大部分内容,所以我只会加入一些自己的想法。请注意,我对LUT的看法并不像Cody那么消极:与其给它们一个普遍的“thumbs down”,我认为您需要仔细分析缺点。特别是,LUT越小,就越有可能将其与计算方法进行苹果对苹果的比较。
通常有些情况下,即时计算这些值非常昂贵,或者仅需要小型LUT。虽然在接近平局的情况下,我也使用相同的经验法则:如果LUT方法只是稍微快一点,我通常会选择计算方法,但也有一些例外情况(例如,输入大小非常大,使得LUT将保留并用于许多计算)。
本节后面的大部分讨论都不是SIMD特定的-它适用于标量和SIMD代码。在我们到达那里之前,让我们谈一谈LUT,因为它与SIMD有关。
对于SIMD代码,LUT有一些优点,但也有额外的缺点。主要缺点是,在下面讨论的PSHUFB
类型诡计之外,没有好的SIMD等价于标量LUT代码。也就是说,虽然你可以使用SIMD每个指令进行N(其中N是SIMD宽度)个并行独立计算,但通常不能进行N个查找。通常情况下,你在SIMD代码中每个周期的查找次数与LUT代码中的查找次数相同,现代硬件中2个/周期是一个常见的数字。
gather
指令可用,但它无法克服这个基本限制:你仍然会受到每周期2次加载的限制。在gather
中,你能够期望的最好情况是它能够注意到两个加载地址相同(或者至少“足够接近”),以便它们可以由同一次加载满足。6
而SIMD让你做的是更宽的加载。因此,你可以一次性加载32个连续字节。这通常对于直接向量化标量查找没有用处,但它可以启用一些其他技巧(例如,向量本身可以是一个表,你可以使用“寄存器中的LUT”之类的东西进行第二次查找,如下所述)。您将代码向量化意味着您可能预计中等或大型问题规模,这有助于分摊LUT的缓存成本。
与标量代码相比,SIMD更喜欢加载许多掩码和其他常量:但是通过“计算”计算洗牌掩码等内容通常很困难,因此LUT通常是自然的选择。
SIMD指令集通常没有直接加载立即常量的方法,不像它们的标量兄弟,因此您通常会从内存中加载固定常量。在那时,如果某些后续计算的部分可以通过查找而不是加载固定常量来折叠到加载中,请看是否有意义(您已经支付了延迟惩罚等)。
SIMD指令集通常具有可重新用于“寄存器内查找”的洗牌/置换指令功能,如下所述。
在使用SIMD代码进行LUT时,请记住要保持表格的小型化,如果可以的话,应避免16或32字节宽的表格条目。除了下面缩小表格的技巧外,如果条目具有某些规律性,您还可以经常使用广播或“解包”指令。在某些情况下(最近的x86),这些指令可能是“免费”的,当替换普通加载时。
确实,微基准测试几乎总是不公平地支持基于LUT的方法-但是它对特定代码的影响程度取决于很多因素。像往常一样,最好的方法是简单地对真实负载进行两种方式的分析。当然,这并不总是切实可行的,并且还存在“增量决策问题”1...
如果您基于基准测试做出一系列优化决策,并根据实际基准测试每次选择“最佳”方法,那么后续的决策可能会使早期的决策失效。例如,假设您正在考虑为函数A使用LUT或计算。您可能会发现,在实际基准测试中,LUT略快,因此您会实现它。第二天,您再次测试功能B的新实现,再次使用LUT与计算方法进行比较 - 您可能再次发现LUT比计算更好,因此您会实现它 - 但是如果您回头测试A,结果可能会不同! 现在,由于为函数B添加了LUT导致缓存争用增加,因此A可能更适合使用计算方法。如果您按相反的顺序优化函数,则不会出现这个问题2。不幸的是,这种影响本质上是“整个程序”的非线性效应,因此很难测量。也就是说,您可能会多次选择更大但更快的代码而没有明显的指令缓存惩罚,但然后您会越过某个阈值,您会得到几个百分点的下降-所谓的最后一根稻草。因此,它很难在孤立的情况下进行衡量和做出正确的决策。
通常比较的是纯LUT与计算方法。通常有一种折中方法,可以使用较小的LUT与一些计算结合使用。
这种计算可能在查找之前进行,其中您将输入域映射到具有较小域的索引中,以便映射到相同索引的所有输入具有相同的答案。一个简单的例子是计算奇偶性:您可以使用一个65K查找表“快速”(在微基准意义上!)完成此操作,但您也可以仅对输入进行异或折叠,例如input ^ (input >> 8)
,然后使用底部字节来索引256个条目的表。因此,您可以将表的大小缩小256倍,代价是多了几个指令(但仍比完整计算方法快得多)。lut bool[256]
实现,成本为256个字节。但是,每个条目实际上只需要一个位(总共32个字节),而不是一个字节 - 如果您愿意在查找后“解压缩”,则可以像return bitwise_lut[value] & (1 << (value & 7))
这样做。PSHUFB
指令(首次出现于SSSE3)将这个想法发挥到极致。在其128位SSE版本中,它实际上允许您在单个周期内执行16个并行的4位到8位查找。AVX2版本允许您同时执行32个这样的查找。因此,您可以进行高强度查找,而几乎没有真正LUT的大部分缺点(即表格保存在寄存器中 - 尽管您可能需要一次加载才能将其放入其中)。PSHUFB
操作和相似数量的混合操作将其扩展到32、64等元素表,但这仍然只适用于相当小的表。
1或许你也可以称之为“路径依赖优化”或“非加性优化”问题。
2当然,知道在这种情况下优化B然后再优化A会起作用更多的是学术意义而非实际价值,因为没有好的方法事先知道正确的顺序。
3这比你想象的要常见得多——有效的真实世界测试不仅仅是懒惰所导致的,还可能包括许多其他因素,例如(a)没有单一的“规范”负载,因为应用程序或库在非常不同的上下文中使用,(b)没有“规范”负载,因为应用程序未发布,实际使用模式尚不清楚,(c)无法在未来的硬件上进行测试,甚至可能尚不存在,(d)整个应用程序比所涉及的函数要大得多,差异在噪声中被忽略了,(e)由于数据隐私问题(无法获取客户数据),无法复制现实世界的情况等等。
4编译器、浏览器和各种即时编译代码都会想到。
5 例如,通过使用由顺序预取带来的高速缓存行,否则可能会浪费,或者至少将代码和LUT定位在同一页4K中,可能可以节省TLB miss。
6 值得注意的是,在英特尔上,尽管已经发布了至少4个新芯片,gather
仍然不支持此功能:即使加载的索引中存在重复,它最多也只能每个周期进行2次加载。