41

情况:

我正在优化 LZF 压缩算法的纯 java 实现,它涉及大量 byte[] 访问和基本的 int 数学,用于散列和比较。性能确实很重要,因为压缩的目标是减少 I/O 需求。我没有发布代码,因为它还没有被清理,并且可能会被大量重组。

问题:

  • 如何编写代码以使用更快的 SSE 操作将其 JIT 编译为表单?
  • 如何构造它以便编译器可以轻松消除数组边界检查?
  • 是否有关于特定数学运算的相对速度的广泛参考(等于正常加/减需要多少增量/减量,移位或与数组访问相比有多快)?
  • 我该如何优化分支——拥有大量带有短主体的条件语句,或者一些长的,还是带有嵌套条件的短的条件语句更好?
  • 使用当前的 1.6 JVM,在 System.arraycopy 击败复制循环之前必须复制多少元素?

我已经做了什么:

在我因过早优化而受到攻击之前:基本算法已经很出色了,但 Java 实现的速度不到等效 C 的 2/3。我已经用 System.arraycopy 替换了复制循环,致力于优化循环并消除了 un - 需要的操作。

我大量使用位旋转并将字节打包到整数中以提高性能,以及移位和屏蔽。

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

良好(已接受)答案的要求:

  • 不可接受的答案: “这更快”没有解释多少和为什么,或者没有用 JIT 编译器测试。
  • 边界答案:在 Hotspot 1.4 之前没有经过任何测试
  • 基本答案:将提供一般规则和解释,说明为什么它在编译器级别更快,以及大概快多少
  • 好的答案:包括几个代码示例来演示
  • 很好的答案:有 JRE 1.5 和 1.6 的基准
  • 完美答案:由从事 HotSpot 编译器工作的人提供,并且可以完全解释或参考要使用的优化的条件,以及它通常的速度有多快。可能包括由 HotSpot 生成的 java 代码和示例汇编代码。

另外:如果有人有详细说明热点优化和分支性能的链接,欢迎提供。我对字节码有足够的了解,因此在字节码而不是源代码级别分析性能的网站会有所帮助。

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

这取自提供的 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 代码

从逻辑上讲,这与开发版本相同,但它使用 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; 
    }

最终编辑:

到目前为止,我已将最佳答案标记为已接受,因为截止日期快到了。由于我在决定发布代码之前花了很长时间,因此我将继续投票并在可能的情况下回复评论。 如果代码混乱,请道歉:这代表开发中的代码,没有为提交而完善。

4

4 回答 4

19

不是一个完整的答案,我根本没有时间做你的问题需要的详细基准,但希望有用。

了解你的敌人

您的目标是 JVM(本质上是 JIT)和底层 CPU/内存子系统的组合。因此,当您进行更积极的优化时,“这在 JVM X 上更快”不太可能在所有情况下都有效。

如果您的目标市场/应用程序将主要在特定架构上运行,您应该考虑投资于特定于它的工具。* 如果您在 x86 上的性能是关键因素,那么英特尔的VTune非常适合深入研究您描述的那种jit 输出分析。* 64 位和 32 位 JIT 之间的差异可能相当大,尤其是在 x86 平台上,调用约定可能会改变并且注册机会非常不同。

获取正确的工具

您可能希望获得一个采样分析器。满足您的特定需求的检测开销(以及相关联的内联、缓存污染和代码大小膨胀等问题)将太大。英特尔 VTune 分析器实际上可以用于 Java,尽管集成不如其他分析器那么紧密。
如果您使用的是 sun JVM,并且很高兴只知道最新/最好的版本在做什么,那么如果您了解一点汇编,那么可用于调查 JIT 输出的选项是相当可观的。本文详细介绍了使用此功能的一些有趣的分析

向其他实现学习

Change history change history 表明以前的内联汇编实际上适得其反,并且允许编译器完全控制输出(通过调整代码而不是汇编中的指令)产生了更好的结果。

一些细节

由于 LZF 在现代台式机 CPUS 上的高效非托管实现中,很大程度上是内存带宽受限(因此它与未优化的 memcpy 的速度相比),您将需要您的代码完全保持在 1 级缓存内。
因此,您不能将任何静态字段设置为常量应放置在同一个类中,因为这些值通常会放置在专用于与类关联的 vtable 和元数据的同一内存区域中。

需要避免无法被 Escape Analysis 捕获的对象分配(仅在 1.6 及更高版本中)。

c 代码积极使用循环展开。然而,这在较旧的(1.4 时代)VM 上的性能在很大程度上取决于 JVM 所处的模式。显然,后来的 sun jvm 版本在内联和展开方面更具侵略性,尤其是在服务器模式下。

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

“它直奔我们而来”

您的目标正在移动,并将继续移动。再说一次 Marc Lehmann 之前的经历:

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

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

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

船长明显

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

于 2009-09-11T22:00:14.700 回答
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中的实现。还介绍了实现的加速。

还有这个博客条目,它从表面上讨论了其中一篇论文,并提供了一些基准测试结果,不仅针对数组,还针对新 JDK 中的算术。博客条目的评论也很有趣,因为上述论文的作者提出了一些非常有趣的评论并讨论了论点。此外,还有一些关于此主题的其他类似博客文章的提示。

希望能帮助到你。

于 2009-09-14T15:35:50.027 回答
3

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

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

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

如果您不知道这一点:如果您需要将某些数据同时视为字节和整数,则不必转换和 |-mask 它 - 使用ByteBuffer.asIntBuffer()和相关方法。

使用当前的 1.6 JVM,在 System.arraycopy 击败复制循环之前必须复制多少元素?

最好自己做基准测试。当我在 Java 1.3 时代这样做时,它大约有 2000 个元素。

于 2009-09-14T20:10:52.510 回答
2

到目前为止有很多答案,但还有一些额外的事情:

  • 测量,测量,测量。正如大多数 Java 开发人员警告不要进行微基准测试一样,请确保您比较更改之间的性能。不会带来可衡量的改进的优化通常不值得保留(当然,有时它是事物的组合,这会变得更棘手)
  • 紧循环对于 Java 和 C 一样重要(变量分配也是如此——尽管您不能直接控制它,但 HotSpot 最终将不得不这样做)。通过重新安排将单字节大小写(7 位 ascii)处理为紧密(er)内部循环的紧密循环,我设法将 UTF-8 解码的速度几乎提高了一倍,而将其他情况排除在外。
  • 不要低估分配和/或清除大型数组的成本——如果您希望 lzf 编码/解码对于中小块也更快(不仅仅是兆字节大小),请记住 byte[]/int[ 的所有分配] 有点贵;不是因为 GC,而是因为 JVM 必须清除空间。

H2 实现也进行了相当多的优化(例如:它不再清除哈希数组,这通常是有道理的);我实际上帮助修改了它以用于另一个 Java 项目。我的贡献主要只是改变它对于非流媒体情况更优化,但这并没有触及紧密的编码/解码循环。

于 2009-12-08T05:38:50.203 回答