Skip to content

Avoid per-checksum boxing in Java9IntHash by using MethodHandle.invokeExact - #4876

Open
lhotari wants to merge 1 commit into
apache:masterfrom
lhotari:lh-circe-crc32c-invokeexact
Open

Avoid per-checksum boxing in Java9IntHash by using MethodHandle.invokeExact#4876
lhotari wants to merge 1 commit into
apache:masterfrom
lhotari:lh-circe-crc32c-invokeexact

Conversation

@lhotari

@lhotari lhotari commented Sep 5, 2026

Copy link
Copy Markdown
Member

Motivation

Java9IntHash reaches java.util.zip.CRC32C's private updateBytes and updateDirectByteBuffer
through java.lang.reflect.Method.invoke. Method.invoke takes its arguments as an Object[], so
every checksum boxes the running CRC, the buffer address and both offsets, and allocates the array to
hold them. That happens once per checksummed buffer, on the ledger write path.

This is the fallback used on every platform without the SSE 4.2 native library, which is every
platform except x86-64 Linux.
Crc32cIntChecksum picks JniIntHash only when
Sse42Crc32C.isSupported(), and the published circe-checksum jar contains exactly one native
library:

$ unzip -l circe-checksum-4.18.0.jar | grep lib/
        0  lib/
    16936  lib/libcirce-checksum.so

$ file lib/libcirce-checksum.so
lib/libcirce-checksum.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), ...

It is built from crc32c_sse42.cpp, so it is x86-only by construction. On ARM — Graviton,
Ampere, Apple Silicon — the load fails on the wrong architecture; on macOS and Windows there
is no .dylib/.dll in the jar at all. All of them fall through to Java9IntHash and pay the boxing
on every entry. Deployments on x86-64 Linux take the native path and never see this.

I found it profiling a Pulsar broker on aarch64 under a write-heavy workload: allocation sampling put
9% of all broker allocation in Java9IntHash.updateDirectByteBufferLong.valueOf /
Integer.valueOf, and the broker was GC-bound.

Changes

Bind the two methods to MethodHandles 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,
which is what the existing code establishes with setAccessible(true). So this needs no Java 9+
lookup API (no privateLookupIn) and still compiles at source level 8, which matters for
branch-4.17 and earlier if this is worth backporting. Behaviour when java.util.zip.CRC32C cannot be
reached is unchanged — HAS_JAVA9_CRC32C stays false and Crc32cIntChecksum falls back to
Java8IntHash.

One behavioural difference worth noting: invokeExact does not wrap an exception raised by the
target the way Method.invoke does with InvocationTargetException, so an unchecked exception now
propagates unchanged instead of nested inside a RuntimeException.

Results

DigestTypeBenchmark, digest=CRC32_C, bufferType=BYTE_BUF_DEFAULT_ALLOC, JDK 21.0.11 on
aarch64 (so the JNI path is unavailable and Java9IntHash is in use), -f 2 -wi 5 -i 5:

entry size throughput before throughput after allocation before allocation after
64 350,622 ± 6,010 ops/ms 510,521 ± 3,182 ops/ms (+46%) 24 B/op, 8,024 MB/s ~0 B/op, 0.004 MB/s
1024 20,765 ± 311 ops/ms 21,050 ± 75 ops/ms (+1%) 40 B/op, 792 MB/s ~0 B/op, 0.004 MB/s

Larger entries are dominated by the checksum computation itself, so there the win is the allocation
rather than the throughput — 8 GB/s of garbage at 64-byte entries becomes none at all, and
gc.count for that run goes from 240 to 0.

Tests

Java9IntHashTest gains a case that checks Java9IntHash against Java8IntHash — an independent
implementation of the same algorithm — over a direct buffer, a heap buffer and a buffer with neither
an array nor a memory address, plus the incremental resume form.

The direct-buffer case is the one that needed adding: 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 with WrongMethodTypeException rather than at compile
time. No existing test reached it — I confirmed that by swapping the two offset arguments, which the
new test catches and the pre-existing tests do not.

mvn -pl circe-checksum test passes (79 tests), as do the bookkeeper-server digest tests.

…eExact

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant