[Pre-Pre-RFC] Target restriction contexts

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 restriction target(os=any("windows", "linux"), feature=all("sse4", "aes", "rdrand")) and linux_bar has this one target(os="linux", feature="sse4").
  • More restricted items can not be used in less restricted contexts, e.g. bar and linux_bar can not be used in the foo, 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 include float, alloc, net, file, etc.?
  • Default restriction context for crates dependent on std.

Will this include the target_has_atomic cfg?

If so, how will the suggested compile time error work with the types in core::sync::atomic which may or may not be available depending on the current target. It sounds like this would give an error while compiling libcore because the target feature is not available?

Minor nitpick: while it is worth pointing out what #![cfg(..)] does, what most users currently do is use compile_fail! to produce an error message. Not having to write compile_fail! is obviously better, but the improvement isn't from "no-error" to "error", but rather, from manually having to write an error message once per crate to not having to write it at all. I think this is an improvement, but I also think that an user-defined error message can have advantages in some situations over a generic one.

#[target(feature=all("aes", "rdrand"))]

Another minor nitpick: it might make sense to disable feature in some contexts which is why #[target_feature] uses enable=... so that this can be extended with disable=... in the future.

How to cache runtime dispatch results efficiently? Initialization code which will run before main() or execute detection at each dispatch point?

FWIW std::arch::detect currently initializes the cache the first time that run-time feature detection happens. This way users who don't need run-time feature detection don't pay for it.

feature=("avx2" && "sse4.1"), feature=all("aes", "rdrand")

Nitpick: the post sometimes uses && and other times it uses all, you might want to make the syntax consistent.

// target restrictions are turned into lints inside unsafe blocks

What does this mean?

AtomicBool will be marked with attribute #[target(has_atomic="8")]. libcore will be compiled without any problems, but if crate which uses AtomicBool will not have an appropriate restriction this crate (not libcore) will not compile, even for targets which have atomics.

Having sane compiler errors is just part of the proposed story. (btw you still can use cfg based compile_error! messages with target setup, though I don't see much reason to duplicate target functionality with it) It's not only that those errors will be default behavior, but that they will be always checked. Also the important part is restriction context enhancement through the dispatch macros. So while at the first glance target is similar to existing cfg setups, they are fundamentally different tools.

As I understand it, the main reason for having #[target_feature(enable = "...")] is to allow runtime dispatch. Restriction context can essentially supersede it, as given restriction context can be compiled with features representing “lowest common denominator” of features in the restriction expression. So the following functions:

#[target_feature(enable = "aes")]
fn foo() { .. }

#[target(feature = "aes")]
fn foo() { .. }

will be equivalent, as aes feature will be enabled for both function bodies. But the second variant provides better compile time guarantees and checks, while the first one can result in surprises.

Thank you for noticing this! It was a leftover from older draft.

I'll try to improve the wording. I wanted to say that if you'll use more restricted item in an unsafe block instead of a compiler error you'll get a warning, which can be disabled.

What I meant with

it might make sense to disable feature in some contexts which is why #[target_feature] uses enable=… so that this can be extended with disable=… in the future.

is that some targets might have some features globally enabled (e.g. in the target definition), but the user might want to disable some of these globally enabled features in some functions (e.g. thumb mode). While IIRC we haven't whitelisted any of those features yet, it might be worth for this RFC to at least mention how it could be extended to support disabling some globally-enabled features in a backwards compatible way, just in case we'd like to whitelist one of these in the future.

The "Escape hatch" section of this pre-RFC shows that #[target(feature)] behaves exactly like #[target_feature] inside unsafe blocks. Isn't this the case? If not, could you explain the differences? Otherwise, how is using #[target(feature)] inside an unsafe block any safer than using #[target_feature] ?

[quote="newpavlov, post:4, topic:7163"] I’ll try to improve the wording. I wanted to say that if you’ll use more restricted item in an unsafe block instead of a compiler error you’ll get a warning, which can be disabled. [/quote]

I think this warning by default is good. The preferred way of using #[target(feature)] for doing run-time dispatch should be through the dispatch macros. This might be a bit annoying for targets in which doing run-time feature detection is not possible, but those targets should probably be using compile-time dispatch anyways and they can disable the warning if they want to.

Ah, sorry for the misunderstanding. For this I believe we'll still need a #[target_feature(disable = "...")]. I think it's better to have such explicit attribute over denying the optimization possibilities in cases like this:

#[target(feature="sse3")]
fn foo() { .. }

// we want `foo` used here to be compiled with `avx2` as well
#[target(feature=all("sse3", "avx2")]
fn bar() { ..; foo(); .. }

The difference is that foo_linux() is a safe function and can be used in appropriate restriction contexts without unsafe block. (see the example before that) In other words in case if outside of unsafe blocks restriction mismatch will be a compile error , then most (or all?) SIMD intrinsics can be safe functions. But, yes, inside unsafe block the only difference will be a warning.

In Rust 2018 we will have no choice but to use warnings in both safe and unsafe contexts. Although without changing them to errors for safe contexts in the next edition we will not be able to make SIMD intrinsics safe functions, as their safety will rely on target restriction checks.

Yeah I was referring exclusively to their use inside unsafe blocks, since #[target_feature] can't currently be used out of them. Are you aware of proposals like this: Performance regressions porting Jetscii from inline assembly to intrinsics · Issue #401 · rust-lang/stdarch · GitHub or about RFC2212 (post-poned: Minimal target feature unsafe by hsivonen · Pull Request #2212 · rust-lang/rfcs · GitHub) ?

Basically I like the direction of all of this.

What I'd like to avoid is to end up with two features that do exactly the same thing. So maybe we should just rename #[target_feature] to target(feature), and shape it in such a way that it can evolve to what you propose here or something like it incrementally. A first steps would be what the comment and the RFC above propose, but your proposal goes much further than those. Maybe there is a way in which we could shape the evolution of target feature as a series of incremental steps that get us here someday.

On the other hand, there's the option (by analogy with all()) of using not(). e.g:

#[target(feature=all("aes", "rdrand", not("avx512"))]
fn baz() { ... }

This introduces a form of tri-valued logic - all target features default to "don't care", and by specifying them (either alone or in a not()) they become fixed at "required" or "forbidden".

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