This is an automated email from the ASF dual-hosted git repository.
merlimat pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/bookkeeper.git
The following commit(s) were added to refs/heads/master by this push:
new 0a2d5bee28 Avoid per-checksum boxing in Java9IntHash by using
MethodHandle.invokeExact (#4876)
0a2d5bee28 is described below
commit 0a2d5bee2848c9b4783151544f37e29e555cc3ae
Author: Lari Hotari <[email protected]>
AuthorDate: Sun Sep 6 01:12:33 2026 +0300
Avoid per-checksum boxing in Java9IntHash by using MethodHandle.invokeExact
(#4876)
Java9IntHash reaches java.util.zip.CRC32C's private updateBytes and
updateDirectByteBuffer through java.lang.reflect.Method.invoke, which takes
its
arguments as an Object[]. Every checksum therefore boxes the running CRC,
the
buffer address and both offsets, and allocates the array to hold them —
24-40
bytes per call, once per checksummed buffer.
This is the path taken whenever the SSE 4.2 native library is unavailable,
which
is every platform except x86-64 Linux: the published circe-checksum jar
contains
a single native library, lib/libcirce-checksum.so, built from
crc32c_sse42.cpp
and linked for x86-64. ARM hosts, macOS and Windows all fall through to this
class and pay the boxing on every entry.
Bind the two methods to method handles instead and call them with
invokeExact.
The arguments are passed as primitives, nothing is allocated, and the JIT
can
inline through a static final handle. Lookup.unreflect skips its own access
check
for a Method whose accessible flag is already set, so this needs no Java 9+
lookup API and still compiles at source level 8.
DigestTypeBenchmark, CRC32_C over a pooled direct buffer, JDK 21 on aarch64:
entry size throughput (ops/ms) allocation (B/op)
64 350,622 -> 510,521 (+46%) 24 -> ~0
1024 20,765 -> 21,050 (+1%) 40 -> ~0
Larger entries are dominated by the checksum itself, so the win there is the
allocation rather than the throughput: 8.0 GB/s of garbage at 64-byte
entries
becomes none at all.
Also covers the direct-buffer path, which no test reached before — it is
the only
caller of updateDirectByteBuffer, whose (int, long, int, int)int shape has
to be
matched exactly by invokeExact, and a mismatch fails at runtime rather than
at
compile time.
---
.../scurrilous/circe/checksum/Java9IntHash.java | 66 +++++++++++++++++-----
.../circe/checksum/Java9IntHashTest.java | 46 +++++++++++++++
2 files changed, 97 insertions(+), 15 deletions(-)
diff --git
a/circe-checksum/src/main/java/com/scurrilous/circe/checksum/Java9IntHash.java
b/circe-checksum/src/main/java/com/scurrilous/circe/checksum/Java9IntHash.java
index f2d7276ec0..92db94c44f 100644
---
a/circe-checksum/src/main/java/com/scurrilous/circe/checksum/Java9IntHash.java
+++
b/circe-checksum/src/main/java/com/scurrilous/circe/checksum/Java9IntHash.java
@@ -20,15 +20,22 @@ package com.scurrilous.circe.checksum;
import io.netty.buffer.ByteBuf;
import io.netty.util.concurrent.FastThreadLocal;
-import java.lang.reflect.InvocationTargetException;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
import java.lang.reflect.Method;
import lombok.CustomLog;
@CustomLog
public class Java9IntHash implements IntHash {
static final boolean HAS_JAVA9_CRC32C;
- private static final Method UPDATE_BYTES;
- private static final Method UPDATE_DIRECT_BYTEBUFFER;
+
+ // Method handles rather than java.lang.reflect.Method: Method.invoke
takes its arguments as an
+ // Object[], so every call boxes the checksum, the address and the offsets
and allocates the
+ // array. Since this runs once per checksummed buffer, that showed up as
~9% of all allocation
+ // in a broker under a write-heavy workload. invokeExact on a static final
handle passes the
+ // primitives straight through and lets the JIT inline the target,
allocating nothing.
+ private static final MethodHandle UPDATE_BYTES;
+ private static final MethodHandle UPDATE_DIRECT_BYTEBUFFER;
private static final String CRC32C_CLASS_NAME = "java.util.zip.CRC32C";
@@ -41,16 +48,25 @@ public class Java9IntHash implements IntHash {
static {
boolean hasJava9CRC32C = false;
- Method updateBytes = null;
- Method updateDirectByteBuffer = null;
+ MethodHandle updateBytes = null;
+ MethodHandle updateDirectByteBuffer = null;
try {
Class<?> c = Class.forName(CRC32C_CLASS_NAME);
- updateBytes = c.getDeclaredMethod("updateBytes", int.class,
byte[].class, int.class, int.class);
- updateBytes.setAccessible(true);
- updateDirectByteBuffer =
+ MethodHandles.Lookup lookup = MethodHandles.lookup();
+
+ // The methods are private to java.util.zip, so they are made
accessible first and then
+ // unreflected: Lookup.unreflect skips its own access check for a
method whose accessible
+ // flag is already set, which is what lets this reach them without
a Java 9+ lookup API.
+ Method updateBytesMethod =
+ c.getDeclaredMethod("updateBytes", int.class,
byte[].class, int.class, int.class);
+ updateBytesMethod.setAccessible(true);
+ updateBytes = lookup.unreflect(updateBytesMethod);
+
+ Method updateDirectByteBufferMethod =
c.getDeclaredMethod("updateDirectByteBuffer", int.class,
long.class, int.class, int.class);
- updateDirectByteBuffer.setAccessible(true);
+ updateDirectByteBufferMethod.setAccessible(true);
+ updateDirectByteBuffer =
lookup.unreflect(updateDirectByteBufferMethod);
hasJava9CRC32C = true;
} catch (Exception e) {
@@ -76,9 +92,11 @@ public class Java9IntHash implements IntHash {
private int updateDirectByteBuffer(int current, long address, int offset,
int length) {
try {
- return (int) UPDATE_DIRECT_BYTEBUFFER.invoke(null, current,
address, offset, offset + length);
- } catch (IllegalAccessException | InvocationTargetException e) {
- throw new RuntimeException(e);
+ // The argument and return types have to match the handle's type
exactly for
+ // invokeExact: (int, long, int, int)int.
+ return (int) UPDATE_DIRECT_BYTEBUFFER.invokeExact(current,
address, offset, offset + length);
+ } catch (Throwable t) {
+ throw asUnchecked(t);
}
}
@@ -97,10 +115,28 @@ public class Java9IntHash implements IntHash {
private static int updateBytes(int current, byte[] array, int offset, int
length) {
try {
- return (int) UPDATE_BYTES.invoke(null, current, array, offset,
offset + length);
- } catch (IllegalAccessException | InvocationTargetException e) {
- throw new RuntimeException(e);
+ // The argument and return types have to match the handle's type
exactly for
+ // invokeExact: (int, byte[], int, int)int.
+ return (int) UPDATE_BYTES.invokeExact(current, array, offset,
offset + length);
+ } catch (Throwable t) {
+ throw asUnchecked(t);
+ }
+ }
+
+ /**
+ * Adapts a failure from {@link MethodHandle#invokeExact}, which is
declared to throw
+ * {@link Throwable}, to something this method can throw. Unlike {@code
Method.invoke}, an
+ * exception raised by the target is not wrapped, so it is passed through
unchanged when it
+ * already is unchecked.
+ */
+ private static RuntimeException asUnchecked(Throwable t) {
+ if (t instanceof Error) {
+ throw (Error) t;
+ }
+ if (t instanceof RuntimeException) {
+ return (RuntimeException) t;
}
+ return new RuntimeException(t);
}
@Override
diff --git
a/circe-checksum/src/test/java/com/scurrilous/circe/checksum/Java9IntHashTest.java
b/circe-checksum/src/test/java/com/scurrilous/circe/checksum/Java9IntHashTest.java
index 3fb57b4a1a..50754d0239 100644
---
a/circe-checksum/src/test/java/com/scurrilous/circe/checksum/Java9IntHashTest.java
+++
b/circe-checksum/src/test/java/com/scurrilous/circe/checksum/Java9IntHashTest.java
@@ -25,6 +25,7 @@ import io.netty.buffer.DuplicatedByteBuf;
import java.util.Random;
import lombok.extern.slf4j.Slf4j;
import org.junit.Assert;
+import org.junit.Assume;
import org.junit.Test;
@Slf4j
@@ -102,6 +103,51 @@ public class Java9IntHashTest {
b2.release();
}
+ /**
+ * The three ways {@link Java9IntHash#resume(int, ByteBuf, int, int)} can
reach the data, checked
+ * against {@link Java8IntHash} as an independent implementation of the
same algorithm.
+ *
+ * <p>The direct path is the one worth pinning: it is the only caller of
the JDK's
+ * {@code updateDirectByteBuffer}, whose {@code (int, long, int, int)int}
shape has to be matched
+ * exactly by the method handle invocation. Getting that wrong is not a
compile error — it fails
+ * at runtime with a {@code WrongMethodTypeException} — and none of the
other cases would catch
+ * it, since they take the {@code updateBytes} path instead.
+ */
+ @Test
+ public void matchesJava8ImplementationOnEveryBufferKind() {
+ Assume.assumeTrue("java.util.zip.CRC32C is not reachable, so
Java9IntHash is not in use",
+ Java9IntHash.HAS_JAVA9_CRC32C);
+
+ byte[] data = new byte[8192];
+ new Random(42).nextBytes(data);
+
+ ByteBuf direct = ByteBufAllocator.DEFAULT.directBuffer(data.length);
+ ByteBuf heap = ByteBufAllocator.DEFAULT.heapBuffer(data.length);
+ try {
+ direct.writeBytes(data);
+ heap.writeBytes(data);
+ Assert.assertTrue("expected a buffer with a memory address to
cover the direct path",
+ direct.hasMemoryAddress());
+
+ Java9IntHash java9 = new Java9IntHash();
+ Java8IntHash java8 = new Java8IntHash();
+
+ Assert.assertEquals(java8.calculate(direct),
java9.calculate(direct));
+ Assert.assertEquals(java8.calculate(heap), java9.calculate(heap));
+ Assert.assertEquals(java8.calculate(new
NoArrayNoMemoryAddrByteBuff(heap)),
+ java9.calculate(new NoArrayNoMemoryAddrByteBuff(heap)));
+
+ // Resuming has to agree too: it is the incremental form the
ledger write path uses.
+ int half = data.length / 2;
+ int java9Resumed = java9.resume(java9.calculate(direct.slice(0,
half)),
+ direct.slice(half, data.length - half));
+ Assert.assertEquals(java8.calculate(direct), java9Resumed);
+ } finally {
+ direct.release();
+ heap.release();
+ }
+ }
+
public static class NoArrayNoMemoryAddrByteBuff extends DuplicatedByteBuf {
public NoArrayNoMemoryAddrByteBuff(ByteBuf buffer) {