Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: No (opsem) Magic Boxes #3712

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

chorman0773
Copy link

@chorman0773 chorman0773 commented Oct 15, 2024

Summary

Currently, the operational semantics of the type alloc::boxed::Box<T> is in dispute, but the compiler adds llvm noalias to it. To support it, the current operational semantics models have the type use a special form of the Unique (Stacked Borrows) or Active (Tree Borrows) tag, which has aliasing implications, validity implications, and also presents some unique complications in the model and in improvements to the type (e.g. Custom Allocators). We propose that, for the purposes of the runtime semantics of Rust, Box is treated as no more special than a user-defined smart pointer you can write today1. In particular, it is given similar behaviour on a typed copy to a raw pointer.

Rendered

Footnotes

  1. We maintain some trivial validity invariants (such as alignment and address space limits) that a user cannot define, but these invariants only depend upon the value of the Box itself, rather than on memory.

@chorman0773 chorman0773 added T-lang Relevant to the language team, which will review and decide on the RFC. T-opsem Relevant to the operational semantics team, which will review and decide on the RFC. labels Oct 15, 2024
@programmerjake

This comment was marked as resolved.

Clarify the constraint o the invariant in footnote

Co-authored-by: Jacob Lifshay <[email protected]>
@clarfonthey
Copy link
Contributor

It feels odd that one of the clear options is left out: why not expose a Unique<T> that has the same semantics as Box<T>, so any smart pointer type can use it?

Like, I definitely agree that Box shouldn't have special semantics that you can't reproduce elsewhere. But among the options, it feels pretty limiting to come to the conclusion that we should eliminate those semantics, rather than just making them reproducible elsewhere.

I agree that whatever happens shouldn't be specific to the Global allocator, though.

@scottmcm scottmcm added the I-lang-nominated Indicates that an issue has been nominated for prioritizing at the next lang team meeting. label Oct 15, 2024
@scottmcm
Copy link
Member

scottmcm commented Oct 15, 2024

We maintain some trivial validity invariants (such as alignment and address space limits) that a user cannot define

I, at least, fully expect us to eventually have some way of writing alignment-obeying raw pointers in Rust in some way. If nothing else, slice::Iter is less good than it could be because of the lack of them, and optimizing that type is super-important.

(Transmuting between NonNull<T> and unsafe<'a> &'a T is one thing that might work, for example, though it's also possible that the opsem for that will end up saying that it doesn't and that something else is required instead.)

EDIT: added a word to try to communicate that I wasn't expecting this RFC to include such a type.

@scottmcm scottmcm self-assigned this Oct 15, 2024
@chorman0773
Copy link
Author

I, at least, fully expect us to have some way of writing alignment-obeying raw pointers in Rust in some way. If nothing else, slice::Iter is less good than it could be because of the lack of them, and optimizing that type is super-important.

That's my hope for the future as well, but to avoid the RFC becoming too cluttered, I am refraining from defining such a type in this RFC.

@juntyr
Copy link

juntyr commented Oct 16, 2024

Is there a list of optimisations that depend on noalias being emitted for Box’es?

@clarfonthey
Copy link
Contributor

The RFC seems pretty clear that noalias hasn't really provided many benefits compared to being an extra burden to uphold for implementers, but maybe it is worth seeing if there are any sources that can provide a bit more detail on that.

* A pointer with an address that is not well-aligned for `T` (or in the case of a DST, the `align_of_val_raw` of the value), or
* A pointer with an address that offsetting that address (as though by `.wrapping_byte_offset`) by `size_of_val_raw` bytes would wrap arround the address space

The [`alloc::boxed::Box<T>`] type shall be laid out as though a `repr(transparent)` struct containing a field of type `WellFormed<T>`. The behaviour of doing a typed copy as type [`alloc::boxed::Box<T>`] shall be the same as though a typed copy of the struct `#[repr(transparent)] struct Box<T>(WellFormed<T>);`.
Copy link
Member

@kennytm kennytm Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry but is the term "typed copy" explained somewhere?

(the explanations I could find are from pretty unofficial places like a reddit1 and urlo2 post)

Footnotes

  1. "they are like memcpy, but the copy occurs with a type, giving the compiler some extra power."

  2. "a typed copy consists of essentially decoding the AM-bytes into the abstract value then encoding that abstract value back to AM-bytes at the new location."

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The urlo definition is probably good.
It's defined in the opsem, but I don't know if we have a very good written record of that other than spread arround zulip threads and github issues.

@scottmcm scottmcm removed the I-lang-nominated Indicates that an issue has been nominated for prioritizing at the next lang team meeting. label Oct 16, 2024
Copy link
Member

@scottmcm scottmcm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a fan of this. I think that people moving from Vec<T> to Box<[T]> having to deal with drastically-different soundness rules is a giant footgun, and getting rid of the special [ST]B behaviour here sounds good to me.

@nikomatsakis
Copy link
Contributor

My general take:

The two "endpoints" here are

  • Efficient end-user abstractions: this allows most safe code to run faster. This would have strong alias requirements and would not expose raw/unsafe details. This permits non-obvious optimizations (e.g., small string optimization or 0-length capacity).
  • Building blocks for unsafe code: this exposes raw/unsafe details.

From what I can tell, we current orient Box as the former but Vec and String as the latter. That seems backwards, since if anything those are far more useful as abstractions than Box is.

If I could go back in time, I think I would favor end-user abstractions and offer different types (e.g., RawVec or RawBuffer or something) that exposed their innards, but I think that ship has sailed, and we might as well embrace the current situation (which is nice in some ways too).

@nikomatsakis
Copy link
Contributor

The RFC would benefit from some attempt to quantify the impact on performance, though our lack of standardized runtime benchmarks makes that hard.

@traviscross
Copy link
Contributor

@rust-lang/opsem: We were curious in our discussions, does this RFC represent an existing T-opsem consensus?

@chorman0773
Copy link
Author

chorman0773 commented Oct 16, 2024

We were curious in our discussions, does this RFC represent an existing T-opsem consensus?

It does not represent any FCP done by T-opsem, which is why I've included them here. The claims I make, including those about the impact on the operation semantics, are included in the request for comment and consensus.

The RFC would benefit from some attempt to quantify the impact on performance, though our lack of standardized runtime benchmarks makes that hard.

I recall some perf PR's (using the default rustc-perf suite) being done to determine the impact, which showed negligible impact. I can probably pull them up at some point in the RFC's lifecycle.


(Note that we do not define this type in the public standard library interface, though an implementation of the standard library could define the type locally)

The following are not valid values of type `WellFormed<T>`, and a typed copy that would produce such a value is undefined behaviour:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Reference has been adjusted a while ago to state validity invariants positively, i..e by listing what must be true, instead of listing what must not be false. IMO that's more understandable, and the RFC should be updated to also do that.

@RalfJung
Copy link
Member

RalfJung commented Oct 17, 2024

I agree that whatever happens shouldn't be specific to the Global allocator, though.

There are patterns of using a custom per-Box allocator that are incompatible with the aliasing requirements, at least under our current aliasing models. See rust-lang/miri#3341 for an example. So if we always make Box be unique, we have to declare those allocators to be UB.

Is there a list of optimisations that depend on noalias being emitted for Box’es?

It's "every LLVM optimization that looks at alias information". The question is how much that matters in practice, which is hard to evaluate.

We were curious in our discussions, does this RFC represent an existing T-opsem consensus?

As Connor said, not in any formal sense. Several opsem members have expressed repeatedly that they want to see noalias on Box go, but I don't know whether we have team consensus on this.

My own position is that I love how this simplifies the model and Miri, I am just slightly concerned about this being an irreversible loss of optimization potential that we might regret later. Absence of evidence of optimization benefit is not evidence of absence. Our benchmarks likely just don't have a lot of functions that take Box<T> by value. However, that in itself is an indication that the optimization benefit is likely limited.

Is there a way we can query the ecosystem for functions taking Box<T> by value?

- While the easiest alternative is to do nothing and maintain the status quo, as mentioned this has suprisingly implications both for the operational semantics of Rust
- Alternative 2: Introduce a new type `AlisableBox<T>` which has the same interface as `Box<T>` but lacks the opsem implications that `Box<T>` has.
- This also does not help remove the impact on the opsem model that the current `Box<T>` has, though provides an ergonomically equivalent option for `unsafe` code.
- Alternative 3: We maintain the behaviour only for the unparameterized `Box<T>` type using the `Global` allocator, and remove it for `Box<T,A>` (for any allocator other than `A`), allowing unsafe code to use `Box<T, CustomAllocator>`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually the status quo, since rust-lang/rust#122018

@clarfonthey
Copy link
Contributor

clarfonthey commented Oct 17, 2024

Just to follow up on some of the discussion, it wasn't immediately clear to me that types similar to Box, like Vec and Arc, genuinely don't have these semantics, even though it is implied by the fact that Box is unique in this regard. I think this is worth emphasising more in the text of the RFC, since in a very real sense, we've been going without these semantics for most[citation needed] parts of Rust totally fine, which further emphasises less will be lost by removing it for Box.

I would still love if there's more data showing the lack of returns on noalias optimisations, since it feels wrong that something with a lot of history and usage isn't helping that much, but the fact that Vec and other types don't have this optimisation at least helps us understand that it's not going to cause any performance issues if removed.

…C++ counterpointer `std::unique_ptr`, in the prior art section
@chorman0773
Copy link
Author

chorman0773 commented Oct 17, 2024

I am just slightly concerned about this being an irreversible loss of optimization potential that we might regret later

FTR, speaking right now as one of the main developers of lccc, my opinion is that the best way to mitigate any loss of future optimization potential is to just be far more granular with &mut. I don't think spamming extra noalias on parameters is necessary if we just emit more metadata on derefs (llvm's scoped noalias and dereferenceable, and lccc's unique and dereferenceable type attributes). If there are problems with doing that, I don't think this is a place where we should necessarily bend the whole language to this one attribute on one backend, especially given the complexity of maintaining the feature as-is, where we keep running into fundamental issues with the special treatment of Box. If there are legitimate optimizations lost as a result of the function level noalias, that can't be made up with more granular scoping on dereferences, that may be a different consideration, but my view is that the language after this change has the semantics necessary to justify at the very least a majority of the optimizations that may ultimately be lost, and its up to rustc and llvm (and other compilers) to make use of those semantics if they wish to.

Just to follow up on some of the discussion, it wasn't immediately clear to me that types similar to Box, like Vec and Arc, genuinely don't have these semantics, even though it is implied by the fact that Box is unique in this regard. I think this is worth emphasising more in the text of the RFC, since in a very real sense, we've been going without these semantics for most parts of Rust totally fine, which further emphasises less will be lost by removing it for Box.

I mentioned Vec<T> in particular, and also mentioned std::unique_ptr<T> from C++, which is the most closely equivalent type, and lacks the same semantic implications (and also optimizations).

@scottmcm
Copy link
Member

Just to follow up on some of the discussion, it wasn't immediately clear to me that types similar to Box, like Vec and Arc, genuinely don't have these semantics

And more than them just not having them, IIRC someone tried to implement Vec<T> as the obvious wrapper around Box<[MaybeUninit<T>]>, and in the process found out that lots of people are depending on Vec not having them.

@RalfJung
Copy link
Member

RalfJung commented Oct 18, 2024 via email

@RalfJung
Copy link
Member

@rust-lang/wg-llvm could we get some opinion from you on this RFC? Are you concerned that this will lose us too much optimization potential, or are the existing benchmarks a good enough indication that that's unlikely?

@scottmcm

And more than them just not having them, IIRC someone tried to implement Vec as the obvious wrapper around Box<[MaybeUninit]>, and in the process found out that lots of people are depending on Vec not having them.

That's not quite right, IIRC. What happened is that when applying the Stacked Borrows rules for Box to Vec, a bunch of existing code becomes UB. But that's more a statement about Stacked Borrows than about noalias. With Tree Borrows, things are already better, but even Tree Borrows has more UB than LLVM noalias. I am not aware of a single real-world usage of Vec that actually breaks the noalias rules.

@theemathas
Copy link

As an example of an optimization that noalias can enable: Optimizations can assume that writing to one Box can't change the value of a second Box. This means that if values from that second Box needs to be read repeatedly, then the program can be optimized to read the value only once, even if we write to the first Box in between.

As a concrete example, here's a Godbolt link of artificial code that gets optimized better if Box is noalias.

To test how making Box not noalias affects the optimization of your program, you can run the following command to build your program (requires nightly):

RUSTFLAGS='-Zbox-noalias=no' cargo +nightly build --release -Z build-std --target $(rustc +nightly -vV | sed -n 's|host: ||p')

@GoldsteinE
Copy link

I wonder whether this is common in real code. I don’t think I’ve seen functions accepting boxes as arguments that often: usually it makes more sense to just pass a reference. Maybe it happens in some generic impl AsRef<_> code?

@clarfonthey
Copy link
Contributor

would still love if there's more data showing the lack of returns on noalias optimisations, since it feels wrong that something with a lot of history and usage isn't helping that much
It helps for references. I suspect people added it for Box because "why not".

I guess that the confusion here is how things should propagate. Like, the assumption that references are unique should extend directly from the assumption that the data they're referencing is unique. Otherwise, it feels like a disaster of UB?

@theemathas
Copy link

I guess that the confusion here is how things should propagate. Like, the assumption that references are unique should extend directly from the assumption that the data they're referencing is unique. Otherwise, it feels like a disaster of UB?

It's currently unclear whether the existence of a &mut Box<T> even implies that there's an initialized non-null Box<T>. See rust-lang/unsafe-code-guidelines#412

@RalfJung
Copy link
Member

RalfJung commented Oct 18, 2024

Like, the assumption that references are unique should extend directly from the assumption that the data they're referencing is unique. Otherwise, it feels like a disaster of UB?

I don't understand either of these sentences, sorry.

&mut T references are unique because we say so, because we make the borrow checker and unsafe code enforce this, because it is part of their safety invariant.

@saethlin
Copy link
Member

saethlin commented Oct 18, 2024

I believe noalias has been associated with Box since rust-lang/rust#11538. Note that the justification given at that time was

// `~` pointer parameters never alias because ownership is transferred

I think people are and have been reading way too much into the decision to put noalias on Box. That PR also added noalias to ~ returns, and that was probably unsound for 9 years until rust-lang/rust#106371. And justified with the same comment.

The vibes were good at the time, but we know so much more now.

Comment on lines +15 to +21
# Motivation
[motivation]: #motivation

The current behaviour of [`alloc::boxed::Box<T>`] can be suprising, both to unsafe code, and to people working on the language or the compiler. In many respects, `Box<T>` is treated incredibly specially by `rustc` and by Rust, leading to ICEs or unsoundness arising from reasonable changes, such as the introduction of per-container `Allocator`s.

In the past, the operational semantics team has considered many ad-hoc solutions to the problem, while maintaining special cases in the aliasing model (such as Weak Protectors) that only exist for `Box<T>`.
For example, moving a `ManuallyDrop<Box<T>>` after calling `Drop` is immediate undefined behaviour (due to the `Box` no longer being dereferenceable) - <https://rust-lang.zulipchat.com/#narrow/stream/136281-t-opsem/topic/Moving.20.60ManuallyDrop.3CBox.3C_.3E.3E.60>, and the Active tag requirements for a `Box<T>` are unsound when combined with custom allocators <https://rust-lang.zulipchat.com/#narrow/stream/213817-t-lang/topic/Is.20Box.20a.20language.20type.20or.20a.20library.20type.3F>. This wastes procedural time reviewing the proposals, and complicates the language by introducing special case rules that would otherwise be unnecessary.
Copy link
Contributor

@traviscross traviscross Oct 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proposed RFC kind of handwaves over what the opsem complications are that are motivating this. And for such a consequential RFC, the motivation section is rather short overall.

It'd be helpful for this motivating background context to be inlined and explored more deeply here, and for it to go into what the status of the various solutions to these problems might be.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E.g., it'd be good to include the substance of @RalfJung's comment here somewhere:

What happened is that when applying the Stacked Borrows rules for Box to Vec, a bunch of existing code becomes UB. But that's more a statement about Stacked Borrows than about noalias. With Tree Borrows, things are already better, but even Tree Borrows has more UB than LLVM noalias. I am not aware of a single real-world usage of Vec that actually breaks the noalias rules.


- Alternative 1: Status Quo
- While the easiest alternative is to do nothing and maintain the status quo, as mentioned this has suprisingly implications both for the operational semantics of Rust
- Alternative 2: Introduce a new type `AlisableBox<T>` which has the same interface as `Box<T>` but lacks the opsem implications that `Box<T>` has.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Alternative 2: Introduce a new type `AlisableBox<T>` which has the same interface as `Box<T>` but lacks the opsem implications that `Box<T>` has.
- Alternative 2: Introduce a new type `AliasableBox<T>` which has the same interface as `Box<T>` but lacks the opsem implications that `Box<T>` has.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably we should mention somewhere, in favor of adding a separate type like AliasableBox, that if we did that, we could then align other operations on that type to match in a way we can't do with Box. E.g., making a reference from an AliasableBox would be unsafe.

@nikomatsakis
Copy link
Contributor

@RalfJung Your comment about vec surprised me a bit. I want to check my understanding. I think that the reason that Vec<T> has a more "low-level" API is basically people using Vec<u8> as a kind of convenient byte-buffer to implement things like arenas etc. It'd be helpful if you can point to some kind of summary of the sorts of issues encountered by stacked/tree borrows.

@clarfonthey
Copy link
Contributor

Like, the assumption that references are unique should extend directly from the assumption that the data they're referencing is unique. Otherwise, it feels like a disaster of UB?

I don't understand either of these sentences, sorry.

&mut T references are unique because we say so, because we make the borrow checker and unsafe code enforce this, because it is part of their safety invariant.

Yeah, but if the pointers inside boxes aren't unique, then how can we guarantee that the mutable references are unique?

I'm sure I'm just misunderstanding what noalias means in these two different contexts, but what I'm wondering is, wouldn't aliasable boxes mean you could have two boxes pointing to the same memory, then unsoundly get a mutable reference to both at the same time?

@zachs18
Copy link
Contributor

zachs18 commented Oct 19, 2024

wouldn't aliasable boxes mean you could have two boxes pointing to the same memory, then unsoundly get a mutable reference to both at the same time?

If I understand correctly: Yes, having aliasing Boxes would let you get aliasing &muts in safe code (e.g. by dereferencing them and borrowing the result with &mut *bx). Therefore even under this RFC it sould still be a safety requirement (or library requirement) that Boxes not alias, i.e. you must not expose aliasing Boxes to arbitrary safe code, as opposed to the status quo where it is a validity requirement (or language requirement) that Boxes not alias, and is just immediate UB even if it only happens in localized unsafe code.

@chorman0773
Copy link
Author

Indeed - it is not the intent of the RFC to require that arbitrary code remain valid in the presence of alised Box values, such a thing would be a breaking change - the library contract of Box isn't changed and still must be upheld. The proposal simply is to remove some UB from the language, when you do certain operations with Boxes in unsafe code.

Most collections act like this already, with Box being the outlier. It is possible, for example, to hold duplicate Vec<i32> values (with capacity > 0), but such values can't be fully used (including by dropping them implicitly or explicity) and can't be given to arbitrary code (safe or unsafe).

@RalfJung
Copy link
Member

@RalfJung Your comment about vec surprised me a bit. I want to check my understanding. I think that the reason that Vec<T> has a more "low-level" API is basically people using Vec<u8> as a kind of convenient byte-buffer to implement things like arenas etc. It'd be helpful if you can point to some kind of summary of the sorts of issues encountered by stacked/tree borrows.

Yes to my knowledge the arena usecase is compatible with LLVM noalias semantics. I'm happy to discuss a concrete example if someone provides one and wonders how it interacts with noalias semantics, but don't have the time to produce a full writeup currently.

@RalfJung
Copy link
Member

RalfJung commented Oct 19, 2024

To give a simple example:

let mut v: Vec<i32> = Vec::with_capacity(128);
v.push_within_capacity(0).unwrap();
let elem0: &mut i32  = unsafe { &mut *v.as_mut_ptr().add(0) };
*elem0 = 12; // write to elem0 so that reference becomes "Active" in TB
let mut v = v; // retag v
v.push_within_capacity(1).unwrap();
// Carefully *not* using `v[1]` as that would create a slice conflicting with `elem0`.
let elem1: &mut i32  = unsafe { &mut *v.as_mut_ptr().add(1) };
mem::swap(elem0, elem1);

This is UB under Stacked Borrows and Tree Borrows semantics (if Vec is defined via Box<[_]>) because when v gets moved, there is a retag that conflicts with elem0.

However, LLVM noalias is fine with this program because noalias only exists on function arguments and is only active while the function runs. (In fact LLVM is even fine with using &mut v[1] instead of going via as_mut_ptr().)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
T-lang Relevant to the language team, which will review and decide on the RFC. T-opsem Relevant to the operational semantics team, which will review and decide on the RFC.
Projects
None yet
Development

Successfully merging this pull request may close these issues.