Hi everyone,
To complement the number of new issues on the github repo, I'd like to share with you some design goals and ideas that shape the java interop work we've embarked on here that should help wrap your head around how it all fits together.
First and foremost, the swift-java project consists of two general "approaches" to the problem. While we may yet rename and reorganize them around a bit, in general the two sides of the project are:
Swift-Java Components
Swift -> Java
JavaKit - a collection of Swift macros and a tool that generates Swift sources (with those @Java...
annotations), which allow Swift code to call into Java. In general, it's based around those macros forming the necessary JNI calls.
Examples: com.example.swift.HelloSwift (Java) & the Swift side of this class, implementing some methods marked as native
in Java as well as declaring the rest as @JavaMethod
s
Java -> Swift
jextract-swift
tool -- that generates Java sources, by ingesting swift interface files. Our primary focus here right now is to leverage the newly released (in March 2024, as part of JDK22), JEP 454: Foreign Function & Memory API, also known as Project Panama.
jextract-swift is follows a similar idea to the JDK's jextract tool, which extracts accessors from C headers. However, instead of using C as a common ground, jextract-swift is directly importing *.swiftinterface
files.
SwiftKit support library -- is a Java library that would include some common helpers and utilities for working with Swift types. Including a SwiftArena
that can take care to not leak objects etc.
--
Another distinction between the two approaches is which side of the code needs zero changes. With javakit we're able to write all accessors in Swift, and with jextract we're able to import unmodified Swift libraries and expose them to Java.
--
jextract-swift discussion
In this post I'd like to discuss ideas about jextract-swift.
In general, one should keep in mind that jextract-swift
is a Java source generator, so anything we can do to make the Java-experience of calling Swift good in the generator we should consider doing.
Runtime requirements
Currently we are focusing on JEP-454, because we'd like to utilize it to create an unique and high-performance Java/Swift interop story for server systems.
This aspect is quite important for calling Swift in hot-paths of services. You can imagine use-cases involving cryptography libraries implemented in Swift, which are a common use-case for JNI nowadays, and we'd like to make it both easier to use and more performant when the native side of such calls is Swift.
The use of JEP-454 means that this specific path is not open for legacy systems, when a deployment cannot use JDK22+ (which is not an LTS release), or if the target system is Android (or similar).
Since jextract-swift is just™ a source generator tool, we could offer it another mode that would generate the Java wrapper classes with downcalls implemented using JNI. In fact, here's the issue about this: JNI mode for jextract-swift
#25.
The JNI mode would probably come with different import limitations (what kinds of Swift interfaces we can import/expose as Java), however it would be the most compatible wrt. JDK versions as well as platforms (e.g. Android).
TL;DR;
- Right now the build requires JDK22+, but it won't have to. We'll untangle the build and allow using "what you can use" eventually. (Help on this front is more than welcome!)
- javakit - does not require any "recent" JDK features, as it only uses plain-old-JNI
- jextract-swift (panama) - will keep requiring JDK22+
- jextract-swift (jni) - will need to be developed, and would support older JDKs and platforms. (Again, this might be an excellent area for contributions)
Swift interface enchancements
The jextract-swift approach relies on additions to .swiftinterface files that the Swift compiler is emitting.
Today Swift interfaces offer a description of public APIs exposed by a module, such as this:
// swift-interface-format-version: 1.0
// swift-compiler-version: Apple Swift version 6.0 effective-5.10 (swiftlang-6.0.0.7.6 clang-1600.0.24.1)
// swift-module-flags: -target arm64-apple-macosx15.0 -enable-objc-interop -enable-library-evolution -module-name MySwiftLibrary
import Swift
...
public func globalTakeInt(i: Int)
extension MySwiftClass {
public func helloMemberInExtension()
}
public class MySwiftClass {
public init(len: Swift.Int, cap: Swift.Int)
}
In order to support jextract, and other language integrations (!), we are going enhance swift interface files with additional information, such as mangled names of methods, types, and potentially more detailed information e.g. @layout(...)
of struct/enums etc, such that other languages can be informed about the exact memory layout of how Swift decided to lay out certain types in memory.
For a start, we've started including mangled names when enabled with -abi-comments-in-module-interface
, which show up as follows:
// MANGLED NAME: $s14MySwiftLibrary13globalTakeInt1iySi_tF
public func globalTakeInt(i: Int)
// MANGLED NAME: $s14MySwiftLibrary0aB5ClassCMa
public class MySwiftClass {
// MANGLED NAME: $s14MySwiftLibrary0aB5ClassC3len3capACSi_SitcfC
public init(len: Swift.Int, cap: Swift.Int)
}
This is a good step, however still not sufficient to form member method calls, as swift's calling conventions don't directly match C calling conventions, e.g. in how self or errors are passed.
Swift Accessor Thunks / Swift Calling Convention
Swift's calling convention differs from raw C-style calling conventions, making it difficult (or even not possible) to be directly invoked from other runtimes unless they also implement the calling convention.
Because implementing the calling conventions inside the JVM may prove difficult, we are instead going to investigate a form of "language interop" mode in which Swift will emit thunks that wrap existing public methods and offer a C calling convention compatible "thunk" that calls directly into the target Swift method.
For example, the self
parameter may be passed using registers, and similar optimizations are done for throwing functions. The here proposed thunks, would take care of Swift's calling convention details, and offer an easier (or at least possible) to call using plain parameter passing which should be easier/possible to adopt via the JVM's foreign function interface.
The full extent and details of these thunks are not yet defined, but the general goal here is to make it possible to form calls from the JVM directly to these, without having to worry about when to use what register on the calling side.
This also serves as an opportunity to convert thrown errors in Swift into exceptions thrown in Java.
async
is another interesting topic, though we've not explored it in depth yet. I won't go to deep into speculation mode about we could do there, but in theory Java's virtual threads and runtime based on continuations (jvmls2024) may lean towards something we could integrate with by entering async from an virtual thread, converting it to a Task "in-place" and swift runtime yields/resumes mounting/unmounting the carrier thread etc.
Imported types / Usability
As far as the user experience we're interested in offering with jextract-swift, it boils down to the following snippet from one of the sample apps:
// Import swift-extract generated sources
import static com.example.swift.generated.JavaKitExample.*;
import static com.example.swift.generated.MySwiftClass.*;
JavaKitExample.helloWorld();
// ...
MySwiftClass obj = new MySwiftClass(2222, 7777);
SwiftKit.retain(obj.$memorySegment());
System.out.println("[java] obj ref count = " + SwiftKit.retainCount(obj.$memorySegment()));
obj.voidMethod();
obj.takeIntMethod(42);
Given a Swift module called JavaKitExample
:
- public global functions get imported as static methods on a class with the same name as the module
- public types in the module get imported as their respective Java counterparts: a Java class wrapper around a memory segment which points directly to the memory location of the Swift allocated object.
- the wrapper provides methods which "look and feel" like their Swift versions
- it is possible to get the "pointer" by using
.$memorySegment()
, - and we offer some low level APIs in the
SwiftKit
library to work with those, for example we can retain/release/retainCount such classes. (Maybe we'll offer safer APIs for this, where we accept aSwiftHeapObject
implementing Java object instead)
Details of importing methods is still being developer, however in general you can expect a throwing Swift function to be imported as throwing in Java (checked exception) etc.
Usability: callbacks (swift -> java upcalls)
We are able to pass callbacks across the language barrier as well (source synthesis not implemented yet). The general approach here is that a specific Swift closure signature, e.g. () -> ()
can be expressed as a java.lang.Runnable
, so a method like this:
public func globalCallJavaCallback(callMe: () -> ()) { ... }
is possible to be exposed as the following Java signature:
public static void globalCallJavaCallback(Runnable callMe);
where we create an ad-hoc upcall handler which is able to store the Runnable's this
and allow the Swift side to "just call the closure". The Java side then looks like this:
JavaKitExample.globalCallJavaCallback(() -> {
num += 1;
});
which relies on the usual functional interface mechanisms of Java (a protocol with a single method can be expressed with a lambda matching the method's signature).
Further discussion is needed about handling @escaping closures, because it means we have to keep the Runnable "alive" as the upcall handle escapes.
For those curious how such calls are constructed, here's a snippet to provide a little bit of insight what kind of source generation we're dealing with here in general:
private static class globalCallJavaCallback {
public static final FunctionDescriptor DESC = FunctionDescriptor.ofVoid(
ADDRESS // Runnable / () -> ()
);
public static final MemorySegment ADDR = ManualJavaKitExample.findOrThrow("$s14JavaKitExample010globalCallA8Callback6callMeyyyXE_tF");
public static final MethodHandle HANDLE = Linker.nativeLinker().downcallHandle(ADDR, DESC);
}
public static void globalCallJavaCallback(Runnable callMe) {
var mh$ = globalCallJavaCallback.HANDLE;
try {
// signature of 'void run()'
FunctionDescriptor callMe_run_desc = FunctionDescriptor.ofVoid(
);
MethodHandle callMe_run_handle = MethodHandles.lookup()
.findVirtual(Runnable.class,
"run",
callMe_run_desc.toMethodType());
callMe_run_handle = callMe_run_handle.bindTo(callMe); // set the first parameter to the Runnable as the "this" of the callback pretty much
try (Arena arena = Arena.ofConfined()) {
Linker linker = Linker.nativeLinker(); // the () -> () is not escaping, we can destroy along with the confined arena
MemorySegment runFunc = linker.upcallStub(callMe_run_handle, callMe_run_desc, arena);
mh$.invokeExact(runFunc);
}
} catch (NoSuchMethodException | IllegalAccessException e) {
throw new RuntimeException(e);
} catch (Throwable e) {
throw new AssertionError(e);
}
}
The java.util.function
package contains ~40 interfaces which represent various shapes of functions, such as (T) -> Bool
(Predicate), (T) -> R
(Function), and more. Including specialized versions accepting primitive types.
We should handle all those shapes and provide Java signatures for Swift methods accepting callbacks using the most appropriate signature. This includes picking primitive specialized versions of these interfaces which allows the Java runtime to avoid boxing primitives into wrapper objects (i.e. IntSupplier
is more efficient than Supplier<Integer>
).
Special types and challenges
While on the topic of special types such as functional interfaces.
We should provide efficient ways to pass arrays of bytes, byte buffers, Strings, and other common types between the language boundaries.
Specifically, a way to pass byte buffers to Swift without. Notably, passing arrays with JNI is rather painful, as the receiving side must receive it as the specialized j...Array
type, which is a reference type and must be worked with through explicit JNI APIs:
// JNI, not Panama
JNIEXPORT jdoubleArray JNICALL Java_TestJNIPrimitiveArray_sumAndAverage
(JNIEnv *env, jobject thisObj, jintArray inJNIArray) {
// Step 1: Convert the incoming JNI jintarray to C's jint[]
jint *inCArray = (*env)->GetIntArrayElements(env, inJNIArray, NULL);
...
}
In other words, a plain JNI implementation often is forced into copying data when crossing language boundaries.
With JEP-454, we gain the ability for fine grained control over native memory allocation and can even obtain (Java) ByteBuffer
based directly on memory returned from foreign function calls.
We will want to investigate and offer ways to deal with bytes crossing language boundaries in a safe and more efficient way. Byte arrays are a very common type passed around between JVM and native–especially in high-performance network applications–and deserve extra attention, in order to allow improved performance for networking applications which aim to mix Swift and Java in the same application.
Our goal here that using jextract-swift would not only be more convenient but also more performant for many use cases.
Lifetimes
One of the problems bridging Java and Swift will be that lifetimes of objects don't inherently match eachother.
This was less of a problem for Swift and obj-c, because both systems use reference counting; and to some degree it is an issue with Swift/C++ as C++ types have various ways of managing lifetimes, however Swift/C++ interop offers ways to deal with C++ shared (or immortal) references.
Because the JVM is a managed runtime with a GC, there is an inherent mismatch in behavior of how memory is managed.
We need to discuss how the "Java wrapper around pointer to native Swift class" should manage their lifetime.
One idea includes explicit lifetimes by using a form of Arena
, where a Swift object created in an Arena is tracked by it, and by the end of its lifetime should be count-down-to-zero and destroyed:
try (var arena = SwiftArena.ofConfined()) {
var obj = new MySwiftObject(arena)
hello(obj)
} // count down obj, assert that destroyed
// if obj refcount > 1, it was "leaked"
// beyond lifetime of the arena which is a programming error
This would offer an explicit way of ensuring objects are disposed and not "leaked" accidentally.
The JVM does offer "finalizers" which are similar to Swift's deinit
however they are a very well known source of indeterminism, performance issues and subtle runtime issues that are nigh impossible to work around once finalizers are introduced in a system.
Personally, I would argue we can only use finalizers to "assert the native object was already released" or similar leak detection in debug builds only, but can not rely on them for lifetime cleanup (i.e. just causing the refcount to be decremented). Instead, we have to provide APIs to make the lifetime management bearable, most likely through the SwiftArena
concept.
Further performance optimizations
We can do a lot more with the JDK's new foreign function and memory APIs. For example, if we expose detailed object layout information in swift interfaces (e.g. using some @layout() annotation) then jextract-swift could be able to form MemoryLayout
descriptions of Swift structs:
// example memory Layout
private static final GroupLayout $LAYOUT = MemoryLayout.structLayout(
ManualJavaKitExample.SWIFT_INT.withName("len"),
ManualJavaKitExample.SWIFT_INT.withName("cap")
).withName("$MySwiftType");
which we could use to form direct memory accesses to properties of Swift types, without calling through accessors. We could only do this if such property does not have side effects like willSet
etc, however it may be an interesting future performance optimization.
Without these, we're still able to access properties through their accessors, which function the same as normal member methods in Swift.
Packaging and distribution
While we're yet quite far away from this stage, the end goal is to be able to provide SwiftPM and Gradle and/or Maven plugins that would automate building, extracting and packaging up Swift native code along side Java artifacts.
This may include the ability to include compiled Swift libraries inside a .jar
and shipping it as such native code dependent jar, versioned for specific platforms etc.
For other libraries, e.g. for example for calling into frameworks provided by the SDK on macOS, we would only package up the source generated Java accessors.
The exact details of how far and what use-cases to prioritize here are up for discussion and we'd love to hear from you, and would encourage contributions in this space once we've figured out what we want to build here!
What about GraalVM?
We are currently not investigating native-image or GraalVM solutions.
However in they are an interesting thing to keep an eye on. If someone in the community has more experience with those, input and ideas would be most welcome.
As far as we know GraalVM currently does not support upcalls, and may have other limitations which could make the integration difficult.
Similarly, we are not currently exploring the direction of running Swift -> LLVM bitcode -> GraalVM LLVM Runtime. It may be an interesting avenue to explore, but currently we are going to focus on "plain old" Java interoperability. Again, if you have the expertise and/or time to investigate this avenue, you're more than welcome to.
Get involved!
This has been a bit of a dump of status and plans, but I hope it's useful to set the stage where we're at, and how to get involved.
Please watch the swift-java github repository and check out the "help wanted" and "good first issue".
Please feel free to discuss in this thread and/or create new threads. We're quite early in the development and input/ideas/help are very welcome.
And a special shout out to folks I had a chance to talk with during serversideswift conference last week as well, and I look forward to hearing from you all soon! As soon as video from the talk announcing java-swift is available we'll share it here as well.
~~ mini dictionary ~~
- downcall -- calling "down" from the perspective of the JVM (to native Swift code)
- upcall -- calling "up" into the JVM
- panama -- umbrella name for the JDK's foreign function and memory APIs OpenJDK: Panama
- jextract -- the JDK's "extract Java accessors to C interfaces from C headers" tool: GitHub - openjdk/jextract: https://openjdk.org/projects/code-tools, our
jextract
tool is a completely separate binary, unrelated to jextract (may need to consider renaming?) - jextract-swift -- the tool provided as part of swift-java, able to extract Java accessors to Swift code, based on imported swift interface files.