我最近决定尝试一下Java新的孵化向量API,看看它能有多快。我实现了两个相当简单的方法,一个用于解析int,另一个用于查找字符串中字符的索引。在这两种情况下,我的向量化方法与标量方法相比都非常慢。
下面是我的代码:
public class SIMDParse {
private static IntVector mul = IntVector.fromArray(
IntVector.SPECIES_512,
new int[] {0, 0, 0, 0, 0, 0, 1000000000, 100000000, 10000000, 1000000, 100000, 10000, 1000, 100, 10, 1},
0
);
private static byte zeroChar = (byte) '0';
private static int width = IntVector.SPECIES_512.length();
private static byte[] filler;
static {
filler = new byte[16];
for (int i = 0; i < 16; i++) {
filler[i] = zeroChar;
}
}
public static int parseInt(String str) {
boolean negative = str.charAt(0) == '-';
byte[] bytes = str.getBytes(StandardCharsets.UTF_8);
if (negative) {
bytes[0] = zeroChar;
}
bytes = ensureSize(bytes, width);
ByteVector vec = ByteVector.fromArray(ByteVector.SPECIES_128, bytes, 0);
vec = vec.sub(zeroChar);
IntVector ints = (IntVector) vec.castShape(IntVector.SPECIES_512, 0);
ints = ints.mul(mul);
return ints.reduceLanes(VectorOperators.ADD) * (negative ? -1 : 1);
}
public static byte[] ensureSize(byte[] arr, int per) {
int mod = arr.length % per;
if (mod == 0) {
return arr;
}
int length = arr.length - (mod);
length += per;
byte[] newArr = new byte[length];
System.arraycopy(arr, 0, newArr, per - mod, arr.length);
System.arraycopy(filler, 0, newArr, 0, per - mod);
return newArr;
}
public static byte[] ensureSize2(byte[] arr, int per) {
int mod = arr.length % per;
if (mod == 0) {
return arr;
}
int length = arr.length - (mod);
length += per;
byte[] newArr = new byte[length];
System.arraycopy(arr, 0, newArr, 0, arr.length);
return newArr;
}
public static int indexOf(String s, char c) {
byte[] b = s.getBytes(StandardCharsets.UTF_8);
int width = ByteVector.SPECIES_MAX.length();
byte bChar = (byte) c;
b = ensureSize2(b, width);
for (int i = 0; i < b.length; i += width) {
ByteVector vec = ByteVector.fromArray(ByteVector.SPECIES_MAX, b, i);
int pos = vec.compare(VectorOperators.EQ, bChar).firstTrue();
if (pos != width) {
return pos + i;
}
}
return -1;
}
}
我完全期望我的int解析会更慢,因为它永远不会处理超过向量大小所能容纳的内容(int的长度永远不会超过10位)。
根据我的bechmarks,将123
解析为int类型10 k次,Integer.parseInt
花费了3081微秒,而我的实现花费了80601微秒;在一个很长的字符串("____".repeat(4000) + "a" + "----".repeat(193)
)中搜索'a'
花费了7709微秒,而String#indexOf
花费了7微秒。
为什么它会如此令人难以置信地慢呢?我认为SIMD的全部意义在于它比类似于这些任务的标量等效物更快。
2条答案
按热度按时间wz1wpwve1#
你选择了SIMD不擅长的东西(string-〉int),以及JVM非常擅长在循环外优化的东西。如果输入不是向量宽度的精确倍数,你就做了一堆额外的复制工作。
我假设你的时间是总数(每个重复10 k),而不是每个电话的平均值。
7我们是不可能的快。
"____".repeat(4000)
在'a'
之前有16 k个字符(32 k字节),我假设这就是你要搜索的。即使是一个良好调优/展开的wmemchr
(又名indexOf),在4GHz的CPU上以每个时钟周期运行2x 32字节向量,也需要1250 us来执行10 k次重复(32000B / (64B/c) * 10000 reps / 4000 MHz
),假设32 kB的字符串在32 KiB的L1 d缓存中保持热。我希望JVM要么调用原生
wmemchr
,要么对String#indexOf
这样的常用核心库函数使用同样高效的函数。例如,glibc's avx2 memchr在循环展开方面做了很好的调整。(Java字符串实际上是UTF-16,但Linuxwchar_t
上的C是4字节宽,这与Windows不同,因此JVM需要自己的实现。)**内置String
indexOf
也是JIT“知道”的东西。**当它看到您重复使用相同的字符串作为输入时,它显然能够将其提升出循环。(但是它对剩下的7个我们做了什么呢?我猜做了一个不太好的memchr
,然后在1/时钟可能需要大约7微秒,特别是如果你的CPU没有4GHz那么快。参见Idiomatic way of performance evaluation?-如果将重复计数加倍到20 k并不能使时间加倍,则您的基准测试被破坏,并且没有测量您认为它所做的事情。
**但是你说每次迭代的时间是7 us?**这是不可能的慢,除非是非优化的第一遍。所以可能是错误的基准测试方法的标志,比如缺乏热身运行。
如果
IndexOf
一次检查一个字符,那么16k * 0.25 ns/char
将需要4000纳秒,或者在4GHz CPU上需要4微秒。7 us
在每个周期检查一个字符的范围内,这在现代x86上是非常慢的。我认为主流JVM在完成JIT优化后不太可能使用这样慢的实现。您的手动SIMD indexOf不太可能在循环中得到优化。如果大小不是向量宽度的精确倍数,它每次都会复制整个数组!!(In
ensureSize2
)。通常的技术是对最后的size % width
元素回退到标量,这对于大型数组显然要好得多。在数组的末尾(如果总大小〉= vectorwidth)执行一个非对齐加载,以避免与以前的工作重叠。一个体面的memchr在现代x86(使用类似于您的indexOf的算法而不展开)应该在大约1个向量处进行(16/32/64字节),数据在L1 d缓存中处于热状态,没有循环展开或任何情况。(检查向量比较和指针边界作为可能的循环退出条件需要额外的asm指令,而不是简单的
strlen
。但请参阅一些假设对齐缓冲区的简单手写strlen的微基准测试的答案)。可能您的indexOf
循环在像Skylake这样的CPU上的前端吞吐量瓶颈,其流水线宽度为4 uops/clock。所以让我们猜猜你的实现需要1.5个周期每16字节向量,如果你在一个没有AVX 2的CPU上?
16 kB/16 B = 1000个矢量。每1.5个时钟周期1个矢量,即1500个周期。在3GHz机器上,1500个周期需要500 ns =每次调用0.5 us,或每10 k rep 5000 us。但由于16194字节不是16的倍数,因此每次调用都要复制整个内容,因此会花费更多时间。也能合理解释你7709美元的总时间。
SIMD有什么用
来完成这样的任务
**不,像
ints.reduceLanes
这样的“水平”东西通常是SIMD很慢的。**即使像How to implement atoi using SIMD?这样的东西使用x86pmaddwd
来水平地乘和加对,它仍然需要大量的工作。请注意,要使元素足够宽,可以与位值相乘而不会溢出,必须进行解包,这需要进行一些重排。(元素)shuffle/add步骤,如果您从
int
的512位AVX-512向量开始,则这些shuffle中的前2个是跨车道的,3周期潜伏期(https://agner.org/optimize/)。(或者如果你的机器甚至没有AVX 2,那么一个512位的整数向量实际上是4 × 128位的向量。你必须单独做工作来解压缩每个部分。但至少减少的成本很低,只是垂直添加,直到你得到一个128位的向量。)oaxa6hgo2#
我发现这篇文章是因为我觉得我在Vector性能上遇到了一些奇怪的东西,表面上它应该是两个双精度数组相乘的理想选择。更新后(在纠正错误后),主要例程是这样的(对于Vector的情况):
而这(对于标量的情况):
在测试中,我使用了一个长度为65536(随机数)的数组和1024次迭代。在我的机器上(CPU是2.8 GHz的Intel i7- 7700 HQ),物种长度为4。
时间需要一段时间才能稳定下来。首先,前2个标量和6个向量迭代比后面的迭代慢得多(大约10倍),其次,前180次左右的迭代在时间上非常不稳定。此后偶尔会出现尖峰(可能与机器/jvm上的其他事情有关)
如果我比较一下(使用中位数而不是平均值来避免离群值,并忽略前200次迭代以消除启动影响),在Java 19中,Vector方法比标量方法快大约8%,在Java 20中大约快9%。这意味着,我仍然没有看到“快四倍”。我想知道优化器是否正在分发标量计算……(或者这次我做错了什么:-))