RFC 2574: SIMD in C FFI

lang | compiler (typesystem | simd | ffi)

Summary

This RFC allows using SIMD types in C FFI.

Motivation

The architecture-specific SIMD types provided in core::arch cannot currently be used in C FFI. That is, Rust programs cannot interface with C libraries that use these in their APIs.

One notable example would be calling into vectorized libm implementations like sleef, libmvec, or Intel's SVML. The packed_simd crate relies on C FFI with these fundamental libraries to offer competitive performance.

Why is using SIMD vectors in C FFI currently disallowed?

Consider the following example (playground):

extern "C" fn foo(x: __m256);

fn main() {
    unsafe { 
        union U { v: __m256, a: [u64; 4] }
        foo(U { a: [0; 4] }.v);
    }
}

In this example, a 256-bit wide vector type, __m256, is passed to an extern "C" function via C FFI. Is the behavior of passing __m256 to the C function defined?

That depends on both the platform and how the Rust program was compiled!

First, let's make the platform concrete and assume that it follows the x64 SysV ABI which states:

3.2.1 Registers and the Stack Frame

Intel AVX (Advanced Vector Extensions) provides 16 256-bit wide AVX registers (%ymm0 - %ymm15). The lower 128-bits of %ymm0 - %ymm15 are aliased to the respective 128b-bit SSE registers (%xmm0 - %xmm15). For purposes of parameter passing and function return, %xmmN and %ymmN refer to the same register. Only one of them can be used at the same time.

3.2.3 Parameter Passing

SSE The class consists of types that fit into a vector register.

SSEUP The class consists of types that fit into a vector register and can be passed and returned in the upper bytes of it.

Second, in C, the __m256 type is only available if the current translation unit is being compiled with AVX enabled.

Back to the example: __m256 is a 256-bit wide vector type, that is, wider than 128-bit, but it can be passed through a vector register using the lower and upper 128-bits of a 256-bit wide register, and in C, if __m256 can be used, these registers are always available.

That is, the C ABI requires two things:

And this is where things went wrong: in Rust, __m256 is always available independently of whether AVX is available or not1, but we haven't specified how we are actually compiling our Rust program above:

1: its layout is currently unspecified but that is not relevant for this issue - what matters is that 256-bit registers are not available and therefore they cannot be used.

You might be wondering: why is __m256 available even if AVX is not available? The reason is that we want to use __m256 in some parts of Rust's programs even if AVX is not globally enabled, and currently we don't have great infrastructure for conditionally allowing it in some parts of the program and not others.

Ideally, one should only be able to use __m256 and operations on it if AVX is available, and this is exactly what this RFC proposes for using vector types in C FFI: to always require #[target_feature(enable = X)] in C FFI functions using SIMD types, where "unblocking" the use of each type requires some particular feature to be enabled, e.g., avx or avx2 in the case of __m256.

That is, the compiler would reject the example above with an error:

error[E1337]: `__m256` on C FFI requires `#[target_feature(enable = "avx")]`
 --> src/main.rs:7:15
  |
7 |     fn foo(x: __m256) -> __m256;
  |               ^^^^^^

And the following program would always have defined behavior (playground):

#[target_feature(enable = "avx")]
extern "C" fn foo(x: __m256) -> __m256;

fn main() {
    unsafe { 
        #[repr(C)] union U { v: __m256, a: [u64; 4] }
        if is_x86_feature_detected!("avx") {
            // note: this operation is used here for readability
            // but its behavior is currently unspecified (see note above).
            let vec = U { a: [0; 4] }.v;
            foo(vec);
        }
    }
}

independently of the -C target-features used globally to compile the whole binary. Note that:

Guide-level and reference-level explanation

Architecture-specific vector types require #[target_feature]s to be FFI safe. That is, they are only safely usable as part of the signature of extern functions if the function has certain #[target_feature]s enabled.

Which #[target_feature]s must be enabled depends on the vector types being used.

For the stable architecture-specific vector types the following target features must be enabled:

Future stabilizations of architecture-specific vector types must specify the target features required to use them in extern functions.

Drawbacks

None.

Rationale and alternatives

This is an adhoc solution to the problem, but sufficient for FFI purposes.

Future architecture-specific vector types

In the future, we might want to stabilize some of the following vector types. This section explores which target features would they require:

Require the feature to be enabled globally for the binary

Instead of using #[target_feature] we could allow vector types on C FFI only behind #[cfg(target_feature)], e.g., via something like the portability check.

This would not allow calling C FFI functions with vector types conditionally on, e.g., run-time feature detection.

Prior art

In C, the architecture specific vector types are only available if the required target features are enabled at compile-time.

Unresolved questions