Modular ABI for Rust

No, that was great!

:smiley: :+1:

1 Like

Let's see... here's my summary of @daboross's post:

  1. Writing a stable ABI for Rust is possible, here's my proposal for an interface.
  2. abi_stable uses #[repr(C)]. This is a step in the right direction, but every type you use has to pass through their mechanisms. This limits the type of types you can send. You also have to annotate every type you use, which is annoying.
  3. A modular ABI proposal could solve this: an "ABI" crate is a proc-macro-like crate that determines exactly how each byte of a data-structure should be laid out in memory. This is done by providing macros for each Rust structure.
  4. To use the ABI, you'd tell the compiler to make specific data ABI-compliant. At compile time, the compiler uses the ABI crate to determine the layout of each byte of data.
  5. All the data that is moved across ABI-boundaries would have to be copied, but that's a given.
  6. It wouldn't be easy, but it is possible. One thing that hasn't been determined is what pointers / memory ownership and the interface itself would look like.
  7. To determine the layout, a minimum API [typo, ABI?] would have to be found. How could the ABI deal with niche optimizations and field ordering [for field ordering, see this resource I posted]; how should functions be defined to work on the data?
  8. What are your thoughts? I think that a modular crate-based ABI would be a good idea.
  9. How feasible is this?
2 Likes

A question that should be asked is; how likely is a Rust specific ABI to be adopted?

My opinion is that "anything" will get framed in a negative light.

1 Like

I'm not sure about that. I've seen it brought up multiple times that people wished Rust could have a stable ABI. It is the people who work on the compiler that are, understandably, hesitant. Also there are people who would love to have Rust integrate with other ABI's such as swift.

I think from a "what would it bring to the ecosystem" perspective, it is something to be desired if there is a way to implement it in a convenient and not horribly convoluted manner.

Still, you never know, you could be right. That's why I think it is good to start discussion on whether or not it could be feasible. If it could be, then we need to see what people would think about it, create an RFC, etc. .

1 Like

That "anything" was intended for this thread, hence the horizontal rule. The actual proposal will have a better-worded request for feedback :slight_smile:.

1 Like

Oh, I missed the quotes. I misunderstood what you were referring to. :slight_smile:

1 Like

@zicklag, what parts of @daboross's post do you think we should include in the draft that haven't been included yet? Do you think the whole thing should just be reworded and appended to the current draft?

Yeah, probably.

I'll start working on it.

1 Like

Good job, BTW, combining and wording all of the points so far.

1 Like

Thanks :grinning:, here it is:


Proposing a stable modularizable ABI interface for Rust

Based on the points from the discussion here.

Introduction

Rust is a powerful systems programming with strong memory guarantees. Rust allows for concise expression at a high-level, while still producing fast low-level code. However, Rust does not guarantee the calling conventions and layout of structures in memory, which makes it difficult to write external applications that interface with Rust; Rust lacks a standardized ABI. Standardizing Rust's ABI has been brought up before, but has usually gone nowhere due to the difficulty of the task. In this post, we outline the benefits and stumbling-blocks of a stable ABI, as well as suggest a semi-novel technique as to how such an ABI could be implemented.

Benefits

There are many benefits an standardized ABI would bring to Rust. A stable ABI enables dynamic linking between Rust crates, which would allow for Rust programs to support dynamically loaded plugins (a feature common in C/C++). Dynamic linking would result in shorter compile-times and lower disk-space use for projects, as multiple projects could link to the same dylib. For example, imagine having multiple CLIs all link to the same core library crate.

Although this use case is already rather well covered by abi-stable-crates, there are still many more benefits beyond linking crates dynamically. A stable ABI would allow Rust libraries to be loaded by other languages (such as Swift), and would allow Rust to interop with libraries defined in other programming languages. Non-Rust crates could be integrated with Rust toolchains; providing an ABI would also allow outside code to rely on Rust for performance-intensive tasks. Cross-language compatibility would increase the diversity of Rust's package ecosystem.

Quote: Imho one of the biggest mistakes C++ ever made was not stabilizing its abi; swift just stabilized theirs and is already reaping the benefits, swift system libraries, the swift runtime, swift UI libraries, all dynamically linked and backwards abi compatible.

Stabilizing the Rust's ABI would allow for cross language interop and dynamic linking. " extern "C" as the lowest common denominator is too low for Rust" (Quote).

Recently, the Fuschia OS Team at Google decided to ban Rust's for use in Fuschia microkernel, citing C as an alternative because of its stable ABI. Not providing a stable ABI ultimately hurts Rust when getting down to the metal. Given similar languages like C and Swift have a stable ABI, I see no reason why a stable ABI would not be implementable for Rust. As discussed here, some ABIs/FFIs have already been written using proc macro and the like.

Potential Issues

However, a stable ABI is not all peaches and roses. Having to standardize the memory layout of data can limit the number of optimizations the compiler can perform.There has been a lot of work on optimizing laying out fields in structs in reliable and ABI-compliant ways. There are a large class of optimizations that can be done in compliance with an ABI; since an ABI solidifies the layout of data, more reliable bit-twiddling and the like can occur.

While discussing the matter, a point was brought up that the ABI could be modularized. A modularized ABI would be optional while compiling. This modular ABI could be published as a versioned crate. If the ABI ever needs a backward-compatibility breaking change, the change could be made within Semver. Alternatively, a new ABI-compliant compiler backend could be developed, or the current compiler backend could be extended to support an ABI feature flag that would toggle ABI compliant builds.

However:

Standardizing the ABI would take a lot of work. A poorly designed ABI is worse than not having an ABI at all. And as we all know, the right solution is often the hardest one.

Another downside is that allowing ABI crates might not stabilize Rust's ABI, there'd just be ABI fragmentation. Although this is a genuine concern, a 'master' ABI crate with Rust's 'official' ABI could be developed. This would standardize Rust's ABI, while still allowing other crates with other ABI's to be written for interop with other ABIs, like Swift's. Additionally, because modular ABIs are opt-in, ABIs would be used only where explicitly necessary.

Implementation Proposal

So, what might this modularized ABI look like? Roughly speaking, an ABI would be defined by a series of macros in a crate which specify the layout and calling conventions of data structures according to that ABI. During compilation, while determining the layout of the data, the layout information provided by the ABI macros would be used. The end-goal would be for something like #[repr(RustABI)] or $ cargo build --release --abi rust-abi to be plausible.

Let's get into more detail. Right now, the closest analogue to a stable Rust ABI is the abi_stable crate. abi_stable uses #[repr(C)] to create ABI-compatible data structures. This is a step in the right direction, but every ABI-complaint type has to pass through abi_stable's mechanisms. These data-structures are also more expressively limited. For example, every abi_stable ABI struct has to contain ABI compatible fields - and some Rust types, like Result, aren't compatible at all.

A modular ABI could solve this issue. An "ABI" Rust crate is a proc-macro-like crate that determines exactly how each byte of a data-structure should be laid out in memory. To do this, the "ABI" crate should provide a macro each standard Rust data-structure (struct, enum, tuple, etc.) When a data-structure is marked as ABI-compliant (either through a #[repr(ABI)] proc macro or compiler flag), the compiler calls out the "ABI" crate which recursively lays out said data-structure in an ABI-compliant manner.

There are a few issues that still need to be addressed. How do pointers and memory management work across ABI boundaries? We propose that when data is transferred across an ABI boundary, it should be either copied or moved. Once some data has moved across an ABI boundary, the only way to reference that data is to use the copy, or have the program the data was transferred to transfer it back. This copy/move borrowing technique is merely a suggestion, as there is probably a better way to do it (semi-related post).

To determine the layout of data for Rust's own ABI, a minimum API would have to be found. Rust currently provides many niche optimizations and field ordering techniques to increase performance - a stable ABI might interrupt or prevent some of this. However, as mentioned in the Potential Issues section, there are ways to work around this. Different calling conventions could be supported through a proxy assemble stub or the like, but the devil's always in the details.

Multiple ABI crates would be able to be defined—for example, there could be a abi_swift crate for interop with Swift's ABI—Rust itself could have it's own ABI in an ABI crate titled abi_rust or the like.

Closing Thoughts

We hope that this outline of a very rough specification will provide a launching point for the ultimate development of a stable modularized ABI interface for Rust. Such an ABI would expand the number of applications that Rust could be used for. A stable ABI would standardize dynamic linking between Rust crates, minimize the amount of space-time used during compilation, allow for cross-compatibility between Rust and other programming languages, and increase the plausibility of Rust as a kernel-level language. If you have any questions, comments, concerns, feedback, or other ideas, please don't hesitate to share them. Something like this takes hard work and good communication, so we're open for feedback.


How's this? I think it's about done and just needs someone else to go over it and edit for clarity, meaning, etc.

5 Likes

The potential to have different ABIs (e.g., abi_rust, abi_swift) that are used concurrently in the same compilation would permit Rust programs to act as the "glue" between external components that use incompatible ABIs.

4 Likes

Great point! I'll add that now.

I think that the above draft is about as good as it's going to get. I've posted it on the internals forum, the conversation should be continued from there. Thanks for all the great ideas y'all've provided.

6 Likes

Going to make a post replying to the things replying to my earlier post! Putting it here since I think it mostly responds to posts in this thread. The proposal on internals stands well on its own, and I don't think it needs any of the corrections for interpreting my idea :slight_smile:

With that said, I would like to challenge some points here.

I think I must have made this part unclear!

I'd propose that it is moved at runtime. In other words, all regular rust structures are still laid out in the compiler's regular, unstable ABI, and are only translated into the stable, modular ABI directly before being sent over the FFI boundary.

This would allow all computations inside rust to continue benefiting from compiler optimizations, and data could be translated into the stable ABI before being transferred across an FFI boundary.

I think there's still some intricacies with allocators and how we know what data is actually being moved, here. We might be able to special case some things like Box/Vec, but for an arbitrary structure containing a *const T pointer, how do we know what should be transfered?

If we have custom structures that need to be translated to the stable ABI, and some of them are behind allocations and are just pointed to by *const Struct pointers, I don't know how we're going to in general make that correct.

I think your proposal of having the ABI simply change the layout for all structures in the rust program would make this a lot easier - if we did that, then there wouldn't have to be any translation, and the things allocated in memory would already be in the right layout. This difficulty would only be if we had an explicit translation layer between regular rust structures in the unstable compiler ABI and things laid out according to the modular ABI crate.

My point here wasn't that doing the optimizations themselves is hard. Rather, I think the hard part for us would be making a compiler API which can encapsulate those optimizations.

For example, here's a suboptimal, but simple, compiler API for struct layout which doesn't handle this:

pub struct EnumInfo {
    pub variants: Vec<(String, EnumVariantInfo)>,
}
pub struct EnumVariantInfo {
    pub fields: Vec<(String, TypeLayout)>,
}
pub struct EnumLayout {
    pub size: usize,
    pub alignment: usize,
    pub discriminant_size_bytes: usize,
    pub discriminant_offset: usize,
    pub variant_layouts: HashMap<String, EnumVariantLayout>,
}
pub struct EnumVariantLayout {
    /// field name => offset in enum
    pub fields: HashMap<String, usize>,
}
pub trait EnumLayoutGenerator {
    fn layout(s: &EnumInfo) -> EnumLayout;
}

A real API would probably use methods and builders rather than public fields, and this has other problems, but that's besides the point.

While this simple API works, it doesn't represent the full range of things which might happen. For instance, the current rust compiler does niche layout optimizations where the discriminant doesn't actually exist as byte in the type. Instead, it's represented by some non-null pointer being null, or a bool being a non-0,1 value, or another niche. This API does not handle that.

We could design then instead design an API which has special cases for all current optimizations the compiler can do, like niche optimizations for enum discriminants. But is it possible to make a good API which would also handle future optimizations, or at least be extensible to ones which someone might want to add?

It probably is, but I wanted to point out that designing such an API is a hard problem, one which a proposal for modular ABIs should probably solve.

If we go the route of opt-in struct-by-struct ABIs, is there any difference between it and the stable-abi crate? I mean, as I understand it that's what that crate offers today: an opt-in stable ABI for particular structs.

But I don't think doing a "full crate" would be sufficient, either. If we want to be able to translate arbitrary rust structures across FFI boundaries, we need all rust structures to be represented by a stable ABI.

The two ways I can see doing that are either A) having compilation-wide modifications (including recompiling std with the stable ABI) or B) having a functionality to translate arbitrary non-ABI-stable structures into a stable ABI.

I did mean API here - we need the API the compiler offers to crates which create stable ABIs to allow them to perform optimizations. Field reordering is definitely possible, but niche optimizations and bitpacking might be harder...

The rest of the summary post looks good. :+1:


Looking back at my post & your proposal, I think yours is overall more sound. It doesn't suffer from the same problems of needing to inspect heap memory somehow to translate structures behind pointers, though many of the other concerns would apply to both.

4 Likes

I agree that changing the memory representation of everything should be the way to go to truly make it a stable ABI, and not an FFI "portal" I guess.

If we don't store everything in memory with that ABI, it introduces runtime performance hits for anything going over the FFI, which would probably eliminate some of the advantages for certain use-cases such as zero-cost dynamically loaded plugins.

It would require re-compiling the standard library, but that's not a huge deal and you could automate that with a cargo sub-command or something. We'd probably end up with a cargo subcommand for this at least earlier on anyway.

1 Like

Thanks for taking the time to make clarifications! Some of these points are really good, I might rejiggle a few things in the proposal to better match your explanations.

I was thinking of this, but there are a few issues that I saw, the largest one being having to re-twiddle structures during runtime. This might not be a problem for small structs, but for larger, more complex types, the conversion overhead might be quite large. Laying out all structures in an ABI compliant manner before time is results in O(1) transfer times, whereas converting between is O(N) where N is the number of referenced sub-structures.

I was thinking that Vec and Box might need to be special cases, but when you think about it, just about anything can be a special case. An anecdote: A while back, I wrote a NaN-tagging implementation. I won't go into much detail here, but NaN-tags are special pointers to data structures. The problem with NaN-tags is that they aren't explicitly pointers, they're pointers only within certain contexts. When a NaN-tag is made, the data it points to is basically leaked, meaning no pointers to the data exist. To retrieve the data inside a NaN-tag, the original pointer needs to be reconstructed from the bits contained in the NaN-tag.

If you tagged some data in Program A, passed the tag over an FFI boundary to Program B, then tried to reconstitute the data from the tag from program B, chances are something would go wrong. If data was only converted at runtime, the system risks Program B trying to access Non-ABI compliant data. I'm not sure about the exact intricacies of FFIs and ABIs, so correct me if I'm wrong, but Program B might be not be able to even access the data in Program A if they're in different processes.

It seems like due to the unsafe nature of Rust code, guaranteeing that all memory associated with a data structure is transferred over the boundary is hard. This in part can be fixed by using a compile-time ABI, but memory still might be lost if managed in an unsafe manner. An ABI would expose a larger surface area for things to go wrong. This is not to say that it's impossible, it just might take more work. I guess what I'm trying to say is that unless special cases are written, only 'safe' data structures can be transferred across FFI boundaries.

I see. I think the solution would be to develop a generic data-layerouter. Here's what I mean. In Rust, it's possible to define custom layouts for pointers through a very generalized Layout interface. I'm suggesting something similar be done for the ABI.

All a layout really does is indicate which bits belong to what, along with some metadata. Therefore, if a general data structure that can represent these mappings exist, an ABI can be implemented. Take this simple example.

/// A `Layout` is a set of bytes.
/// Each `Layout` contains a number of fields, which may contain a sub-layout.
pub trait Layout {
    fn size(&self) -> usize;
    fn fields(&self) -> Vec<Field>;
}

/// A `Field` represents a series of bytes in a `Layout` possibly containing a sub-`Layout`.
type Field = (usize, Option<Box<dyn Layout>>);

Then, to define an enum:

pub struct Enum {
    size: usize,
    discriminant: (usize, Discriminant),
    variants: Vec<(usize, Variant)>,
}

pub struct Variant {
    size: usize,
    contents: Option<Box<dyn Layout>>,
}

pub struct Discriminant {
    size: usize,
}

Then we can implement Layout for the Enum:

impl Layout for Enum {
    fn size(&self) -> usize { self.size }

    fn fields(&self) -> Vec<Fields> {
        // ... snip        
        // use `self.discriminant` and `self.variants` to construct a `Vec<Option<Fields>>`.
    }
}

impl Layout for Variant {
   // ... snip
   // `fields()` would just wrap `self.contents` in a `Vec<...>`.
}

impl Layout for Discriminant {
    // ... snip
    fn fields(&self) -> Vec<Fields> { vec![] }
}

Then, at compile time, macros would expand enum definitions into Layouts. The compiler would then use the layouts to construct the actual data structures. Note that this is just an example - a real implementation would require a bit more involvement. Needless to say, option types or enums could be used to represent niche optimizations in Enum itself. When the compiler tries to build a layout described by Enum through the Layout trait, the optimizations can be applied, as a Layout simply represents a series of bytes and what they mean. This toy model doesn't show the full range of behaviours that I'm trying to describe, but I hope you see what I'm saying.

We only need to translate structures that go across FFI boundaries, though all Rust structures should be representable. At compile time, ABI compliant data types are translated. Non-ABI compliant datatypes can keep the Rust representation, they just can't be transferred across FFI boundaries. (Of course there's the unsafe caveat I mentioned above.)

B is the way to go, but it shouldn't be done at runtime.

Thanks :heart:

2 Likes

Disclaimer: I hope I didn't miss anything important, I beg forgiveness if some questions are already answered.

ABI != Memory Layout

I've seen a lot of emphasis on memory layout in this thread. The memory layout of types is part of ABI, indeed, but there seems to be quite a few things missing:

  • I have not seen any mention of the memory layout of v-tables.
  • I have not seen any mention of the memory layout of fat-pointers.
  • I have not seen any mention of calling conventions.
  • I have not seen any mention of exception handling.

For reference, here is a complete ABI for C++: The Itanium ABI.

Better is the enemy of Good

I found that the thread started in a reasonable fashion: allowing ABI evolution, whilst offering ABI guarantees. It then took a turn for the unexpected by jumping from the ability to Select an ABI to the ability to Define an ABI.

Now, the ability to completely define an ABI in completely arbitrary ways is kinda awesome. At the same time, it also seems vastly more complex, and this is reflected in the discussion: we've gone from "how to get a stable ABI" to "how to get a stable API for the ABI-defining crate".

I really think that we should take a step back here.

Let's postpone the ABI-defining crate -- it's not a bad idea, it's just a long-term one -- and instead focus on ABI-selection.

Selecting an ABI for fun and profit.

I would propose to start simple: ABI selection.

At the moment, the ABI is defined within the compiler. Let's keep it as is. Or rather, let's keep them as is.

Let us define 2 ABI flags to start with:

  • --abi=latest (default).
  • --abi=2020 or --abi=1.43.1 or whatever version you want.

At the moment? They're both the same, it's just that a slightly different symbol hash is generated (different flag).

This insta-stabilize the current ABI as --abi=2020 (or whatever), and promises that this one will never change. If any change is introduced, then --abi=latest automatically picks it up, while --abi=2020 doesn't change.

Simple, Complicated

The scheme I propose seems like the simple scheme to obtain a stable ABI, and therefore the quickest way to get one.

In practice, though, it's a bit more complicated than that.

Technically, the work to be done is relatively simple. I think implementation-wise the simplest solution is to abstract all ABI decisions behind a trait, and then have two implementations -- which happen to be identical right now -- and create a test-suite to prevent regression of the stable ABI.

Socially, however, you need the compiler developers to buy into this: they'll be the ones tasked with maintenance, after all. Some policy will have to be decided as well: How many ABIs are kept around? How often is a new ABI cut? Are new features retrofitted in previous (stable) ABIs? How?

You don't have to have the answers now, but the answers will be needed before this proposal is accepted.

In parting

I think the ability to select/define an ABI would really help Rust adoption, thanks for working on this.

3 Likes

This discussion has moved to the more-appropriate internals forum. You might want to post a link to the above post there and continue future discussion in the proper forum for language and compiler changes and issues.

2 Likes