Repository navigation
Conversation
- Move the sun.misc.Unsafe code from MemoryUtil into UnsafeMemoryAccessor - MemoryUtil delegates every low-level operation to a MemoryUtilAccessor - No behavior change: UnsafeMemoryAccessor is the only accessor
- New opt-in module (JDK 22+, java.lang.foreign per JEP 454), only part of the Maven reactor when building with a JDK 22+ launcher - FfmMemoryAccessor implements MemoryUtilAccessor with MemorySegment and Arena instead of sun.misc.Unsafe and reflection - FfmAllocationManager allocates one Arena per buffer, mirroring UnsafeAllocationManager, with a DefaultAllocationManagerFactory for CheckAllocator's classpath scan - arrow.memory.accessor.type=FFM selects the FFM accessor and fails with an actionable message if arrow-memory-ffm is missing; unknown values warn and fall back to Unsafe - arrow.allocation.manager.type=FFM selects FfmAllocationManager and, when arrow.memory.accessor.type is unset, the FFM accessor too, falling back to Unsafe with a warning if the module is missing - Isolated Surefire executions cover each property combination, with and without add-opens - Add the module to the BOM and to the install and overview docs
| MEMORY_ACCESSOR_TYPE_PROPERTY_NAME); | ||
| return accessor; | ||
| } catch (RuntimeException e) { | ||
| // Unlike an explicit arrow.memory.accessor.type=FFM request, this preference is only |
There was a problem hiding this comment.
The fallback to Unsafe only catches RuntimeException, and loadFfmAccessor() only converts ReflectiveOperationException . Suppose -Darrow.allocation.manager.type=FFM is set on JDK 21 or earlier with the arrow-memory-ffm jar on the classpath. Loading FfmMemoryAccessor would throw UnsupportedClassVersionError, since the module is compiled for release 22. An ExceptionInInitializerError from its static init would do the same. Both are Errors, so they skip this fallback and MemoryUtil.<clinit> fails. The class then stays unusable for the life of the JVM, which is the situation the comment in this catch block says the fallback avoids.
Could we catch RuntimeException | LinkageError here? A test that forces the FFM path on a pre-22 JDK would also help.
| </dependency> | ||
| <dependency> | ||
| <groupId>org.apache.arrow</groupId> | ||
| <artifactId>arrow-memory-ffm</artifactId> |
There was a problem hiding this comment.
The BOM lists arrow-memory-ffm unconditionally, but memory/pom.xml only builds the memory-ffm module under the arrow-memory-ffm profile, which is active on [22,). If a release or deploy is built on JDK 17 or 21, the published BOM will reference an artifact that was never built. Anyone who imports the BOM and adds arrow-memory-ffm would then get an unresolvable dependency.
It's not a big deal, but as arrow-java release is not cut with JDK 22+ (for now), I wanted to mention that.
There was a problem hiding this comment.
Good point, indeed. So what are the options:
- Build the release job on JDK 25 and target Java 17 for other modules
maven.compiler.release=17 - Remove the
arrow-memory-ffmfrom the BOM until releases are built on JDK 22+.
Let me know which one fit the best for now ...
There was a problem hiding this comment.
Maybe the later is easier and makes more sense for now.
There was a problem hiding this comment.
OK, but that also means the new module won't be built or published to Maven Central either, since the release jars are built on JDK 17 (the Binaries job in rc.yml). Is there any plan to move it on JDK 25 (with the compiler compatibility) ?
There was a problem hiding this comment.
I can also configure java 25 in this PR to https://github.com/apache/arrow-java/blob/main/.github/workflows/rc.yml#L398
- name: Set up Java
uses: actions/setup-java@v6
with:
java-version: '25'
distribution: 'temurin'|
|
||
| FfmAllocationManager(BufferAllocator accountingAllocator, long requestedSize) { | ||
| super(accountingAllocator); | ||
| this.arena = Arena.ofShared(); |
There was a problem hiding this comment.
Nit: each allocation creates its own Arena.ofShared() here, and release0() calls arena.close(). Closing a shared arena requires a handshake will all threads. Workloads that allocate and free many small buffers, such as vector resizing or per-batch Flight allocations, will likely be much slower than with the Unsafe and Netty managers. This doesn't need to block the PR. It might be worth documenting as a known limitation, or following up with a different arena strategy. Do we have any benchmark numbers?
There was a problem hiding this comment.
Thanks, nice catch @jbonofre, you were right! Benchmark and results: https://gist.github.com/fb64/334d2eeb14e1e4c5adbff6e38e9f3b7e
Using one shared Arena per buffer makes allocation about 100x slower than Unsafe, and it gets worse with more JVM threads (4 KiB: Unsafe 89 ns vs FFM 8.8 µs, and 58.8 µs with 256 idle threads).
I also prototyped a version that allocates with malloc/free downcalls and keeps the same FFM accessor: it matches Unsafe on every workload, including vector growth and batch building. So memory access isn't the issue, only the per-buffer arena.
This doesn't weaken safety: the FFM accessor already accesses memory through new global segments built from the buffer's raw address, not through the arena's segment, so closing the arena never protected against use-after-free. Arrow's protection comes from ArrowBuf's reference count checks, which don't change. Unlike Arena.allocate, malloc also doesn't zero the memory, which matches the Unsafe and Netty managers.
I propose to update this PR with a malloc/free implementation based on the foreign linker instead of one shared Arena per buffer. Does that sound good?
There was a problem hiding this comment.
@fb64 yes, agree for a malloc/free impl in this PR. Happy to help and review 😄
There was a problem hiding this comment.
✅ I also updated the benchmark results in the gist after multiple run on my laptop (Mac book pro M1)
[fix] fall back to unsafe on ffm linkage errors - An inferred FFM accessor (arrow.allocation.manager.type=FFM) now also falls back to Unsafe on LinkageError: UnsupportedClassVersionError on JDK 21 or earlier, or a failing FfmMemoryAccessor static initializer - resolveAccessor takes its inputs as parameters, so tests can simulate these errors without a pre-22 JDK - Addresses review comment r4192588927
[fix] init ffm allocation factory before empty buffer - Declare FfmAllocationManager.FACTORY before EMPTY: creating EMPTY can initialize BaseAllocator, whose default config reads FACTORY back while the class is still initializing, and got null - With only arrow-memory-ffm on the classpath, accessing FACTORY first failed with an NPE in BaseAllocator's static initializer - Add an isolated Surefire execution, since the test needs a fresh JVM
[fix] allocate ffm buffers with malloc and free - Replace one shared Arena per buffer with malloc/free downcalls through the foreign linker, in FfmAllocationManager and FfmMemoryAccessor - Closing a shared Arena handshakes with every JVM thread: allocating and releasing 4 KiB drops from 8.8 us to 97 ns (Unsafe: 90 ns), and from 58.8 us to 99 ns with 256 idle threads - Memory is no longer zeroed on allocation, like the Unsafe and Netty managers - FfmMemoryAccessor.freeMemory now frees any malloc address, which removes the address-to-arena map and its silent no-op for foreign addresses - A failed malloc throws OutOfMemoryError, as Arena.allocate and Unsafe do - Addresses review comment r4192653585
|
|
||
| private FfmMemoryAccessor() {} | ||
|
|
||
| private static MemorySegment segment(long address, long byteSize) { |
There was a problem hiding this comment.
Every scalar get/put goes through this helper, which creates a new MemorySegment via reinterpret. getByteBufferAddress also builds a duplicate buffer and a segment per call. These are hot paths for vectors.
Just curious if you have JMH numbers agains the Unsafe accessor? I would like to know whether escape analysis removes the allocations in practice.
There was a problem hiding this comment.
Good question! I added accessor benchmarks to the gist, run with -prof gc: https://gist.github.com/fb64/334d2eeb14e1e4c5adbff6e38e9f3b7e (section "Accessor reads and writes")
Escape analysis does remove them: with default settings the FFM accessor allocates 0 B per value and matches Unsafe on reads and writes.
As a control, with -XX:-DoEscapeAnalysis each access allocates 80 B (the two segments) and FFM gets 4 to 5x slower, so the benchmark does catch them when they exist.
For getByteBufferAddress, escape analysis removes the allocations too, but copying into a direct ByteBuffer is still about 1.4 ns slower than Unsafe (5.6 ns vs 4.2 ns for 64 bytes). That cost is paid once per copy, whatever its size, not once per value.
Instead of relying on C2 optimization, two improvements are possible:
- Reads and writes: we can use a single global segment as a view over the whole native address space, created once, and access native addresses directly through it. I tried it CF this gist section: with
-XX:-DoEscapeAnalysis, reads and writes no longer allocate and match Unsafe and nothing changes with default settings. I can push it as a fixup. getByteBufferAddress: we can drop theduplicate()withMemorySegment.ofBuffer(buf).address() - buf.position(), which returns the same address and cuts the lookup from 2.1 ns to 1.3 ns in a quick test. The segment created by ofBuffer can't be avoided with the public API, so this path would still rely on escape analysis. Can also push it as a fix-up
I think it'd be a good idea. Let me know what you think.
| @Override | ||
| public void copyToMemory(byte[] src, long srcIndex, long destAddress, long bytes) { | ||
| MemorySegment.copy( | ||
| src, | ||
| checkedInt(srcIndex), | ||
| segment(destAddress, bytes), | ||
| ValueLayout.JAVA_BYTE, | ||
| 0, | ||
| checkedInt(bytes)); | ||
| } | ||
|
|
||
| @Override | ||
| public void copyFromMemory(long srcAddress, byte[] dest, long destIndex, long bytes) { | ||
| MemorySegment.copy( | ||
| segment(srcAddress, bytes), | ||
| ValueLayout.JAVA_BYTE, | ||
| 0, | ||
| dest, | ||
| checkedInt(destIndex), | ||
| checkedInt(bytes)); | ||
| } |
There was a problem hiding this comment.
checkedInt throws IllegalArgumentException when bytes or the index exceeds Integer.MAX_VALUE, while the Unsafe accessor takes long. Since the other side is a byte[], this probably can't happen in practice.
Could you add a short comment saying so, so the difference is deliberate?
There was a problem hiding this comment.
Yes, this is deliberate, due to MemorySegment.copy taking an int index and length on the array side, since Java arrays are int-indexed, so a valid call always fits. Every call site in Arrow (ArrowBuf, the JDBC ClobConsumer, the decimal vectors) passes ints or checks the array bounds first, so checkedInt can only fail on a caller bug, where the Unsafe accessor would copy outside the array without any check...
I'll add a comment on checkedInt
| * @throws OutOfMemoryError if {@code malloc} cannot allocate them, like {@code | ||
| * sun.misc.Unsafe#allocateMemory} | ||
| */ | ||
| static long allocate(long bytes) { |
There was a problem hiding this comment.
A negative bytes is passed to malloc as a huge size_t, so it returns NULL and we throw OutOfMemoryError. Unsafe.allocateMemory throws IllegalArgumentException here.
Could we add an explicit bytes < 0 check so callers see the same exception type?
There was a problem hiding this comment.
Good catch, done: NativeMemory.allocate now throws IllegalArgumentException for a negative size, like Unsafe.allocateMemory, with a test. Before, the OutOfMemoryError was even treated as fatal by JUnit and killed the test JVM 😅
[docs] explain int checks in byte array copies - Note why checkedInt narrows byte[] indexes and lengths to int: MemorySegment.copy takes ints there, so a valid call always fits - Addresses review comment r4207204760
[fix] reject negative ffm allocation sizes - NativeMemory.allocate throws IllegalArgumentException for a negative size, like Unsafe.allocateMemory, instead of passing it to malloc as a huge size_t and throwing OutOfMemoryError - Addresses review comment r4207224636
What's Changed
MemoryUtilrelies onsun.misc.Unsafewhichever allocation manager is used, and on reflection that requires--add-opens=java.base/java.nio=ALL-UNNAMED. Unsafe memory access is deprecated for removal (JEP 471) and has printed a warning since JDK 24 (JEP 498). This PR adds an opt-in alternative built on the FFM API, which is final since JDK 22 (JEP 454).MemoryUtilmoves toUnsafeMemoryAccessor, behind a newMemoryUtilAccessorinterface. Unsafe stays the default.arrow-memory-ffmmodule (JDK 22+):FfmMemoryAccessorusesMemorySegment/Arenainstead of Unsafe and reflection, andFfmAllocationManagerallocates oneArenaper buffer. The module is only built with a JDK 22+ launcher. It's added to the BOM and the docs.-Darrow.allocation.manager.type=FFMswitches both the allocator and the accessor, so Unsafe isn't used at all.-Darrow.memory.accessor.type=FFMswitches only the accessor.With FFM,
--add-opensis no longer needed, and a dedicated test run checks that.MemorySegment.reinterpretis a restricted method, so pass--enable-native-access=org.apache.arrow.memory.ffm(orALL-UNNAMEDon the classpath) to avoid a JVM warning.I assume this request was created with the help of AI agent (Claude).
Closes #163.