rust-diplomat / diplomat

Experimental Rust tool for generating FFI definitions allowing many other languages to call Rust code
https://rust-diplomat.github.io/book/
Other
468 stars 45 forks source link

Add support for Java #144

Open tomleavy opened 2 years ago

tomleavy commented 2 years ago

Same use case as #143 , except the Java version

tomleavy commented 2 years ago

Here is my suggestion for how to handle Java binding generation

FFI Code from fixed_decimal.rs

#[diplomat::bridge]
pub mod ffi {
    use diplomat_runtime::{DiplomatResult, DiplomatWriteable};
    use fixed_decimal::FixedDecimal;
    use writeable::Writeable;

    #[diplomat::opaque]
    #[diplomat::rust_link(fixed_decimal::FixedDecimal, Struct)]
    pub struct ICU4XFixedDecimal(pub FixedDecimal);

    impl ICU4XFixedDecimal {
        /// Construct an [`ICU4XFixedDecimal`] from an integer.
        pub fn new(v: i32) -> Box<ICU4XFixedDecimal> {
            Box::new(ICU4XFixedDecimal(FixedDecimal::from(v)))
        }

        /// Multiply the [`ICU4XFixedDecimal`] by a given power of ten.
        #[diplomat::rust_link(fixed_decimal::FixedDecimal::multiply_pow10, FnInStruct)]
        pub fn multiply_pow10(&mut self, power: i16) {
            self.0.multiply_pow10(power).unwrap();
        }

        /// Invert the sign of the [`ICU4XFixedDecimal`].
        #[diplomat::rust_link(fixed_decimal::FixedDecimal::negate, FnInStruct)]
        pub fn negate(&mut self) {
            self.0.negate()
        }

        /// Format the [`ICU4XFixedDecimal`] as a string.
        #[diplomat::rust_link(fixed_decimal::FixedDecimal::write_to, FnInStruct)]
        pub fn to_string(&self, to: &mut DiplomatWriteable) -> DiplomatResult<(), ()> {
            self.0.write_to(to).map_err(|_| ()).into()
        }
    }
}

Generated JNI Glue

Runtime JNI type conversions

#[inline(always)]
pub fn jint_to_u32(val: jint) -> u32 {
    val as u32 // jint is a type alias for i32
}

#[inline(always)]
pub fn jint_to_i16(val: jint) -> i16 {
    val as i16
}

#[inline(always)]
pub fn jlong_to_mut_ptr<T>(val: jlong) -> *mut T {
    &mut *(val as *mut T)
}

#[inline(always)]
pub fn jlong_to_ptr<T>(val: jlong) -> *T {
    &*(val as *T)
}

JNI Methods

pub extern "system" fn Java_org_icu4x_FixedDecimalJNI_new(env: JNIEnv, _class: JClass, v: jint) -> jlong {
    // Convert each input from JNI type to FFI type
    let v_ffi = jint_to_u32(v);

    // Call FFI method
    let res = ICU4XFixedDecimal_new(v_ffi);

    // Return the FFI method result
    res as jlong // return memory address of Box<T> to java
}

pub extern "system" fn Java_org_icu4x_FixedDecimalJNI_pow10(env: JNIEnv, _class: JClass, obj_addr: jlong, power: jint) {
    // Convert the jlong back into a pointer
    let obj = jlong_to_mut_ptr::<ICU4XFixedDecimal>(obj_addr);

    // Convert each input from JNI type to FFI type
    let power_ffi = jint_to_i16(power);

    // Call the FFI method
    ICU4XFixedDecimal_multiply_pow10(obj, power_ffi)
}

pub extern "system" fn Java_org_icu4x_FixedDecimalJNI_negate(env: JNIEnv, _class: JClass, obj_addr: jlong) {
    // Convert the jlong back into a pointer
    let obj_ptr = jlong_to_mut_ptr::<ICU4XFixedDecimal>(obj_addr);

    // Call the FFI method
    ICU4XFixedDecimal_negate(obj_ptr)
}

pub extern "system" fn Java_org_icu4x_FixedDecimalJNI_to_string(env: JNIEnv, _class: JClass, obj_addr: jlong, to_addr: jlong) {
    let obj_ptr = jlong_to_ptr::<ICU4XFixedDecimal>(obj_addr);
    let to_ptr = jlong_to_mut_ptr::<DiplomatWriteable>(to_addr);

    let res = ICU4XFixedDecimal_to_string(obj_ptr, to_ptr);

    if !res.is_ok {
        env.throw("some error message".to_string()).unwrap() // have to think about how to handle errors here.
    }
}

pub extern "system" fn Java_org_icu4x_FixedDecimalJNI_destroy(env: JNIEnv, _class: JClass, obj_addr: jlong) {
    // Convert the jlong back into a pointer
    let obj_ptr = jlong_to_mut_ptr::<ICU4XFixedDecimal>(obj_addr);

    // Call the FFI method
    ICU4XFixedDecimal_destroy(obj_ptr)
}

Low Level Java (FixedDecimalJNI.java)

class FixedDecimalJNI {
    public static native jlong new(int v);
    public static native pow10(long addr, int power);
    public static native negate(long addr);
    public static native toString(long addr, long to_addr);
    public static native destroy(long addr);

    static {
        System.loadLibrary("icu4x");
    }
}

High Level Java

class JniBase {
    protected long addr;

    public JniBase(int addr) {
        this.addr = addr
    }

    public getAddr() {
        return this.addr;
    }
}

class DiplomatWriteable extends JniBase {
    ....
}

class FixedDecimal extends JniBase {

    public FixedDecimal(int v) {
        super(FixedDecimalJNI.new(v));
    }

    public void pow10(int power) {
        FixedDecimalJNI.pow10(this.addr, power);
    }

    public void negate() {
        FixedDecimalJNI.negate(this.addr);
    }

    public void toString(DiplomatWriteable to) throws Exception {
        FixedDecimalJNI.toString(this.addr, to.getAddr())
    }

    @Override
    public void finalize() {
        FixedDecimalJNI.destroy(this.addr)
    }
}
sffc commented 2 years ago

Thank you; this is helpful!

Do you need to include jni.h anywhere, or is this handled by the JNI crate?

There isn't currently a place to plug in custom ABIs being exported from the dylib, but I'm fairly convinced by this use case. I think it shouldn't be too hard to add that. @Manishearth likely has more ideas on exactly where that fits in.

tomleavy commented 2 years ago

One advantage of the JNI crate over doing it in c with jni.h is that it takes care of the linkage for you. If you do it with C then you need to know all the paths for JDK etc to find the header. I'm pretty sure the JNI crate doesn't need to find anything because it just supplies the base JNI functionality all versions of Java support due to ABI stability, and when you load the dylib into a running Java application it is able to backfill all the external java symbols

tomleavy commented 2 years ago

There isn't currently a place to plug in custom ABIs being exported from the dylib, but I'm fairly convinced by this use case. I think it shouldn't be too hard to add that. @Manishearth likely has more ideas on exactly where that fits in.

@Manishearth what if for Java the output of the tool was a crate that when built produces the proper ABI for Java? So we would produce the Rust code in one folder complete with Cargo.toml, and then the Java code in another folder? Might simplify things but I'm not sure if that's the vision you had for this sort of setup.

Manishearth commented 2 years ago

That's totally fine! In general in Diplomat I wanted to avoid generating additional Rust code since it complicates the build chain, and I've foudn it easier to do most of the codegen in the target language beyond the glue stuff we do to make things C-compatible. If you think that's the best route forward for Java, though, I'm fine with that.

Is there no way to directly call C-ABI functions with C types from Java? I guess Java really does want to put folks through JNI.

sffc commented 2 years ago

Since Java is likely not the only language using JNI, maybe we could make this a feature of the core crate. If the "jni" feature is enabled, then Diplomat exports JNI functions alongside the regular C functions. Tools can then choose whether to wrap the C or JNI functions.

tomleavy commented 2 years ago

Is there no way to directly call C-ABI functions with C types from Java? I guess Java really does want to put folks through JNI.

Welcome to Java, it's the worst. Yeah, I think it's just that Java is a really old language and they designed the VM this way and never decided on a better strategy like how C# is doing things where it is directly FFI compatible

tomleavy commented 2 years ago

This pattern will come up again, other languages I've done this for have similar setups. NodeJS (if you aren't just using WASM in it) requires a similar pattern where a particular ABI is required. Same thing for PHP as well, and I think Ruby

Manishearth commented 2 years ago

Since Java is likely not the only language using JNI, maybe we could make this a feature of the core crate. If the "jni" feature is enabled, then Diplomat exports JNI functions alongside the regular C functions. Tools can then choose whether to wrap the C or JNI functions.

I don't think this would work since the core/macro crate doesn't have knowledge of the full type graph.

However we totally could have a "jni" backend and a "Java" backend with the java backend using the jni backend. This is basically what we do with C++/C headers anyway right now :smile: .

tomleavy commented 2 years ago

Since Java is likely not the only language using JNI, maybe we could make this a feature of the core crate. If the "jni" feature is enabled, then Diplomat exports JNI functions alongside the regular C functions. Tools can then choose whether to wrap the C or JNI functions.

I don't think this would work since the core/macro crate doesn't have knowledge of the full type graph.

However we totally could have a "jni" backend and a "Java" backend with the java backend using the jni backend. This is basically what we do with C++/C headers anyway right now 😄 .

That would make sense if you want Java + Kotlin

josh-hemphill commented 1 year ago

Has any consideration been given to creating Java bindings via the preview Panama API? https://openjdk.org/projects/panama/

Manishearth commented 1 year ago

Yeah we've been eyeing that with interest, we'd be pretty excited to have a Panama based Diplomat bindings generator

ExE-Boss commented 5 months ago

The Panama FFM API is now finalised and will be shipping in JDK 22 (see JEP 454).

Manishearth commented 5 months ago

Copying over discussion comments from #68 . They're somewhat old.

This issue is to discuss paths toward Java support in Diplomat.

We had a discussion today to brainstorm some options and ask questions toward this goal. Here are the notes from that discussion:

  • Q: Panama or JNI?
    • Panama is only available in JDK 16+ and only for C. So if you need something right away, JNI seems like the most practical option.
    • Diplomat talks over C ABI. Diplomat generates a C API that is wrapped in all the other languages.
    • Panama is like magic.
  • Q: Is there a technical difference between Panama and JNI?
    • There are some advances in Panama's models, but don't know for sure whether there is a real advantage over JNI.
  • Q: Any idea when Panama might come to Android?
    • Not aware that it is on their radar.
  • Q: GC? Finalizers or something better?
    • Finalizers are not great. But there's no good answer.
  • Q: How do we return structs by value? Do we need to malloc? For field access, if we own the struct, can we avoid calling over JNI?
    • Panama has nice memory segments. That is supported already.
  • Q: How should we do error handling?
    • Exceptions are expensive. So if you plan to throw them all the time, you should try to avoid them. But if they are only occasional, maybe exceptions are okay.
    • Or we could use Java Optional. That's like what we are doing in C++.
  • Q: Does anyone use JNA?
    • That's probably replaced in favor of Panama. It gives you the ergonomics without the penalty.
  • Q: How about WebAssembly? Is there a future there for WASM/Java interop?
    • That sounds cool, but not aware of current efforts to make that work. WASM on the Server sounds cool and would like to learn more about it.
    • The WebAssembly folks have been pushing for WASM being more of an industry standard bytecode format.
  • Q: How about GraalVM? It supports both WASM and Java.
    • Not aware of work to adopt GraalVM.
jcrist1 commented 4 months ago

Hey @Manishearth @sffc, we met at the Rust meetup last wednesday and discussed this topic. A little while back I made a simple PoC of tool that tries to do a much more restricted subset of diplomat does for Kotlin: jni_cli. In particular because of my worries about deadlocks and thread safety, I was uncomfortable with allowing jni methods that operated on more than one Rust struct, so methods only allowed operations on one rust struct (which was the self type) or some basic number types, arrays and strings. I'd be open to taking a stab at this, but I think it would be helpful to get a minimally functional version out first and I'd like to do it in Kotlin first whose type system is closer to Scala's (my background) which allows interop with Java but is not as fringe as Scala.

Going over some of the questions

  • Q: Panama or JNI?
    • Panama is only available in JDK 16+ and only for C. So if you need something right away, JNI seems like the most practical option.
    • Diplomat talks over C ABI. Diplomat generates a C API that is wrapped in all the other languages.
    • Panama is like magic.

I'm open to Panama, but I'd rather get started with jni because the jni_rs crate makes interrop relatively straightforward. For the initial work I'd also like to bring in the jni_fn crate which just helps with a little of the namespacing boilerplate

  • Q: Is there a technical difference between Panama and JNI?

    • There are some advances in Panama's models, but don't know for sure whether there is a real advantage over JNI.
  • Q: Any idea when Panama might come to Android?

    • Not aware that it is on their radar.
  • Q: GC? Finalizers or something better?

    • Finalizers are not great. But there's no good answer.

for ownership I leaked the box containing the rust struct (actually a <Box<Mutex<T>> which I'd never used before but seemed right, as the GC should mean I don't need to pay the price of an Arc) and pass the pointer back to a java object where there's a finalizer that's called by the GC, which unsafe loads the pointer as that type and drops it. @sffc you mentioned something like scopes could be used. But I'd like to start with the finalizer first, cause it's conceptually easier.

  • Q: How do we return structs by value? Do we need to malloc? For field access, if we own the struct, can we avoid calling over JNI?

    • Panama has nice memory segments. That is supported already.

I don't have a good answer here. I feel like it's Java so (almost) everything is an object anyway. In particular a java byte array is an object, and as far as I can see there's no way to get an actual &[u8] from it in jni_rs. I need to allocate a dedicated Vec anyway. I would be tempted to forbid taking a diplomatted Rust type by value in a method body, because then we'd have to convert the associate pointer to null. And I will not be responsible for any more null pointer errors. .

  • Q: How should we do error handling?
    • Exceptions are expensive. So if you plan to throw them all the time, you should try to avoid them. But if they are only occasional, maybe exceptions are okay.
    • Or we could use Java Optional. That's like what we are doing in C++.

Kotlin has an Either class (like scala which is my background). I'd be very tempted to use this to wrap a result type and introduce a dedicated throwable (probably custom derive for an error type). Then the caller can choose to throw, which is often more ergonomic.

  • Q: Does anyone use JNA?

    • That's probably replaced in favor of Panama. It gives you the ergonomics without the penalty.
  • Q: How about WebAssembly? Is there a future there for WASM/Java interop?

    • That sounds cool, but not aware of current efforts to make that work. WASM on the Server sounds cool and would like to learn more about it.
    • The WebAssembly folks have been pushing for WASM being more of an industry standard bytecode format.

My personal use case and motivation is for more incorporating ML stuff into JavaLandâ„¢, so I'd do it just native. (Also I have no idea how to get started with JVM and WASM). But I do think WASM is super cool, and there could definitely be cool interop uses

  • Q: How about GraalVM? It supports both WASM and Java.

    • Not aware of work to adopt GraalVM.

I found this link on jni and graalvm. I don't have a working GraalVM setup, but it seems like it should work. And seeing as performance is my main motivation (but also Rust infestation 😛 ), it makes sense to look at. But I wouldn't propose that for my minimal version.

One big open question I have is

Q: how to deal with thread safety?

@sffc said we should probably use JVM primitives, and that sounds like a good idea. I would wrap anything that has &mut access behind a mutex or RwLock. My biggest worry is mutable access combined with multiple converted Rust types, each behind some kind of lock introducing deadlocks. The only thing I can think that would help is if we can have a deterministic lock order independent of the call order (can I sort by pointer 🤪?). I'd be very tempted for each function to only allow one convertible rust struct that allows mutable access (and thus is hidden behind a mutex).

Q: how do we deal with packaging?

I looked at just requiring the library to be loaded, but also packaged the library in a JAR and loading it from there. For now I would suggest

Proposed as a PoC

Manishearth commented 4 months ago

Sounds great!

A lot of those questions are somewhat outdated now (in a good way! we've crystallized on good approaches). I'll try to respond to various ones.

Thanks for looking into this!

I'm open to Panama, but I'd rather get started with jni because the jni_rs crate makes interrop relatively straightforward. For the initial work I'd also like to bring in the jni_fn crate which just helps with a little of the namespacing boilerplate

That's fine by me; however note that diplomat-tool doesn't generate any Rust code. diplomat (the macro) does, and it is supposed to be language agnostic. However we're okay with having a diplomat_runtime module for jni specific utilities if needed.

Not fully opposed to diplomat-tool also generating Rust code, but it would have to generate an additional crate that you have to hook together manually. Could be annoying.

The Diplomat design is that Diplomat generates one low level C API and all other libraries can call it, so typically the interop work is in the other language.

So yeah, fine with JNI, but if you're pulling in jni_rs you might be attempting to perform interop at the wrong level. JNA does seem more suited for this.

Another way to do this would be for the diplomat macro could gain a JNI mode where it generates additional JNI-specific bindings. In general we try and keep the macro simple but this is a possible route if we really need to. I'm also just wary of the maintenance burden: with diplomat-tool backends they're super self contained.

for ownership I leaked the box containing the rust struct (actually a <Box<Mutex> which I'd never used before but seemed right, as the GC should mean I don't need to pay the price of an Arc) and pass the pointer back to a java object where there's a finalizer that's called by the GC, which unsafe loads the pointer as that type and drops it. @sffc you mentioned something like scopes could be used. But I'd like to start with the finalizer first, cause it's conceptually easier.

Yeah at this point Diplomat just uses finalizers in all managed languages. It works pretty well.

I don't have a good answer here. I feel like it's Java so (almost) everything is an object anyway. In particular a java byte array is an object, and as far as I can see there's no way to get an actual &[u8] from it in jni_rs. I need to allocate a dedicated Vec anyway.

Yeah the question was more about the low level nitty gritties: if you have a C API that returns, say, struct {int x; int y; }, by what mechanism can Java destructure it? It appears to me that JNA and Panama both have ways of doing this. As previously mentioned, JNI requires you to tweak the C side so it doesn't quite work.

Regarding slices, JS has the same situation and it also just copies slices into a new buffer on the JS side. It's fine.

I would be tempted to forbid taking a diplomatted Rust type by value in a method body, because then we'd have to convert the associate pointer to null. And I will not be responsible for any more null pointer errors.

I'd recommend looking more into Diplomat's model: Diplomat already forbids this. Diplomat has two types of structs, "structs" (which are just aggregates of fields, always passed by value, always converted over FFI), and "opaques" (these are an allocated pointer, they can be retuend as Boxes and references and otherwise can only exist in Diplomat APIs behind a reference).

Your main problem is with actual structs: for a first pass you could declare that you don't support structs at all, and only opaques. You'll have to java-disable large swathes of the testsuite, but it would work.

Kotlin has an Either class (like scala which is my background). I'd be very tempted to use this to wrap a result type and introduce a dedicated throwable (probably custom derive for an error type). Then the caller can choose to throw, which is often more ergonomic.

Sounds good.

Q: how to deal with thread safety?

@sffc said we should probably use JVM primitives, and that sounds like a good idea. I would wrap anything that has &mut access behind a mutex or RwLock. My biggest worry is mutable access combined with multiple converted Rust types, each behind some kind of lock introducing deadlocks. The only thing I can think that would help is if we can have a deterministic lock order independent of the call order (can I sort by pointer 🤪?). I'd be very tempted for each function to only allow one convertible rust struct that allows mutable access (and thus is hidden behind a mutex).

Diplomat's model doesn't give you control over this: if you're writing a backend you shouldn't be dealing with the Rust code directly, you should be generating it based on HIR.

We've got an open issue for some of the mutation issues: https://github.com/rust-diplomat/diplomat/issues/225 . That's a relatively straightforward validation pass to implement, we just haven't done it yet.

However so far all the non-C++ languages we've been working with do not share state between threads: Dart isolates and JS workers both work by message-passing of serializable data.

If you are generating a Rust crate as a part of your Java backend, then you should feel free to require : Send + Sync or whatever.

Another route would be to have the diplomat macro add these if you enable a certain feature on the diplomat crate. I'm open to that but as previously mentioned we'd like to keep hte diplomat macro simple.

You could introduce some form of diplomat attribute that lets you control if a method is synchronized.

In general I'd suggest not worrying about this for now until you have gotten something you can play around with; and have a better understanding of the Diplomat model. There are multiple paths for us once we get there.

Summing up

  • use jni-rs

If you're doing this you either need to:

This translates to "forbid Diplomat structs in Java". That's fine for a first pass, we would eventually like struct support and that may need something like JNA or Panama.

  • any rust struct that is called mutably has to have all of every method that calls it's corresponding object be synchronised or is called behind a dedicated Mutex

I'd say to ignore this for now and we can figure it out later.

  • library has to be loaded manually for now

Yeah that's fine for now. The other backends

One final tip: Make sure to look at backends that use the diplomat HIR, not the diplomat AST, we're phasing out the diplomat AST as anything other than for driving the diplomat macro. Currently the backends that do this are dart, c2, and cpp2, we're actively working on js2.

ExE-Boss commented 4 months ago

for ownership I leaked the box containing the rust struct (actually a <Box<Mutex<T>> which I'd never used before but seemed right, as the GC should mean I don't need to pay the price of an Arc) and pass the pointer back to a java object where there's a finalizer that's called by the GC, which unsafe loads the pointer as that type and drops it.

Finalizers are deprecated for removal since Java 9.

The correct way to handle deallocation is to use Panama’s Arena API.

In particular a java byte array is an object, and as far as I can see there's no way to get an actual &[u8] from it in jni_rs.

That’s where Panama’s MemorySegments come in.

jcrist1 commented 4 months ago

for ownership I leaked the box containing the rust struct (actually a <Box<Mutex<T>> which I'd never used before but seemed right, as the GC should mean I don't need to pay the price of an Arc) and pass the pointer back to a java object where there's a finalizer that's called by the GC, which unsafe loads the pointer as that type and drops it.

Finalizers are deprecated for removal since Java 9.

The correct way to handle deallocation is to use Panama’s Arena API.

In particular a java byte array is an object, and as far as I can see there's no way to get an actual &[u8] from it in jni_rs.

That’s where Panama’s MemorySegments come in.

I've started doing this with JNA, which seems to be much more ergonomic than JNI, and in particular seems easier to pass by value. I'll do the minimal approach first in order to understand how diplomat works, and then I'm happy to explore Panama. But for now, learning that and diplomat is a bit too much new tech all at once.

By Finalizers I mean the Cleaner api from Java 9 onwards.

   private class TokenizerCleaner(val handle: Long, val lib: TokLib) : Runnable {
        override fun run() {
            lib.Tokenizer_destroy(handle)
        }
    }

    val handle = lib.Tokenizer_create(pointerLong, bytes.size)
    val model = Tokenizer()
    model.handle = handle
    CLEANER.register(model, TokenizerCleaner(model.handle, lib));
    return model

Where CLEANER is a top level shared cleaner for the whole library

Manishearth commented 3 months ago

JNA sounds good!

jcrist1 commented 3 months ago

@Manishearth I've got an initial PoC that only supports opaque types (in self and returned by value) and primitive types. It's already quite big so I thought I would solicit feedback first before building more functionality. Don't know if you prefer that it be a draft until all functionality is implemented or if you'd be okay slowly merging functionality.

Manishearth commented 3 months ago

Looking good, thanks!

I'm fine with landing piecemeal but I'd like there to be some tests to review.

Manishearth commented 3 months ago

If you want to try and run feature_tests you could even hardcode a bunch of type names in your backend that you support and skip codegenning the rest. Diplomat disable attrs technically should work though.

brainstorm commented 2 months ago

Following this issue with interest since I'm currently putting together Rust bindings for a small Java library using Panama (WIP non public yet), and I found the cbindgen approach a bit painful w.r.t type mapping (i.e Result) but diplomat seems to have a more sensible approach to FFI types across languages.

I started playing with Panama with this repo as an initial template which was really handy and works out of the box. That eased the "new tech learning curve" mentioned in this issue... and by the way, a jextract guide was released fairly recently which covers a lot of documentation ground for FFI and cross-language types with Panama to dispel the low level "magic" bits, it's a pretty good read.

Diplomat also caught my interest because I saw that uniffi-rs from Mozilla does not seem to support regular desktop Java whereas it does support Kotlin (similar to the PoC put together by @jcrist1 in PR #461)?

But I guess that this ship has sailed already and efforts are being put towards JNA and I totally understand the reasons/compromise of the main authors here, kudos everyone, just wanted to chime in with my small 2 cents ;)

Manishearth commented 2 months ago

We'd be happy to get a Panama based backend for Java. It's fine to have both!

jcrist1 commented 2 months ago

But I guess that this ship has sailed already and efforts are being put towards JNA and I totally understand the reasons/compromise of the main authors here, kudos everyone, just wanted to chime in with my small 2 cents ;)

Yeah I don't think there's any conflict between the two, and it looks like there're some nice features like memory sessions. One reason I didn't want to use it was it was still in preview mode for JDK 19 which we use at work. I think I'd have a harder time getting native bindings through there if it meant upgrading to JDK 22 as well.

brainstorm commented 2 months ago

But I guess that this ship has sailed already and efforts are being put towards JNA and I totally understand the reasons/compromise of the main authors here, kudos everyone, just wanted to chime in with my small 2 cents ;)

Yeah I don't think there's any conflict between the two, and it looks like there're some nice features like memory sessions. One reason I didn't want to use it was it was still in preview mode for JDK 19 which we use at work. I think I'd have a harder time getting native bindings through there if it meant upgrading to JDK 22 as well.

Totally agree with the choices and reasons, re-reading my text I see it came out wrong! ;)

jcrist1 commented 2 months ago

But I guess that this ship has sailed already and efforts are being put towards JNA and I totally understand the reasons/compromise of the main authors here, kudos everyone, just wanted to chime in with my small 2 cents ;)

Yeah I don't think there's any conflict between the two, and it looks like there're some nice features like memory sessions. One reason I didn't want to use it was it was still in preview mode for JDK 19 which we use at work. I think I'd have a harder time getting native bindings through there if it meant upgrading to JDK 22 as well.

Totally agree with the choices and reasons, re-reading my text I see it might have came out wrong! ;)

😅 just to be clear I would support an alternative backend as well. In fact I was thinking of trying to implement it once I'm done with the first PoC. Now that I understand Diplomat's HIR I don't think it would be that much additional work to understand Panama's FFI, especially with your linked example.

jcrist1 commented 1 month ago

@brainstorm I'm starting to look at Panama, and it looks like it really leans heavily on jextract to do mitigate the boilerplate. However when I tried to run it on the generate C2 backend it ignored the opaque types. So from my understanding it would be rather difficult to integrate jextract, and we would essentially need to duplicate a lot of the functionality in a Panama backend. This by itself doesn't seem like a blocker, but just makes it more complex. In the meantime it seems to me that if you don't need opaque types you can already start with the C2 backend and run jextract on that.

jcrist1 commented 1 month ago

I spent some time looking at how we might do something with opaque types in Panama. As is, Panama is not nearly as ergonomic as JNA yet for actually interfacing with native methods and types. E.g. a function call looks something like the following in:

    class Tokenizer_new {
        val DESC: FunctionDescriptor = FunctionDescriptor.of(
            DiplomatResultPointerStr.layout,
            Tokenizer_h.C_POINTER,
            Tokenizer_h.C_LONG
        );

        val ADDR: MemorySegment = Tokenizer_h.findOrThrow("Tokenizer_new");

        val HANDLE: MethodHandle = Linker.nativeLinker().downcallHandle(ADDR, DESC);

        fun Tokenizer_new_native(
            allocator: SegmentAllocator,
            bytes_data: MemorySegment,
            bytes_len: Long
        ) {
            HANDLE.invokeExact(allocator, bytes_data, bytes_len);
        }
    }

compared to

    fun Tokenizer_new(bytes: Slice): ResultPointerSlice

And actually I'm skipping a lot of the generated code in jextract. It looks more like this

    private static class Tokenizer_new {
        public static final FunctionDescriptor DESC = FunctionDescriptor.of(
            diplomat_result_box_Tokenizer_str_ref8.layout(),
            Tokenizer_h.C_POINTER,
            Tokenizer_h.C_LONG
        );

        public static final MemorySegment ADDR = Tokenizer_h.findOrThrow("Tokenizer_new");

        public static final MethodHandle HANDLE = Linker.nativeLinker().downcallHandle(ADDR, DESC);
    }

    public static FunctionDescriptor Tokenizer_new$descriptor() {
        return Tokenizer_new.DESC;
    }

    public static MethodHandle Tokenizer_new$handle() {
        return Tokenizer_new.HANDLE;
    }

    public static MemorySegment Tokenizer_new$address() {
        return Tokenizer_new.ADDR;
    }

    public static MemorySegment Tokenizer_new(SegmentAllocator allocator, MemorySegment bytes_data, long bytes_len) {
        var mh$ = Tokenizer_new.HANDLE;
        try {
            if (TRACE_DOWNCALLS) {
                traceDowncall("Tokenizer_new", allocator, bytes_data, bytes_len);
            }
            return (MemorySegment)mh$.invokeExact(allocator, bytes_data, bytes_len);
        } catch (Throwable ex$) {
           throw new AssertionError("should not reach here", ex$);
        }
    }

It seems like there would be two possible ways forward

  1. use jextract to generate the glue code from generated code for the C backend (would require bundling jextract with diplomat somehow), i.e. defining classes to access native types and methods. This seems like it would make diplomat more complicated to build and package, but jextract should be mature and robust, which would probably be less buggy.
  2. reproduce a substantial part of what jextract does for the code generation. This seems like the more natural but will require more effort, and will probably miss some edge cases that are already covered by jextract. But I think that we could get away with doing less than what jextract does, as we would anyway try to hide most of the native access behind a more ergonomic API.

So another question is does panama seem worth it? From an ergonomics perspective I'd say no but that's really only on the developer of the backend. The poor ergonomics should be hidden by the generated API of the backend. This benchmark, also suggests a significant potential speed benefit which pushes me to yes.

@Manishearth do you have any thoughts on trying to integrate with jextract?

Manishearth commented 1 month ago

I think it's fine to try either depending on who works on it. The C backend is already used by the C++ backend and I'm actually in the process of making that integration cleaner, which may help for the jextract stuff.

Up to whoever is writing the code, I'd say. Ultimately complex codegen isn't a huge deal because its usually mostly templatable boilerplate.