【问题标题】:Fastest way to compute absolute value using SSE使用 SSE 计算绝对值的最快方法
【发布时间】:2015-12-01 05:43:19
【问题描述】:

我知道3种方法,但据我所知,一般只使用前2种:

  1. 使用andpsandnotps 屏蔽符号位。

    • 优点:如果掩码已在寄存器中,则为一条快速指令,非常适合在循环中多次执行此操作。
    • 缺点:掩码可能不在寄存器中或更糟,甚至不在缓存中,这会导致很长的内存提取。
  2. 将值从零减去取反,然后得到原始和取反的最大值。

    • 优点:固定成本,因为不需要像面具一样获取任何东西。
    • 缺点:如果条件理想,总是会比掩码方法慢,并且我们必须等待 subps 完成才能使用 maxps 指令。
  3. 与选项 2 类似,将原始值从零减去以求反,然后使用 andps 将结果与原始值“按位与”。我进行了一个测试,将其与方法 2 进行了比较,它似乎与方法 2 的行为相同,除了在处理 NaNs 时,在这种情况下,NaN 的结果将与方法 2 的结果不同。

    • 优点:应该比方法 2 稍快,因为 andps 通常比 maxps 快。
    • 缺点:当涉及NaNs 时,这会导致任何意外行为吗?也许不是,因为 NaN 仍然是 NaN,即使它是 NaN 的不同值,对吧?

欢迎提出想法和意见。

【问题讨论】:

  • 掩码不需要从内存中加载。它可以很容易地在两条全寄存器指令中计算出来。
  • @RaymondChen,是的,但这仍然是 2 条额外指令和绕过延迟,可能使其始终比方法 2 或 3 慢。
  • 如何左移一位,然后无符号右移一位?
  • @RaymondChen,很有趣。我其实没有想到这一点。所以,总共有 2 个班次指令。但也不能乱序发生,并且也可能会根据 CPU 产生旁路延迟。不过,值得测试。
  • 与其假设为什么不对几种方法进行基准测试,看看是否有任何显着差异?

标签: x86 vectorization sse simd absolute-value


【解决方案1】:

TL;DR:在几乎所有情况下,使用 pcmpeq/shift 生成掩码,然后使用 andps 来使用它。 它具有迄今为止最短的关键路径(与 constant-from-memory ),并且不能缓存未命中。

如何用内在函数做到这一点

让编译器在未初始化的寄存器上发出pcmpeqd 可能很棘手。 (godbolt)。 gcc / icc 的最佳方式看起来是

__m128 abs_mask(void){
  // with clang, this turns into a 16B load,
  // with every calling function getting its own copy of the mask
  __m128i minus1 = _mm_set1_epi32(-1);
  return _mm_castsi128_ps(_mm_srli_epi32(minus1, 1));
}
// MSVC is BAD when inlining this into loops
__m128 vecabs_and(__m128 v) {
  return _mm_and_ps(abs_mask(), v);
}


__m128 sumabs(const __m128 *a) { // quick and dirty no alignment checks
  __m128 sum = vecabs_and(*a);
  for (int i=1 ; i < 10000 ; i++) {
      // gcc, clang, and icc hoist the mask setup out of the loop after inlining
      // MSVC doesn't!
      sum = _mm_add_ps(sum, vecabs_and(a[i])); // one accumulator makes addps latency the bottleneck, not throughput
  }
  return sum;
}

clang 3.5 及更高版本“优化”了 set1 / shift 以从内存中加载常量。不过,它将使用pcmpeqd 来实现set1_epi32(-1)TODO:使用 clang 查找生成所需机器代码的内在函数序列。从内存中加载一个常量并不是性能灾难,但是让每个函数都使用一个不同的掩码副本是相当糟糕的。

MSVC:VS2013:

  • _mm_uninitialized_si128() 未定义。

  • _mm_cmpeq_epi32(self,self) 在未初始化变量上将在此测试用例中发出movdqa xmm, [ebp-10h](即从堆栈中加载一些未初始化的数据。与仅从内存中加载最终常量相比,缓存未命中的风险更小。然而,Kumputer 说 MSVC 没有设法将 pcmpeqd / psrld 提升出循环(我假设在内联 vecabs 时),因此除非您手动内联并自己将常量提升出循环,否则这是不可用的。

  • 使用 _mm_srli_epi32(_mm_set1_epi32(-1), 1) 会导致 movdqa 加载一个全为 -1 的向量(在循环外提升),并在循环内加载一个 psrld。所以这完全是可怕的。如果你要加载一个 16B 的常量,它应该是最终的向量。让整数指令在每次循环迭代时生成掩码也很糟糕。

对 MSVC 的建议:放弃动态生成掩码,直接写

const __m128 absmask = _mm_castsi128_ps(_mm_set1_epi32(~(1<<31));

您可能只会将掩码作为 16B 常量存储在内存中。希望不要为使用它的每个功能重复。将掩码放在内存常量中更有可能在 32 位代码中有所帮助,其中您只有 8 个 XMM 寄存器,因此vecabs 如果没有可用的寄存器来保持常量,则可以只与内存源操作数进行 ANDPS躺着。

TODO:了解如何避免在内联的任何地方复制常量。可能使用全局常量而不是匿名set1 会很好。但是你需要初始化它,但我不确定内在函数是否可以作为全局 __m128 变量的初始化程序。您希望它进入只读数据部分,而不是在程序启动时运行构造函数。


或者,使用

__m128i minus1;  // undefined
#if _MSC_VER && !__INTEL_COMPILER
minus1 = _mm_setzero_si128();  // PXOR is cheaper than MSVC's silly load from the stack
#endif
minus1 = _mm_cmpeq_epi32(minus1, minus1);  // or use some other variable here, which will probably cost a mov insn without AVX, unless the variable is dead.
const __m128 absmask = _mm_castsi128_ps(_mm_srli_epi32(minus1, 1));

额外的 PXOR 非常便宜,但它仍然是一个 uop,并且仍然是 4 字节的代码大小。如果有人有任何更好的解决方案来克服 MSVC 不愿发布我们想要的代码,请发表评论或编辑。但是,如果内联到循环中,这并不好,因为 pxor/pcmp/psrl 都将在循环内。

使用movd 加载一个32 位常量并使用shufps 进行广播可能没问题(同样,您可能必须手动将其从循环中提升出来)。那是 3 条指令(mov-immediate to a GP reg, movd, shufps),并且 movd 在 AMD 上很慢,其中向量单元在两个整数内核之间共享。 (他们的超线程版本。)


选择最佳的 asm 序列

好的,让我们看看这个,让我们说通过 Skylake 的 Intel Sandybridge,并稍微提到 Nehalem。请参阅Agner Fog's microarch 指南和说明时间,了解我是如何解决这个问题的。我还使用了某人在 http://realwordtech.com/ 论坛上的帖子中链接的 Skylake 号码。


假设我们想要 abs() 的向量在 xmm0 中,并且是 FP 代码典型的长依赖链的一部分。

所以让我们假设任何不依赖于xmm0 的操作可以在xmm0 准备好之前开始几个周期。我已经测试过,假设内存操作数的地址不是 dep 链的一部分(即不是关键路径的一部分),带有内存操作数的指令不会给依赖链增加额外的延迟。


当它是微融合 uop 的一部分时,我不完全清楚内存操作可以多早开始。据我了解,重新排序缓冲区(ROB)与融合的微指令一起使用,并跟踪微指令从发布到退休(168(SnB)到 224(SKL)条目)。还有一个在未融合域中工作的调度程序,仅保存输入操作数已准备好但尚未执行的微指令。 uop 可以在解码(或从 uop 缓存加载)时同时发送到 ROB(已融合)和调度程序(未融合)。 If I'm understanding this correctly, it's 54 to 64 entries in Sandybridge to Broadwell 和 Skylake 中的 97。 There's some unfounded speculation about it not being a unified (ALU/load-store) scheduler anymore.

还有人谈到 Skylake 每时钟处理 6 微秒。据我了解,Skylake 将每个时钟将整个 uop 缓存行(最多 6 个 uops)读取到 uop 缓存和 ROB 之间的缓冲区中。进入 ROB/调度程序的问题仍然是 4-wide。 (即使nop 仍然是每时钟 4 个)。这个缓冲区有助于 其中code alignment / uop cache line boundaries 导致以前的 Sandybridge-microarch 设计出现瓶颈。我之前认为这个“问题队列”就是这个缓冲区,但显然不是。

不管它如何工作,如果地址不在关键路径上,调度程序足够大,可以及时从缓存中获取数据


1a:带有内存操作数的掩码

ANDPS  xmm0, [mask]  # in the loop
  • 字节:7 个 insn,16 个数据。 (AVX:8 个)
  • 融合域微指令:1 * n
  • 向关键路径添加延迟:1c(假设 L1 缓存命中)
  • 吞吐量:1/c。 (Skylake: 2/c)(限2次加载/c)
  • 如果xmm0 在此insn 发出时已准备好,则为“延迟”:L1 缓存命中时约4c。

1b:来自寄存器的掩码

movaps   xmm5, [mask]   # outside the loop

ANDPS    xmm0, xmm5     # in a loop
# or PAND   xmm0, xmm5    # higher latency, but more throughput on Nehalem to Broadwell

# or with an inverted mask, if set1_epi32(0x80000000) is useful for something else in your loop:
VANDNPS   xmm0, xmm5, xmm0   # It's the dest that's NOTted, so non-AVX would need an extra movaps
  • 字节:10 insn + 16 数据。 (AVX:12 个 insn 字节)
  • 融合域微指令:1 + 1*n
  • 延迟添加到 dep 链:1c(在循环早期使用相同的缓存未命中警告)
  • 吞吐量:1/c。 (Skylake: 3/c)

PAND 是 Nehalem 到 Broadwell 的吞吐量 3/c,但延迟 = 3c(如果在两个 FP 域操作之间使用,在 Nehalem 上甚至更糟)。我猜只有端口 5 具有将按位运算直接转发到其他 FP 执行单元(Skylake 之前)的接线。在 Nehalem 之前,在 AMD 上,按位 FP 操作与整数 FP 操作相同,因此它们可以在所有端口上运行,但有转发延迟。


1c:动态生成掩码:

# outside a loop
PCMPEQD  xmm5, xmm5  # set to 0xff...  Recognized as independent of the old value of xmm5, but still takes an execution port (p1/p5).
PSRLD    xmm5, 1     # 0x7fff...  # port0
# or PSLLD xmm5, 31  # 0x8000...  to set up for ANDNPS

ANDPS    xmm0, xmm5  # in the loop.  # port5
  • 字节:12(AVX:13)
  • 融合域微操作:2 + 1*n(无内存操作)
  • 延迟添加到 dep 链:1c
  • 吞吐量:1/c。 (Skylake: 3/c)
  • 所有 3 个微指令的吞吐量:1/c 饱和所有 3 个矢量 ALU 端口
  • “延迟”如果 xmm0 在此序列发出时已准备好(无循环):3c(如果 ANDPS 必须等待整数数据准备好,则 SnB/IvB 上可能会出现 +1c 旁路延迟。Agner Fog 在某些情况下说SnB/IvB 上的 integer->FP-boolean 没有额外的延迟。)

这个版本仍然比内存中具有 16B 常量的版本占用更少的内存。它也非常适合不常调用的函数,因为没有负载会遭受缓存未命中。

“旁路延迟”应该不是问题。如果 xmm0 是长依赖链的一部分,则掩码生成指令将提前执行​​,因此 xmm5 中的整数结果将有时间在 xmm0 准备好之前到达 ANDPS,即使它采用慢速通道。

Haswell 对整数结果没有旁路延迟 -> FP 布尔值,根据 Agner Fog 的测试。他对 SnB/IvB 的描述表明,某些整数指令的输出就是这种情况。因此,即使在该指令序列发出时xmm0 已准备好的“站立开始”链开始的情况下,*well 上只有 3c,*Bridge 上只有 4c。如果执行单元在发出微指令时清除积压的微指令,则延迟可能无关紧要。

无论哪种方式,ANDPS 的输出都会在 FP 域中,并且如果在 MULPS 或其他地方使用,则不会出现旁路延迟。

在 Nehalem 上,旁路延迟为 2c。因此,在 Nehalem 上的 dep 链开始时(例如,在分支错误预测或 I$ 未命中之后),如果 xmm0 已准备好,则当此序列发布为 5c 时,“延迟”。如果您非常关心 Nehalem,并希望此代码是在频繁的分支错误预测或类似的管道停顿后运行的第一件事,这导致 OoOE 机器无法在 xmm0 准备好之前开始计算掩码,那么这可能不是非循环情况的最佳选择。


2a:AVX 最大值(x,0-x)

VXORPS  xmm5, xmm5, xmm5   # outside the loop

VSUBPS  xmm1, xmm5, xmm0   # inside the loop
VMAXPS  xmm0, xmm0, xmm1
  • 字节:AVX:12
  • 融合域微操作:1 + 2*n(无内存操作)
  • 添加到 dep 链的延迟:6c(Skylake:8c)
  • 吞吐量:每 2c 1 个(两个端口 1 微指令)。 (Skylake:1/c,假设MAXPS 使用与SUBPS 相同的两个端口。)

Skylake 丢弃了单独的向量-FP 添加单元,并在端口 0 和 1 上的 FMA 单元中进行向量添加。这使 FP 添加吞吐量翻倍,但延迟增加了 1c。 FMA latency is down to 4 (from 5 in *well)。 x87 FADD 仍然是 3 个周期的延迟,所以仍然有一个 3 周期的标量 80bit-FP 加法器,但只在一个端口上。

2b:相同但没有 AVX:

# inside the loop
XORPS  xmm1, xmm1   # not on the critical path, and doesn't even take an execution unit on SnB and later
SUBPS  xmm1, xmm0
MAXPS  xmm0, xmm1
  • 字节:9
  • 融合域微操作:3*n(无内存操作)
  • 添加到 dep 链的延迟:6c(Skylake:8c)
  • 吞吐量:每 2c 1 个(两个端口 1 微指令)。 (Skylake:1/c)
  • “延迟”如果 xmm0 在此序列发出时已准备好(无循环):相同

使用处理器识别的归零习惯(如xorps same,same)对寄存器进行归零是在 Sandbridge 系列微架构上的寄存器重命名期间处理的,并且具有零延迟和 4/c 的吞吐量。 (与 IvyBridge 及以后可以消除的 reg->reg 动作相同。)

但它不是免费的:它仍然需要在融合域中使用 uop,因此如果您的代码仅受到 4uop/cycle 问题率的瓶颈,这会减慢您的速度。超线程更有可能发生这种情况。


3: ANDPS(x, 0-x)

VXORPS  xmm5, xmm5, xmm5   # outside the loop.  Without AVX: zero xmm1 inside the loop

VSUBPS  xmm1, xmm5, xmm0   # inside the loop
VANDPS  xmm0, xmm0, xmm1
  • 字节:AVX:12 非 AVX:9
  • 融合域微操作:1 + 2*n(无内存操作)。 (没有 AVX:3*n)
  • 添加到 dep 链的延迟:4c(Skylake:5c)
  • 吞吐量:1/c(饱和 p1 和 p5)。 Skylake:3/2c:(3 个向量 uops/周期)/(uop_p01 + uop_p015)。
  • “延迟”如果 xmm0 在此序列发出时已准备好(无循环):相同

这应该可行,但 IDK 要么发生 NaN。很好地观察到 ANDPS 的延迟较低并且不需要 FPU 添加端口。

这是非 AVX 的最小尺寸。


4:左移/右移:

PSLLD  xmm0, 1
PSRLD  xmm0, 1
  • 字节:10(AVX:10)
  • 融合域微指令:2*n
  • 添加到 dep 链的延迟:4c(2c + 旁路延迟)
  • 吞吐量:1/2c(饱和 p0,FP mul 也使用)。 (Skylake 1/c:向量移位吞吐量翻倍)
  • “延迟”如果 xmm0 在此序列发出时已准备好(无循环):相同

    这是 AVX 中最小的(以字节为单位)。

    这可能会导致您无法保留寄存器,并且不会在循环中使用它。 (循环中没有多余的规则,可能使用andps xmm0, [mask])。

我假设从 FP 到整数移位有 1c 的旁路延迟,然后在返回的路上还有 1c,所以这与 SUBPS/ANDPS 一样慢。它确实节省了无执行端口 uop,因此如果融合域 uop 吞吐量是一个问题,并且您不能将掩码生成拉出循环,它具有优势。 (例如,因为这是在循环中调用的函数中,而不是内联)。


何时使用什么:从内存中加载掩码使代码变得简单,但存在缓存未命中的风险。并且占用 16B 的 ro-data 而不是 9 个指令字节。

  • 循环中需要:1c:在循环外生成掩码(使用pcmp/shift);在里面使用一个andps。如果您无法保留寄存器,请将其溢出到堆栈和1aandps xmm0, [rsp + mask_local]。 (生成和存储比常量更不可能导致缓存未命中)。仅向关键路径添加 1 个周期,循环内有 1 个单 uop 指令。这是一个 port5 uop,所以如果你的循环使随机端口饱和并且不受延迟限制,PAND 可能会更好。 (SnB/IvB 在 p1/p5 上有洗牌单元,但 Haswell/Broadwell/Skylake 只能在 p5 上洗牌。Skylake 确实增加了 (V)(P)BLENDV 的吞吐量,但没有增加其他洗牌端口操作。如果 AIDA 数字正确,则不会-AVX BLENDV 是 1c lat ~3/c tput,但 AVX BLENDV 是 2c lat, 1/c tput(仍然比 Haswell 的 tput 有所提高))

  • 在经常调用的非循环函数中需要一次(因此您不能在多次使用时分摊掩码生成):

    1. 如果 uop 吞吐量是一个问题:1aandps xmm0, [mask]。如果这确实是瓶颈,那么偶尔的缓存未命中应该通过节省 uops 来分摊。
    2. 如果延迟不是问题(该函数仅用作短的非循环承载的 dep 链的一部分,例如arr[i] = abs(2.0 + arr[i]);),并且您希望避免内存中的常量:4,因为它只有 2 个微指令。如果 abs 出现在 dep 链的开头或结尾,则不会出现从加载到存储的旁路延迟。
    3. 如果 uop 吞吐量不是问题:1c:使用整数 pcmpeq / shift 即时生成。不可能有缓存未命中,并且只会将 1c 添加到关键路径。
  • 在一个不经常调用的函数中需要(在任何循环之外):只需优化大小(两个小版本都不使用内存中的常量)。非 AVX:3。 AVX:4。它们还不错,并且不能缓存未命中。关键路径的 4 个周期延迟比 1c 版本更糟糕,因此如果您认为 3 个指令字节不是什么大问题,请选择 1c4 版本适用于性能不重要且您希望避免溢出任何内容的注册压力情况。


  • AMD CPU:与ANDPS 之间存在旁路延迟(其本身具有 2c 延迟),但我认为它仍然是最佳选择。它仍然优于 SUBPS 的 5-6 个周期延迟。 MAXPS 是 2c 延迟。由于 Bulldozer 系列 CPU 上 FP 操作的高延迟,乱序执行更有可能及时生成掩码,以便在 @987654388 的其他操作数时准备好掩码@ 是。我猜推土机通过 Steamroller 没有单独的 FP 加法单元,而是在 FMA 单元中进行矢量加法和乘法运算。 3 在 AMD Bulldozer 系列 CPU 上始终是一个糟糕的选择。 2 在这种情况下看起来更好,因为从 fma 域到 fp 域再返回的旁路延迟更短。请参阅 Agner Fog 的微架构指南,第 182 页(15.11 不同执行域之间的数据延迟)。

  • Silvermont:与 SnB 相似的延迟。仍然使用 1c for 循环和概率。也可一次性使用。 Silvermont 出现故障,因此它可以提前准备好掩码,仍然只为关键路径增加 1 个周期。

【讨论】:

  • 彼得,我一直想感谢你的彻底回答。您的理想解决方案只有一个小问题。如果您使用内部函数,Microsoft 不会让您在没有先从未初始化的堆栈空间加载的情况下将未初始化的寄存器与其自身进行比较。似乎您必须先使用 _mm_setzero_ps 将寄存器清零来妥协。其他编译器至少允许您使用内联汇编来解决这个问题。关于这个问题有什么想法吗?
  • @Kumputer:所以如果你使用__m128i var 而不初始化它,MSVC 会发出加载指令吗?这很奇怪。早于 5.0 的 gcc 似乎会添加一个额外的 PXOR,而不是让您对 cmpeq 使用未初始化的变量。英特尔的内在函数指南为此指定了_mm_undefined_si128()。 icc13 做对了,并且不发出任何指令。 gcc 5 是必需的,它不仅是 pxor。铿锵声根本没有。 goo.gl/UhZmeQ 显示了一些示例。在没有_mm_uninitialized_si128() 的平台上,将其#define 为_mm_setzero_si128()
  • 让我知道(或编辑我的答案)在没有任何已初始化的 __m128i 变量值不再为需要。 (对于非 AVX,在将寄存器替换为全 1 之前,需要复制一个额外的 movdqa。)一些旧 CPU 甚至无法将 pcmeq 识别为独立于先前的寄存器内容,因此最好使用一个变量不是新鲜的。 (K10、P4、Intel P6 pre core-2 和 Via Nano 2000 显然不知道这一点。)确保你不使用epi64 版本;在很多 CPU 上速度较慢。
  • @Kumputer:看看 MSVC 在_mm_set1_epi32(-1) 上是否做得不错。 goo.gl/SJclXe 。 gcc 4.5 及更高版本在未初始化的寄存器上将其实现为pcmpeqd。 (gcc 4.4 从内存中加载常量)。在-0O,icc 和clang 仍然使用pcmpeqd,但是gcc -O0 使用了一个非常糟糕的将-1 存储到内存和movd / pinsrd 的序列......通过优化,它是gcc 的最佳选择,直到gcc 5.1停止在未初始化的变量上的 _mm_cmpeq 之前插入破坏依赖的 pxor。无论如何,没有哪款godbolt编译器在优化set1时表现不佳。
  • 如果只使用本地掩码变量,至少当前版本的 clang、icc 和 gcc 都结合了相同的掩码常量。当您为 AVX-512 编译时,它们也足够聪明,可以使用广播负载,因此浮点(双精度)向量的常量只有 32 位(64 位)。 AVX-512 引入了 abs 内在函数,但没有底层指令,它只是糖:clang 和 icc 以恒定负载处理它,而 gcc 则做了一些 vbroadcast 废话。
猜你喜欢
  • 2014-12-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-12-31
  • 1970-01-01
  • 1970-01-01
  • 2011-12-26
相关资源
最近更新 更多