The following text is braindump-like description of how I would like to see Rust work with various target options. (target_arch
, target_feature
, target_os
and others) This approach aims to provide stronger compile time guarantees compared to current cfg
setups, remove most of the unsafe
in the stdsimd
crate and integrate target parameters closer with Cargo.toml
. Also it could be useful for improving Rust portability story. While RFC 2045 is an important step towards stabilization of SIMD, I don’t think that the current approach is a good long-term solution. Also take a look at RFC 1868 which introduces somewhat similar approach, but for the smaller problem scope. I’ve seen elements of the described ideas in various discussions, but I will not be able to provide comprehensive overview of previously proposed ideas.
TLDR: this proposal introduces concept of “target restriction context”, which allows us to get stronger compile time guarantees around target parameters, which is especially important for working with SIMD intrinsics. Additionally it improves ergonomics of enabled target features for crates, as cargo
will be able to compute the minimally required set of features required for building a given crate.
Description
First lets introduce #[target(..)]
attribute:
#![target(os=any("windows", "linux"), feature="sse4")]
pub fn foo() { ... }
#[target(feature=all("aes", "rdrand"))]
pub fn bar() { ... }
#[target(os="linux")]
pub fn linux_bar() { ... }
Functionally #[target(os=any("windows", "linux"), feature="sse4")]
can be perceived as equivalent of #[cfg(all(any(target_os="windows", target_os="linux"), target_feature="sse4"))]
. But there is several distinctions:
- If crate is compiled with target parameters which do not fit into restrictions specified by
#![target(..)]
and#[target(..)]
it will result in a compilation error with an appropriate error message, e.g. it will be impossible to compile the example above for android. (contrary to#![cfg(..)]
) which will compile an empty crate). -
#[target]
restrictions have cascading nature, i.e.bar
in the example above has the following effective restrictiontarget(os=any("windows", "linux"), feature=all("sse4", "aes", "rdrand"))
andlinux_bar
has this onetarget(os="linux", feature="sse4")
. - More restricted items can not be used in less restricted contexts, e.g.
bar
andlinux_bar
can not be used in thefoo
, even if both restrictions are true for given build parameters. - Items covered by different
#[target(...)]
can’t have same names, i.e. they stay visible. Thus the following code will result in a compilation error:
#[target(os="linux")]
pub fn foo() { ... }
#[target(os="windows")]
pub fn foo() { ... }
To decide if item can be used in the given restriction context we generally have to use SAT solver. While expressions used in practice should be quite simple and easily solved by existing solvers, in the beginning we could require that logical expression which represents restriction context must follow disjunctive normal form, which allows to trivially solve the problem in linear time.
Runtime and compile time dispatch
Now we need tools for performing compiletime and runtime dispatches depending on target parameters. To do it we introduce the following macros:
// use runtime detection to select which arm to execute
let result = runtime_dispatch!(
(feature=all("avx2", "sse4.1")) => foo_avx2_sse41(),
(feature="sse2") => {
// e.g. here we can call SSE2 intrinsics safely
let c = _mm_add_epi64(a, b);
// code
},
_ => foo(),
);
// select one of the arms at compile time and inline it into the code
let result = compiletime_dispatch!(bar,
(os="linux", feature="aes") => bar_linux_aes(),
(os="linux") => {
// code
},
(os="windows") => bar_windows,
_ => bar(),
);
(names can be bikesheded)
Each macro creates contexts in its match arms with additional restrictions, which allows us to use more restricted items in a safe way. If compiler is able to determine exhaustiveness of match arms, the “default” arm can be omitted:
#![target(os=("macos" || "linux"))]
#[target(os="linux")]
fn foo_linux() { .. }
#[target(os="macos")]
fn foo_macos() { .. }
pub fn foo() {
// we are in the restriction context `os=("macos" || "linux")`,
// thus no need for default arm
compiletime_dispatch!(
(os="linux") => foo_linux(),
(os="macos") => foo_macos(),
);
}
// this function will result in a compilation error
pub fn foo2() {
foo_linux()
}
Escape hatch
If for some reason user will want to circumvent target restrictions he could use an unsafe
block:
// here instead of a compilation error we'll get a warning,
// which can be disabled by `#[allow(..)]`
pub fn foo2() {
unsafe { foo_linux() }
}
One of the reasons for this escape hatch is to be backwards compatible with SIMD intrinsics which are expected to be stabilized in unsafe variant relatively soon.
Relation with #[target_feature(enable = “…”)]
When we build a crate we fix almost all #[target(..)]
parameters, the most notable exception is feature
. At this stage for each restriction context we can calculate features which should be enabled. In other words we remove the necessity for #[target_feature(enable = "...")]
. As features will be enabled for whole restriction context it will allow compiler to use auto-vectorisation more effectively. For example:
// compiler will be able to use AVX2 for the whole function body
#[target(feature="avx2")]
fn avx_foo() { .. }
// no AVX2 instructions for this function
fn plain_foo() { .. }
But note that for some cases ( e.g. ARM in thumb mode) we still may need #[target_feature(disable = "...")]
to locally disable target feature even if it’s globally enabled.
Integration with Cargo.toml
Crate level #![target(..)]
declaration can be moved to the Cargo.toml
:
[package]
target = {os=["windows", "linux"], feature="sse4"}
It’s probably can be integrated with [target.'cfg(...)'.dependencies]
somehow, but I don’t have good ideas right now.
As cargo will be able to calculate the minimal set of features which should be enabled for given target, it will be able to do it automatically, without any dances around RUSTFLAGS
.
Backward compatibility
Items and crates without any applied target restrictions can be used in all restriction contexts. (although it could be useful to see lack of #![no_std]
as a restriction context to disallow std
dependent crates to be used as dependencies for no_std
crate) This feature can be introduced as a lint in 2018 edition and turned to compile time errors in the next edition. unsafe
escape hatch will allow to be backwards compatible with stabilization of unsafe intrinsics.
Unresolved questions
- Syntax for
#[target(..)]
restriction expressions. - How to cache runtime dispatch results efficiently? Initialization code which will run before
main()
or execute detection at each dispatch point? - How to incorporate Platform Abstraction Layers (PAL) to this design? (e.g. for embedded it can be useful to know that dependency does not use floating point operations) Something like
capability
field which can includefloat
,alloc
,net
,file
, etc.? - Default restriction context for crates dependent on
std
.