-
Notifications
You must be signed in to change notification settings - Fork 59
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
Stacked Borrows: track raw pointers more precisely #248
Comments
When getting a raw pointer as argument it would be unknown if the raw pointer is "integer-available". |
Sure. But that's fine. This is something optimizations have to live with, but that is likewise true in C/C++. LLVM IR has some bugs in this area, but obviously there's no way to design a semantics for a buggy optimizer. So the only real guidance we have is to allow as little as possible but as much as needed. |
In #263 (now closed because it duplicates this), I mentioned that the current model is suprising to me, and at issue with the working model I have for lccc (which is incredibly desireable because of the optimizations it enables, a small example of which is in that issue). lccc tracks raw pointers exactly the same as it tracks references, because it treats all pointers (of which references and raw pointers are both subsets) the same. As for int->pointer casts, that is fun, but the rule that C++ (and thus lccc) has is that if you ( |
@chorman0773 what happens if multiple pointers give the code defined behavior, is that not possible? |
In C++, at least, that's not possible. The only way this would work is if you had multiple objects of the same type at that address, which is not possible. In rust, I also don't think it's possible, necessarily. However, the actual chosen one would be unspecified (but note that in all cases, this is somewhat oversimplified). |
There isn't really any C++ rule on how provenance works. The C and C++ committees are progressing towards adopting one, but this is a recent development, and the current specs say very little about it, leaving it unclear whether integers have provenance and even whether provenance exists at all. Meanwhile, among major C++ compilers, at least GCC and Clang have open bugs regarding how exactly pointer<->integer conversions should be optimized. That's part of what makes coming up with rules for Rust so difficult, especially since Rust is somewhat bound to whatever memory model LLVM ends up with. |
Would something along these lines make any sense? (Assuming no issues with e.g. LLVM)
A few corollaries:
Note: I'm not very familiar with Stacked Borrows, so I'm not sure of the details of how that would work with this. |
ptr->int->ptr expanding provenance is one of the things I do not want. The code I showed in the linked issue was an exposition as to why that would be undesirable. I'd be fine with it destroying provenance, or allowing the previous provenance of the pointer to pop up elsewhere (provided the borrow item still exists), but allowing it to extend without bound negates every other rule in whatever list you may use. |
FWIW, here is how I would analyze the
This write implies a pointer-to-reference conversion, which has the following preconditions:
|
I think this is impossible to avoid. Specifically, the only way to avoid it is to give integers provenance, and that has all sorts of even-less-desirable consequences, or to give pointers no provenance, and that kills most of the alias analyses that LLVM, GCC and likely also lccc do. I am not saying I like the fact that ptr->int->ptr is not a NOP. But this is an unfortunate consequence of a bunch of other properties that it would be even more problematic to lose. Basically, provenance and integer-ptr-casts are mutually incompatible. Most languages only have one: Java, ML and other high-level languages have provenance but no integer-ptr-casts; assembly has integer-ptr-casts (well it doesn't even need a cast, there simply is no distinction) but it does not have provenance. If you want to have them both in the same language, there will be suffering and non-intuitive behavior. I believe this to be unavoidable, and I will soon (finally) publish the blog post laying down this argument in more detail. |
Perhaps not. If converting back to a pointer is defined to assert provenance, I think there is a way to get that information. It can be pulled from the borrow stack. |
There may be multiple items on the stack that could be used to get the provenance. Also you may do something like |
Ah, yeah, that's true. Arithmetic into int->ptr casts are fun. Would this still be an issue with the deffered version, then?
The problem is that by having the ptr->int->ptr round trip extend provenance, it also similarily kills such analysis, as I mentioned. After all, black-box code could, as I demonstrated in the linked issue, perform this round trip, and effectively "launder" the pointer, but without even the implications of C++'s |
My proposed semantics would make it UB for a function (i.e. the black-box code) accepting just a pointer to use a pointer or create a reference with a wider provenance region, due to the "memory accessible from that point in the code" rule. (I have changed the rule for |
Actually, that might work well then. |
No, it doesn't kill it in any comparable way. In particular, Stacked Borrows still works. While we did those proofs in a semantics without int-ptr-casts, but we did deliberately make it so that reference -> raw-ptr -> reference "extends provenance", precisely to show this point. Raw pointers have no provenance, serving as a "model for integers". So, we have a proof that very strong alias analysis is possible even when there are provenance-extending roundtrips in the language. We have not done the proof if the presence of int-ptr-casts, but I see no reason why they would make this any more difficult. |
It does, however, mean that ptr-to-int casts cannot be dead-code eliminated without potentially introducing UB. If we take @chorman0773's original example and change it to a ptr-to-int cast rather than just reference-to-raw-ptr (since we assume raw pointers will have provenance in the future), we're talking about a stray &mut ptr.exclusive_access as *mut _ as usize; where the result of the ptr-to-int conversion just gets thrown out. This function containing it goes on to call another function, passing it a reference to a different field adjacent to This is annoying. But I think C++ has the exact same problem, under the Cerberus PNVI model that was identified as by WG21 the most promising path forward (see my last comment). From a C++ perspective, the Note that if the second function were just guessing the address, the Cerberus paper's model of nondeterminism would justify making it UB even if the guess happened to be right. This is interesting, and probably worth discussing in a Rust context. However, that doesn't apply to @chorman0773's example because it has a way to compute the address. |
It would not, actually. The equivalent C++ code would have undefined behaviour (strict aliasing) by the round trip rule, then the final reinterpret_cast would yield the same pointer, to array[0], because pointer-interconvertibility is stopped by arrays (which was pointed out in my original, that an array is used to prevent reachability from leaking out in lccc), and usize is not similar to Interesting. |
Without necessarily advocating for this. I don't entirely follow the argument for why giving integers provenance is a fatal problem.
This is only true if all optimizations operate on the same model. Couldn't you have two optimizations passes: on the first pass, everything has provenance, and you are indeed unable to optimize integer (or pointer) expressions such as the example. Then you erase all provenance and do a second set of lower-level optimizations. This would allow |
We want to push many optimizations to MIR, but the provenance must still be preserved when lowering to LLVM-ir, so those optimizations would only be possible by LLVM in that case. Also a reloading pass after regalloc could benefit from provenance too I think. After regalloc it is way too late to perform GVN and similar optimizations. |
You might be missing that I modified your example to convert |
The original code was a double transmute on ptr, which is equivalent to effectively |
Sorry, I neglected to include my version of #include <stddef.h>
#include <stdint.h>
struct Interesting {
size_t array[1];
void *exclusive_access;
};
__attribute__((noinline, noipa))
void does_something(size_t *array_ptr) {
uintptr_t array_ptr_i = reinterpret_cast<uintptr_t>(array_ptr);
uintptr_t exclusive_access_ptr_i = array_ptr_i
- offsetof(Interesting, array) // for clarity, though this is guaranteed to be 0
+ offsetof(Interesting, exclusive_access);
void **exclusive_access_ptr = reinterpret_cast<void **>(exclusive_access_ptr_i);
*exclusive_access_ptr = nullptr;
}
void do_interesting_things(Interesting *ptr) {
void *x = ptr->exclusive_access;
reinterpret_cast<uintptr_t>(&ptr->exclusive_access); // unused
does_something(&ptr->array[0]);
ptr->exclusive_access = x; // store
} |
I can see that having two distinct phases would be limiting. You could avoid that by having both pointer representations in the IR: when you apply certain optimizations (such as propagation of equalities) you also convert the pointers to the form without provenance. |
This strikes me as similar to arithmetic optimizations with undefined overflow. E.g. you have something like int foo(int a, int b) {
return a ? a * (b + 1) : 0;
} and optimize it to return a * (b + 1); but now return a * ((wrapping_int)b + 1); with Anyway I suspect this trick doesn't work as well with pointers, because the presence of a single unbounded provenance pointer can interfere with optimizations on all other pointers, since they might alias. But I'd be curious to work something like this out and see how much optimization you can keep. |
I don't think this is necessarily the case; at least in stacked borrows you can definitely have |
Yes. That is a consequence of any semantics where ptr-to-int casts have a "broadcast" side-effect, such as this early and simple model of ptr-int-casts. This is not great, I admit. It's just the best I know how to do.
The details of this are tricky because you have to prove that guessing is even required -- since memory is finite, one can construct a situation where the non-determinism is constrained to a single possibility and then there is no UB. But I digress.
This works in principle, yes -- we can have two IRs with (almost) the same syntax and different semantics. This also causes problems, though. On top of what @bjorn3 said, we also need to be really careful to never apply IR1 optimizations to IR2 programs.
That doesn't help, since when we optimize some function The mere possibility of a ptr/int with provenance already breaks optimizations. This is similar to how the mere existence of |
I see, yeah that would be a problem. You could still avoid two IRs by modelling them as different types within the same IR. Initially all pointers have type
If there are separate pointer types, then you can have optimizations defined generically. eg. if my optimization depends on "equality" of values, then it will automatically work correctly both before and after the lowering step, because |
And then you need to make it UB to load a ptr-with-provenance at type |
The thing is that you may still want to have pointer provenance after regalloc, which would likely run after your proposed lowering step, to implement reloading. |
A few years ago, I wrote a garbage collector for the (abandoned) RustyScheme project. When writing it, I heavily relied on the results of int → ptr casts having unbounded provenance. Specifically, there were many cases where the code knows that an object exists at some address X, but there is absolutely know way that the compiler could know this. Furthermore, for performance reasons, objects were represented as thin pointers, and their actual size was stored in the objects themselves. So being able to create a pointer from nowhere was critical. Similarly, in OS development, it is common to need to cast seemingly-arbitrary numbers to pointers and then dereference them. MMIO is just one example. |
I am not sure what exactly you mean by "unbounded", but in the most general interpretation, this is fundamentally incompatible with any optimizations based on aliasing guarantees established by references (and, likewise, it is fundamentally incompatible with C's A concrete example (ideally in code) of what you mean by "unbounded provenance" would help. |
It wouldn't just be that. All optimizations based on provenance die in the
face of an operation that can increase it without restriction. This is why
C++17s std::launder has a reachability requirement, so as to not
fundamentally undermine all optimizations relying on this. After all,
provenance is not necessary to reason about local (analyzed) code, but is
necessary to reason about other (unanalyzed) code.
…On Tue, Dec 22, 2020 at 06:56 Ralf Jung ***@***.***> wrote:
When writing it, I heavily relied on the results of int → ptr casts having
unbounded provenance.
I am not sure what exactly you mean by "unbounded", but in the most
general interpretation, this is fundamentally incompatible with *any*
optimizations based on aliasing guarantees established by references (and,
likewise, it is fundamentally incompatible with C's restrict). When
someone uses a mutable reference, there must not be any way to legally
alias with that reference, or else we might just not have any aliasing
requirements at all.
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#248 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ABGLD24TIUIVYN4ZVI2FV3TSWCCQBANCNFSM4QL4MXLQ>
.
|
Presumably you could cast all pointers returned by your GC's allocation function to an integer before returning them, broadcasting the fact that it exists at that address. |
The garbage collected heap is a sea of aliasing, mutable pointers. Pointers are often tagged, and any object can (to a good approximation) point to any other object. Furthermore, some GC algorithms need to be able to go from one object to the object after it, even though there are no pointers from one to the other! That’s what I meant by “unbounded provenance”: I need to be able to take a It’s been years since I last touched that code, but I believe the situation is something like: /// A Scheme value
#[repr(C)]
struct Value {
ptr: Cell<usize>;
}
#[repr(C)]
struct ConsBody {
header: Cell<usize>,
head: Value,
tail: Value,
}
impl Value {
/// If `self` is a cons cell, returns it. Otherwise, returns `Err`.
///
/// # Safety
///
/// `self` must be rooted for the GC.
unsafe fn check_cons(&self) -> Result<&ConsBody, ()> {
if (self.ptr & 0x7) == CONS_TAG {
Ok(self.ptr as *const ConsBody as &ConsBody)
} else {
Err(())
}
}
/// Get the size of this object in the heap.
///
/// # Safety
///
/// `self` must be rooted for the GC.
unsafe fn size_of(&self) -> usize {
// low-level stuff here
}
/// Get the next object in the heap, or `None` if this is the last one.
///
/// # Safety
///
/// `self` must be rooted for the GC, and `heap_end` must be the actual end of the heap.
unsafe fn next_object(&self, heap_end: usize) -> Option<&Value> {
// more low-level stuff here
}
} If this were C or C++, I would just give up and use |
The idea is that you should get a pointer that is "at least as valid" as all the ptrs that were before cast to an integer and yielded this particular integer. However, the details of this have not been properly worked out yet in the context of either Stacked Borrows or
That sounds like a potential problem, but I assume that could be fixed once rust-lang/rust#73394 is stabilized? |
Interesting case where I don't see a viable way to avoid losing provenance info: A common way to implement an adaptive mutex is some kind of tagged pointer scheme such that the low bits contain the lock state, and the high bits contain a pointer to a waitset no if there are waiters (or something). In general I think tagged pointers are tricky for this discussion but I think you can make it work (by using unintuitive code). Something like:
(I'm assuming this does indeed keep provenance as it needs to be, and also seems like a good idea — neither of which I'm certain about) Sadly, this is about as far as I can go with it. Unfortunately, I need to keep this state in an atomic variable. AtomicPtr doesn't have fetch_and/fetch_or/etc, AtomicUsize does. I could cast Giving these up isn't super viable always (altho for some algos it may be), so this probably makes the decision for me here, as the alternative is a cas loop, which is terrible for perf. Anyway, this seems like a unconsidered problem case¹, so i thought it might be good to record. ¹ But to be clear not at all a rare one. Lock free code often has to do stuff like this, where pointers and integers are packed together, especially on 64 bit (but on 32 bit too, as 64 bit atomics are more costly on some 32 bit arches). Now that I hear miri has a sexy new data race detector, I guess it will be worth actually testing that part of the code under miri more aggressively. |
This seems related to crossbeam-rs/crossbeam#490. Indeed while sequential code can implement "provenance-preserving pointer tagging" in various ways, doing the same atomically does not seem possible without a CAS loop. It is not hard to specify atomic |
This is resolved now, Stacked Borrows tracks raw pointers with individual tags. Integer-pointer casts are nominally handled via angelic choice; this plan has some problems but it seems unlikely that we will want to fix those with the heavy hammer of untagged raw pointers. |
Currently, Stacked Borrows teats all raw pointers as identical -- if any raw pointer is allowed to access some memory, then all are. This does not reflect LLVM's pointer treatment though, and it leads to confusing behavior (e.g. rust-lang/miri#1526).
I have long planned to make tracking more precise, but there's a big open question in how to handle integer-pointer casts. We cannot (and do not want to) track integers, so we have to somehow mark memory as "integer-available" when casting to int, and then use that when casting back from an int.
The text was updated successfully, but these errors were encountered: