Skip to content

Fix FFM plugin OOM: scope native allocations to per-call Arena.ofConfined()#630

Open
Copilot wants to merge 3 commits intodevelopfrom
copilot/fix-outofmemory-issue
Open

Fix FFM plugin OOM: scope native allocations to per-call Arena.ofConfined()#630
Copilot wants to merge 3 commits intodevelopfrom
copilot/fix-outofmemory-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 20, 2026

Pi4JNativeContext.ARENA was a static final Arena.ofAuto() field. Every MemorySegment allocated for errno capture and data buffers on each native call accumulated there indefinitely — the arena never became unreachable, so GC could never reclaim those segments. Profiler confirmed jdk.internal.foreign.SegmentFactories$1 growing from 97k instances (4.7 MB) → 7.4M instances (357 MB) over 750k I2C iterations.

Changes

  • Pi4JNativeContext — Clarifies ARENA is reserved for long-lived library lookups only (e.g. SymbolLookup.libraryLookup in SMBusContext). Exposes CAPTURED_STATE_LAYOUT as public static final for use in per-call arenas. Marks allocateCapturedState() @Deprecated(since="4.1.0", forRemoval=true).

  • IoctlNative, SMBusNative, FileDescriptorNative, PermissionNative, PollNative — All per-call allocations (errno capture, data buffers) now use Arena.ofConfined() in a try-with-resources block, so native memory is freed deterministically at the end of each call.

// Before — allocates into static arena, never freed
var capturedState = context.allocateCapturedState();
var callResult = (int) IoctlContext.IOCTL.invoke(capturedState, fd, command, data);

// After — memory freed when try block exits
try (var arena = Arena.ofConfined()) {
    var capturedState = arena.allocate(CAPTURED_STATE_LAYOUT);
    var callResult = (int) IoctlContext.IOCTL.invoke(capturedState, fd, command, data);
    processError(callResult, capturedState, "callByValue", fd, command, data);
    return callResult;
}

The context field is retained in each Native class solely to trigger *Context class loading (and thus static MethodHandle initialisation) at construction time, preserving existing error-reporting behaviour.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • checkstyle.org
    • Triggering command: /opt/hostedtoolcache/CodeQL/2.25.1/x64/codeql/tools/linux64/java/bin/java /opt/hostedtoolcache/CodeQL/2.25.1/x64/codeql/tools/linux64/java/bin/java -jar /opt/hostedtoolcache/CodeQL/2.25.1/x64/codeql/xml/tools/xml-extractor.jar --fileList=/tmp/codeql-scratch-850ba6d10baafc1e/dbs/java/working/files-to-index16705773135012226395.list --sourceArchiveDir=/tmp/codeql-scratch-850ba6d10baafc1e/dbs/java/src --outputDir=/tmp/codeql-scratch-850ba6d10baafc1e/dbs/java/trap/java (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI linked an issue Apr 20, 2026 that may be closed by this pull request
Copilot AI and others added 2 commits April 20, 2026 07:19
…obal Arena.ofAuto()

The root cause was that Pi4JNativeContext.ARENA = Arena.ofAuto() is a static field.
Every MemorySegment allocated via context.allocate*() was associated with this
arena, which lives for the JVM lifetime, so segments were never freed.
Memory grew by ~50MB per 250k I2C calls, eventually causing OOM.

The fix: each native method call in IoctlNative, SMBusNative,
FileDescriptorNative, PermissionNative, and PollNative now creates a fresh
Arena.ofConfined() with try-with-resources, scoping all per-call allocations
(errno capture buffers, data buffers) to the duration of the call.

Pi4JNativeContext.ARENA is retained only for long-lived library lookups in
SMBusContext (SymbolLookup.libraryLookup). CAPTURED_STATE_LAYOUT is exposed
as public static so Native classes can allocate it in their per-call arenas.
The allocateCapturedState() instance method is marked @deprecated.

Agent-Logs-Url: https://github.com/Pi4J/pi4j/sessions/0934a648-dac1-4363-b861-cb93bb2be3dd

Co-authored-by: FDelporte <1415873+FDelporte@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix OutOfMemory error in FFM related to I2C commands Fix FFM plugin OOM: scope native allocations to per-call Arena.ofConfined() Apr 20, 2026
Copilot AI requested a review from FDelporte April 20, 2026 07:23
@eitch
Copy link
Copy Markdown
Member

eitch commented Apr 20, 2026

@copilot what are the performance implications with these changes?

@FDelporte
Copy link
Copy Markdown
Member

@copilot what are the performance implications with these changes?

@copilot

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 20, 2026

@copilot what are the performance implications with these changes?

The change is net-positive for performance. Here's the breakdown:

Overhead of Arena.ofConfined() per call:

  • Creating a confined arena is a lightweight object allocation (a few fields, no synchronization needed). JIT quickly inlines and eliminates most of the allocation cost.
  • At close/exit of the try block, all segments in that arena are freed in a single sweep — only 1–3 segments per call (captured-state + 1–2 data buffers), so that sweep is trivially fast.
  • Arena.ofConfined() is actually the fastest arena type in the JDK because it requires no synchronization (confined to the creating thread). The old Arena.ofAuto() relies on GC reachability and weak references, which are comparatively slower.

Why performance improves overall:

  • The old static arena accumulated millions of SegmentFactories$1 objects (the profiler screenshots show 97k → 1.27M → 7.4M instances). This caused GC to spend increasing time scanning and managing reachability for all those weakly-referenced segments — even though they could never actually be collected (the arena was statically reachable). With the fix, each call's segments are freed deterministically on exit, GC has far less to track, and pause times shrink.
  • Reduced memory footprint means fewer page faults and better CPU cache utilization.

Relative cost:
A writeThenRead I2C transaction involves at least one ioctl system call, which takes on the order of tens–hundreds of microseconds of kernel time. Creating and closing a confined arena takes nanoseconds. The arena overhead is effectively in the noise.

In short: negligible extra cost per call, meaningful reduction in GC overhead as the program runs longer.

@copilot what are the performance implications with these changes?

@copilot

See my reply above to @eitch for the full analysis — in short: negligible per-call overhead (nanosecond arena creation vs. tens–hundreds of microseconds for the actual system call), and the change actually improves overall performance by eliminating the growing GC burden caused by millions of accumulated SegmentFactories$1 instances in the old static arena.

@eitch eitch self-requested a review April 20, 2026 08:37
@eitch eitch marked this pull request as ready for review April 20, 2026 08:38
Copy link
Copy Markdown
Member

@eitch eitch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me this makes sense. We need to test it, but otherwise i think this is the correct approach.

@FDelporte
Copy link
Copy Markdown
Member

@taartspi are you able to test before we merge?

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.

OutOfMemory in FFM

3 participants