Repository navigation
Improve live drop precision - #161627
Improve live drop precision#161627nnethercote wants to merge 2 commits into
Conversation
|
LLM disclosure: an LLM found the initial bug and answered some questions I had about this code. I made all the code and text changes myself. |
|
@oli-obk, @RalfJung, @nikomatsakis: this is relevant to the |
|
This is not changing precise const drop analysis nor
I don't think so. Note that we currently have two const-drop checks (which ensure that we don't run destructors in const code): a conservative one that we use by default, and a more precise one that is enabled by From the test change it seems like this PR makes the non-precise (stable, default) const drop check a bit more precise. That particular example seems fine to accept, looking at #65394 this isn't even really about precise const drops but about "legacy and dataflow-based const validators". I don't know what that is but I suspect it is a long-finished refactor of the const checker. The test only later incorporated const_precise_live_drops, with a comment about the stable behavior being surprising. So past me apparently also thought we should accept this code. So overall -- I think this is not relevant const_precise_live_drops, it is relevant to the stable const drop checker. I don't know how it changes that checker; the one test diff looks fine but this is a change observable on stable so this PR is a one-way change. Would be nice to get a better understanding of the new code accepted under this PR. |
The bug fix is in the And you are right that this PR doesn't change precise const drop analysis, but it does change the conservative const drop analysis to be more precise, so there is some overlap/relevance, and could be viewed as a partial stabilization of More generally, I'm trying to get a sense of whether this is interesting. There's a clear bug that is causing the compiler to reject some valid programs, which seems interesting to me, but as I said I'm not an expert on this stuff. If it's not interesting, I could just close this and open a separate PR that puts an explanatory comment on the short-circuiting |
|
The reason The conservative const drop checker is only conservative to avoid depending on things that we might want to change. It should accept all code that we are reasonably confident we can keep accepting even if we refactor compiler internals. So I think what you found was just a genuine bug, not a deliberately conservative approximation. But let's hear from the rest of @rust-lang/wg-const-eval. (Oli is on vacation currently.) |
|
@RalfJung @oli-obk @lcnr @fee1-dead: Any interest in this PR? In summary, fixing a longstanding but obvious bug in |
|
Personally, yes, I am interested. But to actually evaluate the change (and certainly for the t-lang nomination this will need), I'd need a description of the change and which new code this allows -- at the source code level, not in terms of our dataflow framework. I can't reverse engineer that from the diff. Maybe @oli-obk can. The main evaluation criterion would be "does this expose any compiler implementation details that we may want to change" (which the precise const live drop check does and that's why it is unstable). |
18f06f5 to
f6f03bd
Compare
|
Here is some sample code that tries to answer some of Ralf's questions. #![allow(unused)]
// Old and new behaviour: accepted, as intended; x is fully moved, so it doesn't need a drop.
const _: Vec<u32> = {
let mut x = vec![];
let y = x;
y
};
// Old behaviour: error, as intended by the conservative check in
// `TransferFunction::visit_operand` that preserves the old `MaybeMutBorrowedLocals`
// behaviour; the borrow means x is still considered as needing a drop.
//
// New behaviour: ok, due to the loosening of the old `MaybeMutBorrowedLocals` behaviour.
const _: Vec<u32> = {
let mut x = vec![];
let r = &mut x;
let y = x;
y
};
// Old behaviour: accepted. The basic block boundary between the borrow and the move triggers the
// short-circuiting bug which causes us to lose information, effectively preventing the borrow from
// being observed. This is bad; the analysis is disrupted by basic block boundaries.
//
// New behaviour: ok, due to the loosening of the old `MaybeMutBorrowedLocals` behaviour.
const _: Vec<u32> = {
let mut x = vec![];
let r = &mut x;
let n = if 1 + 1 == 2 { 1 } else { 0 }; // makes the difference
let y = x;
y
};
fn main() {}
I don't believe so. In fact, the existing bug exposes some implementation details (basic block boundaries) and fixing the bug hides those details. |
`State::join` uses a short-circuiting `||` when it should use `|`. As a result: - `self.borrow` sometimes is missing some bits it should have; - which means `self.state.borrow.contains(local)` fails when it shouldn't; - which means `!self.state.borrow.contains(local)` succeeds when it shouldn't; - which means `self.state.qualif.remove(local);` runs when it shouldn't; In practice, this means that some analysis information gets lost across basic block boundaries, and the presence/absence of a simple branch or function call or loop can change whether code is accepted or rejected. Not good! This bug has been present since this code was first added five years ago in rust-lang#90214. This commit fixes that bug. But with the operator change alone we get this compile error when building std: ``` error[E0493]: destructor of `MaybeDangling<P>` cannot be evaluated at compile-time --> library/core/src/mem/maybe_dangling.rs:102:29 | 102 | pub const fn into_inner(self) -> P | ^^^^ the destructor for this type cannot be evaluated in constant functions ... 111 | } | - value is dropped here ``` This is a case where a basic block boundary was preventing this code from being rejected. We don't want to reject code that was previously accepted due to a bug. Fortunately we can instead accept some code that used to be rejected, by removing the qualif unconditionally in `TransferFunction::visit_operand`. The existing behaviour is conservative and the existing comment already says the check "might not be strictly necessary" and was only present to match an older implementation. Removing the qualif unconditionally makes the vanilla const drop check more precise, giving it some of the precision that the `const_precise_live_drops` feature provides (just some whole move cases, not the partial move cases), without requiring that feature's extra machinery. This can be seen in the `issue-65394.rs` test, which now compiles without needing the feature.
The last commit removed its only read, so it can be removed. With it gone, `State` has a single field and so can be replaced with `MixedBitSet<Local>`.
f6f03bd to
8c49816
Compare
|
I have also updated the commit message on the first commit to give a better description of what's going on. |
FWIW I have no idea whether that was intended. We never explicitly discussed being conservative here. I think it may just be an accident / leftover from an even older implementation. |
| /// Describes whether a local's address escaped and it might become qualified as a result an | ||
| /// indirect mutation. | ||
| pub borrow: MixedBitSet<Local>, |
There was a problem hiding this comment.
This looks kind of suspicious. The comment is correct that mutable borrows might change the qualif. We use qualif also e.g. for interior mutability, where None is fine but Some(Cell::new(...)) is not, and the code could use a mutable borrow to swap from one to the other.
Why is it correct to entirely stop tracking borrows here?
@oli-obk could be great to get your take as well since I think you know this code better than I do. :)
There was a problem hiding this comment.
Looks like we barely used borrow before... it was only ever checked if IS_CLEARED_ON_MOVE is true, which is only the case for the drop qualifs. I guess we must have some other check to adjust qualifs when a borrow is taken.
There was a problem hiding this comment.
There is a difference between
- behave differently during some access of a local if there is a borrow, and
- collect qualifs through borrows.
Qualif collection eagerly walks through a borrow to its local and collects everything and acts upon it. The thing that was changed here is that const qualif stops doing 1.
| // If a local with no projections is moved from (e.g. `x` in `y = x`), record that | ||
| // it no longer needs to be dropped. |
There was a problem hiding this comment.
| // If a local with no projections is moved from (e.g. `x` in `y = x`), record that | |
| // it no longer has the qualif (as requested by `IS_CLEARED_ON_MOVE`). |
| // The local is no longer initialized so we can remove the qualif. | ||
| self.state.remove(local); |
There was a problem hiding this comment.
With proposed semantics from move elimination RFC, move deallocates the local, and any borrows become invalid. In contrast, this is far from obvious why it would be correct now. Consider the following example running under tree borrows:
struct PanicOnDrop;
impl Drop for PanicOnDrop { fn drop(&mut self) { panic!(); } }
pub const fn main() {
let mut a = None;
let p = &raw mut a;
std::mem::forget(a);
a = None;
unsafe { std::ptr::write(p, Some(PanicOnDrop)) };
a = a;
}
const _: () = main();$ rustc +stage1 a.rs
error[E0080]: calling non-const function `<PanicOnDrop as Drop>::drop`
--> a.rs:11:15
|
11 | const _: () = main();
| ^^^^^^ evaluation of `_` failed inside this call
|
note: inside `main`
--> a.rs:10:1
|
10 | }
| ^
note: inside `std::ptr::drop_glue::<Option<PanicOnDrop>> - shim(Some(Option<PanicOnDrop>))`
--> library/core/src/ptr/mod.rs:848:0
note: inside `std::ptr::drop_glue::<PanicOnDrop> - shim(Some(PanicOnDrop))`
--> library/core/src/ptr/mod.rs:848:0
error: aborting due to 1 previous error; 1 warning emitted
For more information about this error, try `rustc --explain E0080`.There was a problem hiding this comment.
This code only runs when IS_CLEARED_ON_MOVE is set. So qualifs remain in control of whether this should be done. And for drop it seems fine since the drop flag is indeed cleared on move.
There was a problem hiding this comment.
The example passes const checks and executes live drop. I can't see how this can be fine, besides arguing that program has undefined behavior.
If the flag was actually unset at drop time, the drop wouldn't run.
There was a problem hiding this comment.
a = a; does indeed not clear the drop flag on a, so if this analysis thinks that that's a move from a then that does seem incorrect -- but that doesn't invalidate the general point about moves I think?
There was a problem hiding this comment.
There is definitely a room for some kind of justification, my point is simply that it requires something beyond operational semantics.
a = a is irrelevant, you can remove it.
There was a problem hiding this comment.
this is what was happening before this PR 😆
Yeah, but not in the right spot -- the move does unconditionally clear the drop flag. It does not make sense to me that we'd check borrow.contains there.
It is the a = None; where we should check borrow.contains: if a has been borrowed before, then we should not analyze the value on the right.
struct PanicOnDrop;
impl Drop for PanicOnDrop { fn drop(&mut self) { panic!(); } }
pub const fn main() {
let mut a = None;
let p = &raw mut a;
std::mem::forget(a);
a = None; // <-- this one
unsafe { std::ptr::write(p, Some(PanicOnDrop)) };
a = a;
}
const _: () = main();There was a problem hiding this comment.
Oh interesting. Yeah that does not sound great. This sounds like even before this PR it should be possible to write code that triggers the "calling non-const function" error. We do these days detect the UB from mutating
&i32even in CTFE, but on something like&(i32, Cell<i32>)we don't detect the UB from mutating the first field.
Ah no it just works out -- the UB we detect is just barely enough to avoid "calling non-const function" in the code that we accept based on shared_borrow_allows_mutation.
That is an accident though...
There was a problem hiding this comment.
The const-checker doesn't care about UB. It cares about making sure that a const only calls const fn. As in, syntactically, even in dead code or code unreachable because of UB, all function calls must be const fn calls. This is important because CTFE does not actually detect all UB and we never want to run non-const-fn during CTFE. This is mostly trivial except for drop calls which are not yet explicit in MIR by the time we do const checking, so we need this non-trivial analysis to predict whether a call will be present after drop lowering.
The analysis, as implemented now, is in some ways both more precise and less precise than drop elaboration. It doesn't predict a syntactical absence of non-const drops after drop elaboration. Rather, it tries to predict whether they might be executed when the code runs.
For example, it concludes that let a: Option<NonConstDrop> = None; is a not live non-const drop, even though drop is present after drop elaboration.
There was a problem hiding this comment.
Yeah so it's a funny mix. For let a: Option<NonConstDrop> = None; with nothing else in the MIR body we don't have to rely on any UB though. So while I agree it makes the correctness statement more tricky, I am not otherwise concerned.
The shared_borrow_allows_mutation situation is more tricky though. But I guess it's not worth a breaking change, we just have to update the comments to explain that this is fine because const-eval reliably aborts on the UB this is based on.
Which brings us back to this PR and the new example -- there, const-eval does not reliably abort, so even if this is UB, we can't use that UB. I think ideally we'd make it so that we do indeed mark a local as "does not need drop" when it is moved-out-of (what the PR does), and additionally we inhibit value-based reasoning on assignments to locals after the local has been (mutably) borrowed. That should work?
There was a problem hiding this comment.
With MIR move elimination semantics, an allocation is deallocated after move. Once implemented, I would expect it would take an extra effort to miss such UB.
const-eval: ICE when we hit a non-const fn We made this a "nice" error solely so we can test miri-unleashed better, but it was never meant to be an error that users can actually get -- just a second line of defense in case there is a bug in our const checking logic. This seems to [cause some confusion](rust-lang#161627 (comment)) so let's make it an ICE when Miri is not unleashed. This also uncovered that even if `const_precise_live_drops` finds a problem, we still run the code that was found to not be const-safe: rust-lang#163973. r? @oli-obk Cc @tmiasko
const-eval: ICE when we hit a non-const fn We made this a "nice" error solely so we can test miri-unleashed better, but it was never meant to be an error that users can actually get -- just a second line of defense in case there is a bug in our const checking logic. This seems to [cause some confusion](rust-lang#161627 (comment)) so let's make it an ICE when Miri is not unleashed. This also uncovered that even if `const_precise_live_drops` finds a problem, we still run the code that was found to not be const-safe: rust-lang#163973. r? @oli-obk Cc @tmiasko
const-eval: ICE when we hit a non-const fn We made this a "nice" error solely so we can test miri-unleashed better, but it was never meant to be an error that users can actually get -- just a second line of defense in case there is a bug in our const checking logic. This seems to [cause some confusion](rust-lang#161627 (comment)) so let's make it an ICE when Miri is not unleashed. This also uncovered that even if `const_precise_live_drops` finds a problem, we still run the code that was found to not be const-safe: rust-lang#163973. r? @oli-obk Cc @tmiasko
const-eval: ICE when we hit a non-const fn We made this a "nice" error solely so we can test miri-unleashed better, but it was never meant to be an error that users can actually get -- just a second line of defense in case there is a bug in our const checking logic. This seems to [cause some confusion](rust-lang#161627 (comment)) so let's make it an ICE when Miri is not unleashed. This also uncovered that even if `const_precise_live_drops` finds a problem, we still run the code that was found to not be const-safe: rust-lang#163973. r? @oli-obk Cc @tmiasko
const-eval: ICE when we hit a non-const fn We made this a "nice" error solely so we can test miri-unleashed better, but it was never meant to be an error that users can actually get -- just a second line of defense in case there is a bug in our const checking logic. This seems to [cause some confusion](rust-lang#161627 (comment)) so let's make it an ICE when Miri is not unleashed. This also uncovered that even if `const_precise_live_drops` finds a problem, we still run the code that was found to not be const-safe: rust-lang#163973. r? @oli-obk Cc @tmiasko
Rollup merge of #163972 - RalfJung:non-const-eval, r=oli-obk const-eval: ICE when we hit a non-const fn We made this a "nice" error solely so we can test miri-unleashed better, but it was never meant to be an error that users can actually get -- just a second line of defense in case there is a bug in our const checking logic. This seems to [cause some confusion](#161627 (comment)) so let's make it an ICE when Miri is not unleashed. This also uncovered that even if `const_precise_live_drops` finds a problem, we still run the code that was found to not be const-safe: #163973. r? @oli-obk Cc @tmiasko
I encountered a bug in
FlowSensitiveAnalysisand it led in an interesting direction.