为什么Java HotSpot 在一次性调整大小后无法优化数组实例(导致大量性能损失)?
Why does Java HotSpot can not optimize array instance after one-time resizing (leads to massive performance loss)?
问题
为什么在附加的代码示例 (SELECT_QUICK = true
) 中使用 fBuffer1
是 fBuffer2
时的另一个变体的两倍仅在开头调整一次大小 (SELECT_QUICK = false
)?
代码路径完全相同,但即使在 10 分钟后,fBuffer2
的吞吐量也不会增加到 fBuffer1
的水平。
背景:
我们有一个通用的数据处理框架,它收集了不同子类中的数千个 Java 原始值(每个原始类型一个子类)。这些值在内部存储在数组中,我们最初将其大小设置得足够大。为了节省堆内存,我们现在将这些数组切换为动态调整大小(数组仅在需要时增长)。正如预期的那样,此更改已大大减少了堆内存。然而,另一方面,不幸的是,性能已经显着下降。我们的处理工作现在需要比以前长 2-3 倍的时间(例如 6 分钟而不是以前的 2 分钟)。
我已经将我们的问题简化为一个最小的工作示例并附上了它。您可以使用 SELECT_QUICK
选择应使用哪个缓冲区。我看到 jdk-1.8.0_202-x64
和 openjdk-17.0.1-x64
.
的效果相同
缓冲区 1(未调整大小)显示以下数字:
duration buf1: 8,890.551ms (8.9s)
duration buf1: 8,339.755ms (8.3s)
duration buf1: 8,620.633ms (8.6s)
duration buf1: 8,682.809ms (8.7s)
...
缓冲区 2(刚好在开始时调整大小 1 次)显示以下数字:
make buffer 2 larger
duration buf2 (resized): 19,542.750ms (19.5s)
duration buf2 (resized): 22,423.529ms (22.4s)
duration buf2 (resized): 22,413.364ms (22.4s)
duration buf2 (resized): 22,219.383ms (22.2s)
...
如果能得到一些提示,我将不胜感激,如何更改代码以使 fBuffer2
(调整大小后)与 fBuffer1
一样快。反过来(让 fBuffer1
和 fBuffer2
一样慢)也很容易。 ;-) 由于这个问题出现在一些类似框架的组件中,我宁愿更改代码而不是调整热点(使用外部参数)。但当然,非常欢迎双向的建议。
源代码
import java.util.Locale;
public final class Collector {
private static final boolean SELECT_QUICK = true;
private static final long LOOP_COUNT = 50_000L;
private static final int VALUE_COUNT = 150_000;
private static final int BUFFER_LENGTH = 100_000;
private final Buffer fBuffer = new Buffer();
public void reset() {fBuffer.reset();}
public void addValueBuf1(long val) {fBuffer.add1(val);}
public void addValueBuf2(long val) {fBuffer.add2(val);}
public static final class Buffer {
private int fIdx = 0;
private long[] fBuffer1 = new long[BUFFER_LENGTH * 2];
private long[] fBuffer2 = new long[BUFFER_LENGTH];
public void reset() {fIdx = 0;}
public void add1(long value) {
ensureRemainingBuf1(1);
fBuffer1[fIdx++] = value;
}
public void add2(long value) {
ensureRemainingBuf2(1);
fBuffer2[fIdx++] = value;
}
private void ensureRemainingBuf1(int remaining) {
if (remaining > fBuffer1.length - fIdx) {
System.out.println("make buffer 1 larger");
fBuffer1 = new long[(fIdx + remaining) << 1];
}
}
private void ensureRemainingBuf2(int remaining) {
if (remaining > fBuffer2.length - fIdx) {
System.out.println("make buffer 2 larger");
fBuffer2 = new long[(fIdx + remaining) << 1];
}
}
}
public static void main(String[] args) {
Locale.setDefault(Locale.ENGLISH);
final Collector collector = new Collector();
if (SELECT_QUICK) {
while (true) {
final long start = System.nanoTime();
for (long j = 0L; j < LOOP_COUNT; j++) {
collector.reset();
for (int k = 0; k < VALUE_COUNT; k++) {
collector.addValueBuf1(k);
}
}
final long nanos = System.nanoTime() - start;
System.out.printf("duration buf1: %1$,.3fms (%2$,.1fs)%n",
nanos / 1_000_000d, nanos / 1_000_000_000d);
}
} else {
while (true) {
final long start = System.nanoTime();
for (long j = 0L; j < LOOP_COUNT; j++) {
collector.reset();
for (int k = 0; k < VALUE_COUNT; k++) {
collector.addValueBuf2(k);
}
}
final long nanos = System.nanoTime() - start;
System.out.printf("duration buf2 (resized): %1$,.3fms (%2$,.1fs)%n",
nanos / 1_000_000d, nanos / 1_000_000_000d);
}
}
}
}
HotSpot JVM 中的 JIT 编译是 1) 基于运行时配置文件数据; 2) 使用推测优化。
在最大优化级别编译该方法后,HotSpot 将停止分析此代码,因此无论代码运行多长时间都不会再重新编译。 (例外是当方法需要去优化或卸载时,但你的情况不是这样)。
在第一种情况下 (SELECT_QUICK == true),条件 remaining > fBuffer1.length - fIdx
永远不会满足,HotSpot JVM 通过在 收集的分析数据知道这一点。因此它推测性地将检查提升到循环之外,并在数组索引始终在边界内的假设下编译循环体。优化后,循环编译如下(伪代码):
if (VALUE_COUNT > collector.fBuffer.fBuffer1.length) {
uncommon_trap();
}
for (int k = 0; k < VALUE_COUNT; k++) {
collector.fBuffer.fBuffer1[k] = k; // no bounds check
}
第二种情况(SELECT_QUICK == false),相反,HotSpot知道条件remaining > fBuffer2.length - fIdx
是有时满足,所以它不能取消检查。
因为 fIdx
不是循环计数器,HotSpot 显然不够聪明,无法将循环分成两部分(有和没有边界检查)。但是,您可以通过手动拆分循环来帮助 JIT 编译器:
for (long j = 0L; j < LOOP_COUNT; j++) {
collector.reset();
int fastCount = Math.min(collector.fBuffer.fBuffer2.length, VALUE_COUNT);
for (int k = 0; k < fastCount; k++) {
collector.addValueBuf2Fast(k);
}
for (int k = fastCount; k < VALUE_COUNT; k++) {
collector.addValueBuf2(k);
}
}
其中 addValueBuf2Fast
插入一个没有边界检查的值:
public void addValueBuf2Fast(long val) {fBuffer.add2Fast(val);}
public static final class Buffer {
...
public void add2Fast(long value) {
fBuffer2[fIdx++] = value;
}
}
这应该会显着提高循环的性能:
make buffer 2 larger
duration buf2 (resized): 5,537.681ms (5.5s)
duration buf2 (resized): 5,461.519ms (5.5s)
duration buf2 (resized): 5,450.445ms (5.5s)
问题
为什么在附加的代码示例 (SELECT_QUICK = true
) 中使用 fBuffer1
是 fBuffer2
时的另一个变体的两倍仅在开头调整一次大小 (SELECT_QUICK = false
)?
代码路径完全相同,但即使在 10 分钟后,fBuffer2
的吞吐量也不会增加到 fBuffer1
的水平。
背景:
我们有一个通用的数据处理框架,它收集了不同子类中的数千个 Java 原始值(每个原始类型一个子类)。这些值在内部存储在数组中,我们最初将其大小设置得足够大。为了节省堆内存,我们现在将这些数组切换为动态调整大小(数组仅在需要时增长)。正如预期的那样,此更改已大大减少了堆内存。然而,另一方面,不幸的是,性能已经显着下降。我们的处理工作现在需要比以前长 2-3 倍的时间(例如 6 分钟而不是以前的 2 分钟)。
我已经将我们的问题简化为一个最小的工作示例并附上了它。您可以使用 SELECT_QUICK
选择应使用哪个缓冲区。我看到 jdk-1.8.0_202-x64
和 openjdk-17.0.1-x64
.
缓冲区 1(未调整大小)显示以下数字:
duration buf1: 8,890.551ms (8.9s)
duration buf1: 8,339.755ms (8.3s)
duration buf1: 8,620.633ms (8.6s)
duration buf1: 8,682.809ms (8.7s)
...
缓冲区 2(刚好在开始时调整大小 1 次)显示以下数字:
make buffer 2 larger
duration buf2 (resized): 19,542.750ms (19.5s)
duration buf2 (resized): 22,423.529ms (22.4s)
duration buf2 (resized): 22,413.364ms (22.4s)
duration buf2 (resized): 22,219.383ms (22.2s)
...
如果能得到一些提示,我将不胜感激,如何更改代码以使 fBuffer2
(调整大小后)与 fBuffer1
一样快。反过来(让 fBuffer1
和 fBuffer2
一样慢)也很容易。 ;-) 由于这个问题出现在一些类似框架的组件中,我宁愿更改代码而不是调整热点(使用外部参数)。但当然,非常欢迎双向的建议。
源代码
import java.util.Locale;
public final class Collector {
private static final boolean SELECT_QUICK = true;
private static final long LOOP_COUNT = 50_000L;
private static final int VALUE_COUNT = 150_000;
private static final int BUFFER_LENGTH = 100_000;
private final Buffer fBuffer = new Buffer();
public void reset() {fBuffer.reset();}
public void addValueBuf1(long val) {fBuffer.add1(val);}
public void addValueBuf2(long val) {fBuffer.add2(val);}
public static final class Buffer {
private int fIdx = 0;
private long[] fBuffer1 = new long[BUFFER_LENGTH * 2];
private long[] fBuffer2 = new long[BUFFER_LENGTH];
public void reset() {fIdx = 0;}
public void add1(long value) {
ensureRemainingBuf1(1);
fBuffer1[fIdx++] = value;
}
public void add2(long value) {
ensureRemainingBuf2(1);
fBuffer2[fIdx++] = value;
}
private void ensureRemainingBuf1(int remaining) {
if (remaining > fBuffer1.length - fIdx) {
System.out.println("make buffer 1 larger");
fBuffer1 = new long[(fIdx + remaining) << 1];
}
}
private void ensureRemainingBuf2(int remaining) {
if (remaining > fBuffer2.length - fIdx) {
System.out.println("make buffer 2 larger");
fBuffer2 = new long[(fIdx + remaining) << 1];
}
}
}
public static void main(String[] args) {
Locale.setDefault(Locale.ENGLISH);
final Collector collector = new Collector();
if (SELECT_QUICK) {
while (true) {
final long start = System.nanoTime();
for (long j = 0L; j < LOOP_COUNT; j++) {
collector.reset();
for (int k = 0; k < VALUE_COUNT; k++) {
collector.addValueBuf1(k);
}
}
final long nanos = System.nanoTime() - start;
System.out.printf("duration buf1: %1$,.3fms (%2$,.1fs)%n",
nanos / 1_000_000d, nanos / 1_000_000_000d);
}
} else {
while (true) {
final long start = System.nanoTime();
for (long j = 0L; j < LOOP_COUNT; j++) {
collector.reset();
for (int k = 0; k < VALUE_COUNT; k++) {
collector.addValueBuf2(k);
}
}
final long nanos = System.nanoTime() - start;
System.out.printf("duration buf2 (resized): %1$,.3fms (%2$,.1fs)%n",
nanos / 1_000_000d, nanos / 1_000_000_000d);
}
}
}
}
HotSpot JVM 中的 JIT 编译是 1) 基于运行时配置文件数据; 2) 使用推测优化。
在最大优化级别编译该方法后,HotSpot 将停止分析此代码,因此无论代码运行多长时间都不会再重新编译。 (例外是当方法需要去优化或卸载时,但你的情况不是这样)。
在第一种情况下 (SELECT_QUICK == true),条件 remaining > fBuffer1.length - fIdx
永远不会满足,HotSpot JVM 通过在
if (VALUE_COUNT > collector.fBuffer.fBuffer1.length) {
uncommon_trap();
}
for (int k = 0; k < VALUE_COUNT; k++) {
collector.fBuffer.fBuffer1[k] = k; // no bounds check
}
第二种情况(SELECT_QUICK == false),相反,HotSpot知道条件remaining > fBuffer2.length - fIdx
是有时满足,所以它不能取消检查。
因为 fIdx
不是循环计数器,HotSpot 显然不够聪明,无法将循环分成两部分(有和没有边界检查)。但是,您可以通过手动拆分循环来帮助 JIT 编译器:
for (long j = 0L; j < LOOP_COUNT; j++) {
collector.reset();
int fastCount = Math.min(collector.fBuffer.fBuffer2.length, VALUE_COUNT);
for (int k = 0; k < fastCount; k++) {
collector.addValueBuf2Fast(k);
}
for (int k = fastCount; k < VALUE_COUNT; k++) {
collector.addValueBuf2(k);
}
}
其中 addValueBuf2Fast
插入一个没有边界检查的值:
public void addValueBuf2Fast(long val) {fBuffer.add2Fast(val);}
public static final class Buffer {
...
public void add2Fast(long value) {
fBuffer2[fIdx++] = value;
}
}
这应该会显着提高循环的性能:
make buffer 2 larger
duration buf2 (resized): 5,537.681ms (5.5s)
duration buf2 (resized): 5,461.519ms (5.5s)
duration buf2 (resized): 5,450.445ms (5.5s)