[foreign-memaccess] RFC: to scope or not to scope?

Maurizio Cimadamore maurizio.cimadamore at oracle.com
Fri May 31 20:44:42 UTC 2019


Hi,
lately I've been thinking hard about the relationship between scopes and 
memory segments in the foreign-memaccess API. I think some of the 
decisions we made lately - e.g. make scopes either global (pinned) or 
closeable (and confined) is a good one. But now I wonder, why do we need 
scopes in this API?

A scope is useful to manage lifecycle of memory resources; you create a 
scope, do some allocation within it, close the scope and the associated 
resources are gone. This is a good programming model for high level 
layers, but is it still a good model for low-level layers? Over the last 
few weeks I noticed few things:

* creating a scope is quite expensive, as there are several data 
structures associated to it

* the ownership/parent mechanism is completely useless in this API, 
given that the scope parent-of relationship is really only useful when 
storing pointers from scope A into scope B, an operation that is not 
available in this API (since we have no pointers here)

* the API forces you through quite a bit of hops to get to what you 
want; code like this is pretty idiomatic:

try (MemoryScope scope = MemoryScope.globalScope().fork()) {
     MemorySegment segment = scope.allocate(32);
     MemoryAddress address = segment.baseAddress();
     ...
}

* Several of the new MemorySegment sources just use the 'pinned' 
UNCHECKED scope - de-facto turning scope checks off


So, we have quite a complex API, which is used (in part) only when 
dealing with native memory; in the remaining case it's just noise, mostly.

This set me off thinking... what if we brought some of the aspect of 
memory scope into the memory segment itself (and then get rid of memory 
scopes) ? That is, let's see if we can add the following things to a 
segment:

* make it AutoCloseable - this way you can use a segment into a 
try-with-resources, as you would do with scopes

* add a 'isAlive' state; a segment starts off in the 'alive' state and 
then, when it's closed it goes in the 'closed' state - meaning that all 
addresses originating from it (as well as all the segments) will become 
invalid

* add confinement, so that only the owner thread is allowed to call 
methods in the segment (e.g. to resize or close it)

* add some methods to obtain a read-only segment, a confined one (one 
which only allows read/writes from owning thread), or a pinned one (one 
that cannot be closed)

In this new world, the above code can be rewritten as follows:

try (MemorySegment segment = MemorySegment.ofNative(32)) {
     MemoryAddress address = segment.baseAddress();
     ...
}

This is more compact and goes straight to the point. I like how compact 
the API is - and I also like that now the API is very symmetric with the 
heap cases as well - for instance:

try (MemorySegment segment = MemorySegment.ofArray(new byte[32])) {
     MemoryAddress address = segment.baseAddress();
     ...
}

The only thing that changes is the resource declaration in the try with 
resources!


It's not all rosy of course; this choice has some consequences:

* memory segments are no longer immutable; that was possible before, 
since the mutable state was confined into the scope which was then 
attached to the segment. Now it's the segment itself that is mutable (in 
the liveness bit). While this could make transition to value types 
harder, I don't think it's really a blocker - in reality we could 
implement a very similar trick where we push the mutable state somewhere 
else, and then the segment becomes immutable again. Also, in real world 
cases I expect clients will do some kind of pooling, allocating big 
segments and then returning small pinned sub-regions to clients (thus 
avoiding one system call per allocation). In such cases, there is only 
one master mutable segment - and a lot of small immutable ones. Which is 
an happy case.

* Thinking about what happens when you e.g. resize a region is a bit 
harder if you can close a region. Should closing a sub-region also close 
the one it comes from? Or should we throw an exception? Or should we do 
nothing? or reference counting? After reading the very good docs on 
Netty [1], I came to the conclusions that closing a derived segment 
should also result in the closure of the root one. This choice would of 
course not be a very good one for pooled sub-regions, but for this we 
can always use the pinning operation - that is: create a resized 
sub-region, pin it, and return it to the client. I think the combination 
of resizing + pinning gives quite a bit of power and I don't see any 
immediate need for doing something with reference counting.


I then realized that the pointer scopes we have in foreign right now can 
in fact be implemented *cleanly* on top of this lower level memory 
segment mechanism. Here's a snippet of code which demonstrates how one 
would go about writing a PointerScope which allocates a slab of memory 
and returns portions of it to the clients:


class PointerScopeImpl implements PointerScope {
     long SEGMENT_SIZE = 64 * 1024;

     List<MemorySegment> usedSegments;
     MemorySegment currentSegment;
     long offsetInSegment;

     <X> Pointer<X> allocate(LayoutType<X> type) {
        MemorySegment segment = 
allocateInternal(type.layout().bytesSize(), type.layout().alignmentBytes());
        return new BoundedPointer<X>(type, segment.baseAddress());
     }

     ...

     MemorySegment allocateInternal(long bytes, long align) {
          long size = type.bytesSize();
          if (offsetInSegment + size > SEGMENT_SIZE) {
              usedSegments.add(currentSegment);
              currentSegment = 
MemorySegment.ofNative(Math.min(SEGMENT_SIZE, size), align);
              offsetInSegment = 0;
          }
          MemorySegment segment = currentSegment.resize(offsetInSegment, 
size);
          offsetInSegment += size;
          return segment;
     }

     void close() {
         currentSegment.close();
         usedSegments.forEach().close();
     }
}

That is, we can get the same functionality we have in Panama, 
essentially using segments as a way to get at the Unsafe allocation 
facilities. This seems pretty cool!

I've put together a prototype of this approach (should apply cleanly on 
top of foreign-memaccess):

http://cr.openjdk.java.net/~mcimadamore/panama/scope-removal/

I was pleased at how the tests could be simplified with this approach. I 
was also please to see that the performance numbers took a significant 
jump forward, essentially bringing this within reach of raw Unsafe usage 
(but with the extra safety sprinkled on top).

Concluding, this seems yet another of those cases where we were trying 
to conflate high-level concerns with lower-level concerns, and once we 
push everything in the right place of the stack, things seems to slot 
into a lower energy state. What do you think?

Cheers
Maurizio

[1] - https://netty.io/wiki/reference-counted-objects.html






More information about the panama-dev mailing list