Multi-architecture layout

Would it make sense for Rust to support representations given by the intersection of build targets?

#[repr(targets(wasm32-unknown-unknown, x86_64-linux-android, x86_64-unknown-linux-gnu))]
pub struct Passed {
    pub x: u32,
    pub y: u32,

    // Error, the usize type & conflicts between wasm32-* and x86_64-*
    // pub name: &str,

    #[repr(only_argets(x86_64-linux-android, x86_64-unknown-linux-gnu))]
    host_metadata: Arc<T>,  // Unavailable in WASM, but x86_64 space reserved there

    pub name: str,  // Unsized types work fine, but enjoy construction this one.
}

We'd afaik ban doing two platforms with different endianness I guess. It'd maybe help situations like Servo or Android where you've both native and VM code.

It's possible an external tool could preprocess rust structs into structs suitable for each platform. In fact, there already exist other tools for doing exactly this, like flat buffers. I suppose these become fairly type-like if fields-in-traits ever happens, while they already benefit form being cross-language, doing endianness conversions, and offering some data structures. Afaik, these all still represent serialization at some level though, so I'm curious if being more explicit makes sense.

i also want portable layouts where they're identical cross-architectures, since I'm planning on building a cpu at some point that supports multiple ISAs, e.g. x86-64, ppc64le, and rv64gc, and it would be nice to be able to call functions cross-architecture (through trampolines) but this requires in-memory data-structures to have identical layout on all architectures they're accessed from.

copying everything to new data-structures when crossing ISA boundaries doesn't work, e.g. you can't copy an AtomicU32 to some other address and still have it synchronize with different threads that didn't copy it.

1 Like

Interesting, atomics provides the first really strong argument for this perhaps. I think most cases fall under flat buffers or maybe some future rust centric flat buffers, if your only worry is flat buffers not being officially memory safe.

As an intermediate step, would it make sense for some external tool to generate compatible rust structs, and maybe some compatible C struct too? Afaik anything here needs padding field which need not be, or cannot be, initialized, like maybe

#[repr(..)]
pub struct Foo {
    pub x: u32;
    #[padding(u32)]
    pub y: u32;
    #[padding(u32)]
}

There are four u32s here, but two of them are padding, so you cannot safely write to them. It'd also suffice if fields could be initialize using Default of course, but this brings other concerns.

I'd a weaker argument than atomics for all this: We're passing between architectures for performance reasons, so it'd be nicer if we could simply change the struct definitions, but otherwise the numerical code works (fairly) identically on both architectures.

one way it could be done is to have an unsafe trait that you require for cross-arch calls' argument types, that trait guarantees the same layout cross-arch. there would be a derive macro that generates const code to verify if the struct/enum was actually cross-arch safe, e.g. by erroring if your #[repr(C)] has any implicit padding (you need to explicitly specify padding fields).

pub unsafe trait CrossArchSafe {}

impl CrossArchSafe for u8 {}
impl<T: CrossArchSafe, const N: usize> CrossArchSafe for [T; N] {}
impl CrossArchSafe for () {}
impl CrossArchSafe for AtomicI32 {}
impl<T: CrossArchSafe + ?Sized> CrossArchSafe for &'_ T {}

#[derive(CrossArchSafe)]
#[repr(C)] // errors if a stable-layout repr is omitted
pub struct MyStruct {
    pub v: AtomicI32,
    pub v2: u8,
    _padding: [u8; 3], // errors if this field is omitted
    // invalid: &dyn Debug, // errors if this field is included since dyn Debug is not cross-arch safe
}

Your #[repr(C)] serves to stabilize the representation? Rust could always reorder fields?

If substituting structs anyways then maybe an automatic translation could add the _padding: [0u8; 3] everywhere.

derive macros can't substitute structs, since all their output is emitted separately after the struct.

yes, repr(C) fields will not be re-ordered by rustc, repr(Rust) (the default) fields can and often will be re-ordered, especially when the -Z randomize-layout flag is given.

the issue with automatic padding is that different architectures define different alignment requirements for some types (e.g. f64 has alignment 4 on x86-32 and alignment 8 on x86-64 and basically everything else), so the struct needs to explicitly have greater than default alignment for fields of those weird types, done with a combination of padding to the right alignment and using repr(C, align(8)) or similar instead of just repr(C)

But an attribute macro emits a replacement for the annotated item, so could indeed substitute the struct.

that's true, however i think that makes how it works harder to understand, as well as making it harder to figure out how to translate struct definitions to C for FFI.

After Type-changing struct update syntax by jturner314 · Pull Request #2528 · rust-lang/rfcs · GitHub all padding could be handled by always initializing structs using FRUs like

let x = MyType { stuf, ..PAD };

where PAD is just some global const with all the padding fields you'd ever desire.

I suppose #[repr(C, align(8))] winds up quite close without doing anything fancy though.

Note that, like any FRU, this does still require that all fields are public (visible at point of construction). Also, just for clarity, PAD still needs to be the same base type (MyType); RFC#2528 only allows generic parameters to be replaced.

And since it's not mentioned here yet, there are (as far as I'm aware) two main ways to emulate explicit padding, both of which (likely[1]) don't quite emulate it perfectly, and have different tradeoffs.

The first and simpler option you're more likely to see is just using MaybeUninit<Pad> directly, where Pad is some [uN; M]. The latter is more involved, looking like the following:

#[repr(u8)]
enum ZeroPadByte { Value = 0 }

#[repr(C)]
struct ZeroPad<Type> {
    size: [ZeroPad; { size_of::<Type>() }],
    align: AlignAs<Type>,
}

struct AlignAs<Type>([Type; 0]);
unsafe impl Send for AlignAs<_> {}
unsafe impl Sync for AlignAs<_> {}
impl Unpin for AlignAs<_> {}
impl UnwindSafe for AlignAs<_> {}
impl RefUnwindSafe for AlignAs<_> {}

// impl well-known traits as desired, e.g. derivable

You can also use MaybeUninit<u8> instead of ZeroPadByte to cut the difference. There's two dimensions of differences between the options and actual implicit padding:

  • Autotraits. When using AlignAs, we cover any autotrait optouts of the example type, so it doesn't impact the autotrait inference of the containing type. (No impact for [uN; N].) Unfortunately, still requires the example type to be Copy in order to be Copy itself, until we eventually hopefully get impl<T> Copy for [T; 0] in std.
  • Initialization. When doing a typed[2] copy, implicit padding bytes are (likely[3]) reset to an uninitialized state. (The primary benefit is that the compiler would be allowed to copy those bytes or not, at its optimization whims.) With MaybeUninit, any byte pattern[4] is permitted and preserved exactly[5], as would with an untyped copy. With ZeroPadByte, the padding is guaranteed/required to be zero-initialized[6], allowing the use of nonzero values for niche optimization.

For this specific use case of target-independent layout/manipulation, though, it probably makes sense to use nothing less restrictive than MaybeUninit, though; keeping autotraits the same cross-platform is desirable, and I believe preserving the opaque bytes from a different target's representation is desired, which makes guaranteed-zero or reset-to-uninit padding undesirable.

I'd generally prefer to just use MaybeUninit<[uN; M]>, since that's simpler, more easily understood, and the most predictable (combined with an assertion of no implicit padding); if in the future we get a way to specify real padding, MaybeUninit fields can be swapped out fairly easily, if they've always been left uninitialized. Second would actually be just utilizing an aligning [uN; 0] field (or an Aligned<T, uN> wrapper), if that's sufficient for the use case. I'd only pull out ZeroPad if the niching benefit is notable and the type is Rust-native.


  1. The exact rules for how padding behaves are not yet decided. The behavior presented here gives the most leeway to the compiler, but a different model may be adopted. Speaking for myself, not for T-opsem. ↩︎

  2. e.g. any operation which source-level moves the value by-value, thus copying the value's bytes "at" the specific type used for the copy. ptr::copy, on the other hand, does an untyped copy of raw bytes. ↩︎

  3. In addition to the previous footnote disclaimer on "likely," note that Miri does not currently perform this reset. This is IIRC tracked as a deficiency in Miri's UB detection, and not a deliberate choice. ↩︎

  4. Notably including uninitialized bytes but also pointer provenance, irrespective of the wrapped type. ↩︎

  5. At least when the wrapped type doesn't have any padding... this is cursed knowledge, but it's reasonably likely we'll ensure MaybeUninit always keeps the "bag of bytes" repr even in the annoying edge cases. (ABI for unions can sometimes clobber padding.) ↩︎

  6. Considering when the compiler is allowed to elide writes to such a type is interesting. For ptr::write, the write must be performed, because nothing is asserted about the destination memory. For a typed write, the destination is dropped, which would might think would allow eliding the write, since the destination must already be the single valid byte pattern for the type... but in actuality, it's currently the case (speaking from my own understanding only, not a guarantee nor opinion of T-opsem) that an implied drop of a type without drop glue doesn't require that the destination is a valid value for that type. If this is the case, then it's acceptable to write POD types with standard assignment (e.g. *ptr = 0); if it's not, then a destination-forgetting write would be required instead (e.g. ptr.write(0)). ↩︎

Around this, it'd rock if mmaped databases were easier to build & use somehow, but I suppose this represents quite a tall order, with many dangerous corners.

It seems to me that this (or perhaps being able to specify a foreign layout, which seems fairly similar) would be useful for things like translation layers (e.g. wine). I know that at least calling conventions differ between Linux and Windows, and I have a vague memory that some details of the C repr with respect to bitfields with padding also differ.

Then there are things that straddle the border between translation layer and full on emulators (for example some game console emulators will have some of the platform APIs reimplemented in native code instead of emulating them).

Another use case would be in a kernel to handle both 32-bit and 64-bit syscall struct for example.

So I guess none of these are exactly what you suggested, but rather close: being able to select a foreign C repr. As far as I know this isn't supported currently either?

1 Like

Yes, a foreign layout which unifies the "worst" of two regular layouts works fine

It's tricky if LLVM codegen implements parts of the C reprs, so like

#[repr(targets(wasm32-plus-x86_64-linux))]

along with notes on what the repr really mean.

Not really for emulation purposes. You need to be able to exactly match several different ABIs in that case: the native one and the guest (emulated) one.

It's unclear to me what emulation means, but in my mind the goal was simply

#[repr(targets(x, y, z))]
pub struct Foo { ... }

produces identical in-memory layouts when built on targets x, y, and z.

This is useful because

  • mmap could more easily be used with real file formats, and
  • VMs like WASM could more easily pass data without serialization or conversion.

That is another use case.

But consider something like wine, which let's you run unmodified Windows binaries (such as games) on a Linux system. Here there are parts of wine that need to be callable from the window program, and thus use the same calling convention and struct layout as Windows. On the other hand, that same library then needs to call into the native glibc etc with a completely different calling convention and struct layout.

Here a superset is not useful.

And, I don't believe it is possible to do this in Rust today? Wine uses GCC attributes to accomplish it in it's C code.

EDIT:

Thinking about this some more, this is a subset of the feature suggested in this thread, not sure why I didn't spot that before, I feel stupid now. Oh well. Since this isn't supported at all currently as far as I can tell (at least on stable), this would be a nice addition. Having the intersection of ABI thing on top would be a nice extra as far as I'm concerned.

1 Like

Above we reached the question: Can LLVM or whatever even do this? Or does this require an external layout engine? This is pretty niche, so external layout tooling sounds fine.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.