我该如何编写Java代码以允许使用SSE并进行边界检查消除(或其他高级优化)?

41

情况:

我正在优化 LZF 压缩算法的纯 Java 实现,其中涉及大量的 byte[] 访问和基本的 int 数学运算用于哈希和比较。性能非常重要,因为压缩的目标是减少 I/O 要求。我没有发布代码,因为它还没有整理好,可能会被大幅重构。

问题:

  • 如何编写代码以允许 JIT 编译成使用更快速的 SSE 操作的形式?
  • 如何构建代码,以便编译器可以轻松消除数组边界检查?
  • 有关特定数学运算的相对速度(多少次递增/递减等于正常加/减,移位或与数组访问相比有多快)是否有任何广泛的参考资料?
  • 如何在优化分支方面工作——有许多具有短体的条件语句更好,还是有几个具有长体的条件语句,或者具有嵌套条件的短体?
  • 在当前的 1.6 JVM 中,在 System.arraycopy 打败复制循环之前必须复制多少个元素?

我已经做了什么:

在我被攻击为过早优化之前:基本算法已经非常好,但是 Java 实现的速度不到等效 C 的 2/3。我已经用 System.arraycopy 替换了复制循环,努力优化循环并消除不需要的操作。

我大量使用位运算和将字节打包到 int 中以提高性能,以及移位和掩码。

出于法律原因,我无法查看类似库中的实现,并且现有库的许可条款过于限制。

对一个良好(接受)答案的要求:

  • 不可接受的答案:没有解释多少和为什么,或者没有使用 JIT 编译器进行测试的“这更快”。
  • 边缘答案:尚未在 Hotspot 1.4 之前进行任何测试
  • 基本答案:将提供一般规则和解释,说明它在编译器级别上更快,以及大约更快多少
  • 好的答案:包括几个代码示例以演示
  • 优秀答案:具有 JRE 1.5 和 1.6 的基准测试
  • 完美答案:是 HotSpot 编译器的工作人员,可以完全解释或引用使用优化的条件以及它通常更快多少。可能包括 HotSpot 生成的 Java 代码和示例汇编代码。

另外:如果有任何详细介绍 Hotspot 优化和分支性能的链接,那将是受欢迎的。我了解足够的字节码,一个分析字节码而不是源代码级别的性能站点将是有帮助的。

(编辑)部分答案:消除边界检查:

这里是从HotSpot内部wiki提供的链接中获取的信息:https://wikis.oracle.com/display/HotSpotInternals/RangeCheckElimination 在以下条件下,HotSpot将消除所有for循环中的边界检查:
  • 数组是循环不变量(在循环中未重新分配)
  • 索引变量具有恒定步长(增加/减少固定数量,在仅可能的一个位置进行)
  • 数组由变量的线性函数索引。
例如:int val = array[index*2 + 5] 或者:int val = array[index+9] 不是:int val = array[Math.min(var,index)+7]

代码的早期版本:

这是一个示例版本。请勿窃取,因为它是H2数据库项目的未发布版本。最终版本将是开源的。这是对此处代码的优化: H2 CompressLZF code 在逻辑上,它与开发版本相同,但该版本使用for(...)循环遍历输入,并使用if / else循环处理文字和后向引用模式之间的不同逻辑。它减少了模式之间的数组访问和检查。
public int compressNewer(final byte[] in, final int inLen, final byte[] out, int outPos){
        int inPos = 0;
        // initialize the hash table
        if (cachedHashTable == null) {
            cachedHashTable = new int[HASH_SIZE];
        } else {
            System.arraycopy(EMPTY, 0, cachedHashTable, 0, HASH_SIZE);
        }
        int[] hashTab = cachedHashTable;
        // number of literals in current run
        int literals = 0;
        int future = first(in, inPos);
        final int endPos = inLen-4;

        // Loop through data until all of it has been compressed
        while (inPos < endPos) {
                future = (future << 8) | in[inPos+2] & 255;
//                hash = next(hash,in,inPos);
                int off = hash(future);
                // ref = possible index of matching group in data
                int ref = hashTab[off];
                hashTab[off] = inPos;
                off = inPos - ref - 1; //dropped for speed

                // has match if bytes at ref match bytes in future, etc
                // note: using ref++ rather than ref+1, ref+2, etc is about 15% faster
                boolean hasMatch = (ref > 0 && off <= MAX_OFF && (in[ref++] == (byte) (future >> 16) && in[ref++] == (byte)(future >> 8) && in[ref] == (byte)future));

                ref -=2; // ...EVEN when I have to recover it
                // write out literals, if max literals reached, OR has a match
                if ((hasMatch && literals != 0) || (literals == MAX_LITERAL)) {
                    out[outPos++] = (byte) (literals - 1);
                    System.arraycopy(in, inPos - literals, out, outPos, literals);
                    outPos += literals;
                    literals = 0;
                }

                //literal copying split because this improved performance by 5%

                if (hasMatch) { // grow match as much as possible
                    int maxLen = inLen - inPos - 2;
                    maxLen = maxLen > MAX_REF ? MAX_REF : maxLen;
                    int len = 3;
                    // grow match length as possible...
                    while (len < maxLen && in[ref + len] == in[inPos + len]) {
                        len++;
                    }
                    len -= 2;

                    // short matches write length to first byte, longer write to 2nd too
                    if (len < 7) {
                        out[outPos++] = (byte) ((off >> 8) + (len << 5));
                    } else {
                        out[outPos++] = (byte) ((off >> 8) + (7 << 5));
                        out[outPos++] = (byte) (len - 7);
                    }
                    out[outPos++] = (byte) off;
                    inPos += len;

                    //OPTIMIZATION: don't store hashtable entry for last byte of match and next byte
                    // rebuild neighborhood for hashing, but don't store location for this 3-byte group
                    // improves compress performance by ~10% or more, sacrificing ~2% compression...
                    future = ((in[inPos+1] & 255) << 16) | ((in[inPos + 2] & 255) << 8) | (in[inPos + 3] & 255);
                    inPos += 2;
                } else { //grow literals
                    literals++;
                    inPos++;
                } 
        }
        
        // write out remaining literals
        literals += inLen-inPos;
        inPos = inLen-literals;
        if(literals >= MAX_LITERAL){
            out[outPos++] = (byte)(MAX_LITERAL-1);
            System.arraycopy(in, inPos, out, outPos, MAX_LITERAL);
            outPos += MAX_LITERAL;
            inPos += MAX_LITERAL;
            literals -= MAX_LITERAL;
        }
        if (literals != 0) {
            out[outPos++] = (byte) (literals - 1);
            System.arraycopy(in, inPos, out, outPos, literals);
            outPos += literals;
        }
        return outPos; 
    }

最终编辑:

由于时间紧迫,我已经将目前最佳答案标记为接受。由于在发布代码之前我花费了很长时间,因此我将继续在可能的情况下点赞和回复评论。如果代码看起来很混乱,请见谅:这是开发中的代码,不是为提交而精心打磨的。


一些注意事项。在构建时(如果是静态类)可能需要创建cachedHashTable。如果这是每个线程的话,那么API中应该明确说明(即用户负责创建特定的压缩器实例并保存缓存表)。分支预测可能会得到正确结果,但是通过更快地进入后代,并简化代码,您可能会获得一些好处。 - ShuggyCoUk
一个不好的 hack - 但是既然你已经在对它进行不好的操作,你就不需要将 ref 减 2,除非它真的被使用了。而且它只在一个地方被使用(在 if 语句中),因此可以尝试简单地这样做:while (len < maxLen && in[ref + len - 2],并移除 ref -= 2。 - ShuggyCoUk
呃,是啊,有点恶心。我还有几个恶心的黑科技可以用。如果你觉得这里的代码糟糕,那你应该看看原始代码! - BobMcGee
我有一个预初始化哈希表版本,并在可能的后向引用上使用范围检查。但是,对于短输入/输出缓冲区(压缩流时通常为8192 kB),能够快速消除参考为0的可能后向引用是有益的。 - BobMcGee
+1 我已经搜索了几个免费JVM的文档,但没有清晰地提到JVM/JIT如何以及何时使用SIMD(SSE等)。只是从1.4.2开始它们确实被使用。编译器/JVM开发人员绝对不会通过代码结构来"提供提示"或"进行优化"。在6u2中有一些Snorcle JVM补丁引用了bug 6536652中的"SIMD优化";如果您跟随相关的bug链接,似乎 JVM将执行一些基本的循环并行化,但实际实现和使用的详细信息很少。 - charstar
4个回答

19

这不是一个完整的答案,我没有时间为你的问题做详细的基准测试,但希望对你有所帮助。

了解你的敌人

你的目标是JVM(实质上是JIT)和底层CPU/内存子系统的组合。因此,“这在JVM X上更快”在你进入更激进的优化时并不一定适用于所有情况。

如果你的目标市场/应用程序大部分运行在特定架构上,你应该考虑投资于专门针对它的工具。 * 如果x86上的性能是关键因素,则英特尔的VTune非常适合深入挖掘你描述的jit输出分析。 * 64位和32位JIT之间的差异可能相当大,特别是在x86平台上,调用约定可能会改变,enregistering机会也非常不同。

获得正确的工具

您可能需要获取一个采样分析器。对于您特定需求的仪器化开销(以及相关的内联、缓存污染和代码大小膨胀等影响)将会太大。Intel VTune分析器实际上可以用于Java,尽管与其他工具相比,其集成程度不是很紧密。
如果您正在使用sun JVM,并且只想知道最新版本正在做什么,那么如果您了解一些汇编语言,有多种选择来调查JIT的输出。 这篇文章详细介绍了使用此功能进行的一些有趣分析

从其他实现中学习

变更历史记录表明,以前的内联汇编实际上是适得其反的,让编译器完全控制输出(通过代码中的微调而非汇编指令)会产生更好的结果。

一些具体细节

由于在现代桌面CPU上高效的未管理实现中,LZF在很大程度上受到内存带宽的限制(因此它被比作未经优化的memcpy的速度),因此您的代码需要完全保留在一级缓存中。
因此,任何您无法转换为常量的静态字段都应该放置在同一类中,因为这些值通常会放置在与类相关的vtables和元数据所在的内存区域中。

需要避免无法被逃逸分析捕获的对象分配(仅限1.6及以上版本)。

c代码大量使用循环展开。但是在旧版(1.4时代)VM上的性能严重依赖于JVM所处的模式。显然,后来的Sun JVM版本更加激进地进行内联和展开,特别是在服务器模式下。

JIT生成的预取指令可以对像这样接近内存边界的代码产生重大影响。

"它直冲着我们来了"

您的目标正在移动,并将继续移动。马克·勒曼(Marc Lehmann)的先前经验再次得到证实:

默认HLOG大小现在为15(CPU缓存已增加)

即使是对jvm的微小更新也可能涉及重大的编译器更改

6544668 不要对无法在运行时对齐的数组操作进行向量化。 6536652 实现一些超字(SIMD)优化 6531696 不要在英特尔cpu上使用立即16位值存储到内存 6468290 按每个cpu基础分配伊甸园

显而易见

测量,测量,测量。如果您可以让您的库包含一个简单易执行的基准测试(在一个单独的dll中),该测试记录相关信息(vm版本、cpu、操作系统、命令行开关等),并且使其简单地发送回给您,那么您将增加您的覆盖率,最重要的是您将覆盖那些关心它的人。


很好的答案(到目前为止最好的)!您知道有哪些网站提供有关特定汇编命令所需周期的信息?或者有关字节码如何映射到x86汇编的建议?我对汇编不是很了解。VTune现在有点超出我的预算(呵呵),虽然我会记在心里以备将来之需。我正在开发一个具有更可预测的数组访问和较少的数组获取的版本。代码中没有对象分配,但代码中的分支更加复杂。常量加法(例如var2 = var1 + 2)是否可能得到大力优化? - BobMcGee
添加一个变量常量可能不会有太大的区别,除非编译器能够有效地在寄存器中共享该值(如果它经常被使用)。Java有免费的分析工具可供使用。您至少应该查看其中一些,http://code.google.com/p/oktech-profiler/是开源、免费且具有采样模式的。 - ShuggyCoUk
那么,除了优化缓冲区以鼓励缓存使用之外,我还能做什么呢?涉及的唯一缓冲区是输入和输出缓冲区。字节按顺序从输入中处理,并用于增加不可压缩的文字运行长度或引用先前的字节(压缩)。一旦任何一个运行结束,它就会被写入输出缓冲区(对于文字运行使用arraycopy)。
我认为这可以通过打包到int/long来进行矢量化,但这将使分支极其复杂并添加未使用的数组访问。
- BobMcGee
我已经发布了代码的早期版本(不是最新的版本,该版本使用FOR循环可预测地遍历输入缓冲区。那个版本还在调试中。 - BobMcGee
是的,在现代JVM中,arraycopy实际上非常优化。最新的HotSpot版本在这里使用手动调整的汇编,并且复制32个项目的速度大约快了2倍。我认为我不能太多地优化回溯结构,除非添加复制到可缓存的小缓冲区(额外的复制成本),或者其他方式。我将通过重组循环来尝试解决。 - BobMcGee
显示剩余4条评论

6
就“边界检查消除”而言,我相信新的JDK将已经包含了一种改进的算法,可以在可能的情况下消除它。以下是关于这个主题的两篇主要论文:
V. Mikheev, S. Fedoseev, V. Sukharev, N. Lipsky. 2002 在Java中有效增强循环版本控制。链接。这篇论文来自Excelsior的团队,他们在他们的Jet JVM中实现了这种技术。
Würthinger, Thomas, Christian Wimmer和Hanspeter Mössenböck。2007年。 为Java HotSpot客户端编译器消除数组边界检查。PPPJ。链接。基于上述论文,我相信这是将包含在下一个JDK中的实现。同时还介绍了所取得的加速效果
还有this博客文章,它对其中一篇论文进行了表面上的讨论,并展示了一些基准测试结果,不仅涉及数组,还涉及新JDK中的算术运算。该博客文章的评论也非常有趣,因为上述论文的作者提出了一些非常有趣的评论并进行了讨论。此外,还提供了一些指向其他类似博客文章的指针。

这非常有趣,也是 JDK/JRE 1.7 发布的另一个原因,如果它被发布的话。除此之外,异步 I/O API(介于流 I/O 和 NIO 之间)和新的并发 API,应该会让 Java 的性能更接近优化的 C。在这里,因为发布了有用的内容(即使不是答案),给你点赞。 - BobMcGee

3

对于像LZW这样的简单数字计算算法,您很可能不需要帮助JIT编译器进行优化。ShuggyCoUk提到了这一点,但我认为它值得特别关注:

代码的缓存友好性将是一个重要因素。

您必须尽可能减少工作集的大小并改善数据访问局部性。您提到“将字节打包成int以提高性能”。这听起来像是使用int来保存字节值以使它们对齐。不要那样做!增加的数据集大小将抵消任何收益(我曾经将一些ECC数字计算代码从int[]转换为byte[],速度提高了2倍)。

如果您不知道:如果您需要将某些数据视为字节和int处理,则无需进行移位和|-掩码操作-使用ByteBuffer.asIntBuffer()和相关方法即可。

在当前1.6 JVM下,在System.arraycopy之前必须复制多少个元素才能击败复制循环?

最好自己进行基准测试。当我在Java 1.3时期进行测试时,大约需要复制2000个元素。


这是LZF,不是LZW...所以速度至关重要。我已经编辑了原始帖子,包括最新稳定版本的压缩代码和最早版本(未经优化)的链接。字节到整数的打包用于前瞻,用于三字节组位置的哈希表和检查候选后向引用是否可用。 - BobMcGee
另外:我非常确定现在的数字少于32个元素,因为使用arraycopy比循环快大约2倍。不过,这个循环并不是完全优化的。 - BobMcGee
1
我相信 JIT 编译器在一段时间前已经进行了更改,以允许数组复制被特殊处理。除了 http://www.ibm.com/developerworks/java/library/j-devrtj2.html 之外,我无法找到任何参考资料来确定更改的时间。当然,这也是针对每个 JVM 的。 - ShuggyCoUk
1
啊,是的 - 这里有更多关于这个问题的讨论:http://www.mail-archive.com/classpath@gnu.org/msg10172.html - ShuggyCoUk

2
到目前为止,已经有很多答案了,但还有一些额外的事情需要注意:
- 测量、测量、再测量。尽管大多数Java开发人员都警告不要进行微基准测试,但确保在更改之间比较性能。那些没有带来可衡量改进的优化通常不值得保留(当然,有时这是一些因素的结合,这就变得更加棘手)。 - 对于Java和C来说,紧密的循环同样重要(变量分配也是如此——虽然你不能直接控制它,但HotSpot最终必须处理它)。通过重新排列用于处理单字节情况(7位ASCII)的紧密循环,我成功地将UTF-8解码的速度实际上提高了近一倍,而其他情况则被留在外面。 - 不要低估分配和/或清除大型数组的成本——如果您希望LZF编码/解码对于小/中块也更快(而不仅仅是兆字节级别),请记住byte[]/int[]的所有分配都有一定的代价;不是因为GC,而是因为JVM必须清除空间。
H2的实现也经过了相当多的优化(例如:它不再清除哈希数组,这通常是有意义的);我实际上帮助修改了它,以在另一个Java项目中使用。我的贡献主要是将其更改为对于非流式情况更加优化,但这并没有触及紧密的编码/解码循环。

我非常清楚最近的H2优化,并且在某种程度上负有责任:其中一些优化实际上是基于我在发布此代码时发送的草案版本。这包括在检查可能的回溯时重复使用“hash”变量,更干净的循环结构以及不初始化散列表。出于好奇,你的修改是什么?作为一个预告:当东西从代码审查中脱颖而出时,请寻找一些更高压缩选项和更快的解压缩H2代码。 - BobMcGee

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接