【问题标题】:Why is the java vector API so slow compared to scalar?与标量相比,为什么 java 向量 API 这么慢?
【发布时间】:2021-09-04 17:33:23
【问题描述】:

我最近决定尝试使用 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 位)。

根据我的基准,将 123 解析为 int 10k 次对于 Integer.parseInt 需要 3081 微秒,而对于我的实现则需要 80601 微秒。在很长的字符串 ("____".repeat(4000) + "a" + "----".repeat(193)) 中搜索 'a' 需要 7709 微秒到 String#indexOf 的 7。

为什么速度如此之慢?我认为 SIMD 的全部意义在于它比此类任务的标量等价物更快。

【问题讨论】:

  • 您在什么硬件(和 JVM 版本)上进行了测试?此外,您应该在我的回答中使用您的 cmets 中的信息更新此内容,显然长字符串测试只是重复一次。

标签: java vectorization simd


【解决方案1】:

嗯。我发现这篇文章是因为我在 Vector 性能方面遇到了一些奇怪的事情,因为它表面上应该是理想的 - 将两个双精度数组相乘。

  static private void doVector(int iteration, double[] input1, double[] input2, double[] output) {
    Instant start = Instant.now();
    for (int i = 0; i < SPECIES.loopBound(ARRAY_LENGTH); i += SPECIES.length()) {
      DoubleVector va = DoubleVector.fromArray(SPECIES, input1, i);
      DoubleVector vb = DoubleVector.fromArray(SPECIES, input2, i);
      va.mul(vb);
      System.arraycopy(va.mul(vb).toArray(), 0, output, i, SPECIES.length());
    }
    Instant finish = Instant.now();
    System.out.println("vector duration " + iteration + ": " + Duration.between(start, finish).getNano());
  }

我的机器上的物种长度为 4(CPU 是 Intel i7-7700HQ at 2.8 GHz)。

在我的第一次尝试中,即使数组长度很小(8 个元素)。凭直觉,我添加了迭代以查看是否需要预热——事实上,第一次迭代仍然需要很长时间(65536 个元素需要 44 毫秒)。虽然大多数其他迭代报告的时间为零,但有一些大约需要 15 毫秒,但它们是随机分布的(即每次运行的迭代索引并不总是相同)。我有点期待(因为我正在测量实时测量,并且其他事情将会发生)。

但是,总体而言,对于 65536 个元素和 32 次迭代的数组大小,矢量方法的总持续时间是标量方法的 2-3 倍。

【讨论】:

    【解决方案2】:

    您选择了 SIMD 不擅长的东西 (string->int),以及 JVM 非常擅长优化循环外的东西。如果输入不是向量宽度的精确倍数,那么您就需要进行大量额外的复制工作。


    我假设您的时间是总数(每次重复 10k 次),而不是每次通话的平均值。

    7 us 是不可能的快。

    "____".repeat(4000)'a' 早 16k 字节,我认为这就是您要搜索的内容。即使是经过良好调整/展开的memchr(又名 indexOf)在 4GHz CPU 上以每个时钟周期 2 个 32 字节向量运行,10k 次重复也需要 625 us。 (16000B / (64B/c) * 10000 reps / 4000 MHz)。是的,我希望 JVM 要么调用本机 memchr,要么对常用的核心库函数(如 String#indexOf)使用同样有效的东西。例如,glibc's avx2 memchr 非常适合循环展开;如果你在 Linux 上,你的 JVM 可能会调用它。

    内置字符串 indexOf 也是 JIT “知道”的内容。 当它可以看到您重复使用相同的字符串时,它显然能够将其提升到循环之外输入。 (但是那剩下的 7 个我们在做什么呢?我猜做一个不太好的memchr 然后以 1/clock 执行一个空的 10k 迭代循环可能需要大约 7 微秒,特别是如果你的 CPU速度不如 4GHz。)

    请参阅Idiomatic way of performance evaluation? - 如果将重复计数加倍至 20k 并没有使时间加倍,则您的基准已被打破,并且没有衡量您认为的效果。

    您的手动 SIMD indexOf 不太可能在循环之外得到优化。 如果大小不是向量宽度的精确倍数,它每次都会复制整个数组!(在ensureSize2)。通常的技术是回退到最后一个size % width 元素的标量,这对于大型数组显然要好得多。或者更好的是,在与之前的工作重叠不成问题的情况下,执行在数组末尾结束的未对齐加载(如果总大小 >= 向量宽度)。

    现代 x86 上的一个不错的 memchr(使用类似 indexOf 的算法而不展开)应该每 1.5 个时钟周期大约 1 个向量(16/32/64 字节),数据在 L1d 缓存中是热的,没有循环展开或任何事物。 (检查向量比较和指针绑定作为可能的循环退出条件需要额外的 asm 指令而不是简单的strlen,但请参阅this answer 以获得假设对齐缓冲区的简单手写 strlen 的一些微基准)。可能您的indexOf 在像 Skylake 这样的 CPU 上循环了前端吞吐量瓶颈,其管道宽度为 4 微秒/时钟。

    因此,假设您使用的是没有 AVX2 的 CPU,那么我们猜测您的实现每个 16 字节向量需要 1.5 个周期?你没说。

    16kB / 16B = 1000 个向量。每 1.5 个时钟有 1 个向量,即 1500 个周期。在 3GHz 机器上,1500 个周期需要 500 ns = 0.5 us 每次调用,或 5000 us 每 10k reps。但由于 16194 字节不是 16 的倍数,因此您每次调用都要复制整个内容,因此会花费更多时间,并且可能会合理地占您 7709 我们的总时间。


    SIMD 有什么用处

    用于此类任务。

    不,像ints.reduceLanes 这样的“水平”东西通常是 SIMD 很慢的东西。 即使像 How to implement atoi using SIMD? 这样使用 x86 pmaddwd 水平相乘和相加,它仍然是很多工作。

    请注意,要使元素足够宽以乘以位置值而不会溢出,您必须解包,这需要一些洗牌。 ints.reduceLanes 大约需要 log2(elements) 洗牌/添加步骤,如果你从 int 的 512 位 AVX-512 向量开始,这些洗牌的前 2 个是车道交叉,3 个周期延迟 (@987654325 @)。 (或者如果你的机器甚至没有 AVX2,那么一个 512 位整数向量实际上是 4 个 128 位向量。而且你必须做单独的工作来解包每个部分。但至少减少会很便宜,只是垂直的直到你得到一个 128 位向量。)

    【讨论】:

    • 我应该澄清一下——int 解析只有 10k 次调用。对于 indexOf,每个都为单个调用计时。
    • 刚刚用数组上的标量乘法和向量乘法的方法编写了一个基本测试 - 似乎小型和大型数组的运行时间几乎相同。在数组大小为 2560000 上都需要大约 9 毫秒。hastebin.com/pexakenihe.java
    • @Redempt: 哦,那么 7 我们为 one 打电话是可悲的。也许 HotSpot 没有专门处理 indexOf ?但是 IDK 为什么/如何调用您的手动内在函数版本可能需要这么长时间,除非您的基准测试方法完全有缺陷并且您错过了热身运行。我建议使用重复循环。
    • @Redempt: out[i] = arr[i] * scalar; - 这如此很容易自动矢量化,我认为现代 HotSpot 实际上会在 JITting 时为你做到这一点。 (Do any JVM's JIT compilers generate code that uses vectorized floating point instructions? 说它从 Java 7u40 开始就存在了)至少这是一个很好的确认,即 SIMD API 在用于数组上的普通垂直 SIMD 事情时并没有使事情变得更糟。 (或者至少这两种方式都是内存带宽的瓶颈。)
    猜你喜欢
    • 2011-02-11
    • 2012-05-05
    • 1970-01-01
    • 1970-01-01
    • 2012-05-25
    • 1970-01-01
    • 2023-04-10
    • 2020-11-25
    • 1970-01-01
    相关资源
    最近更新 更多