How to think properly about binding memory

I’m trying to use the compression_stream type from the Compression framework, and I have some questions about the concept of binding raw memory to a type. compression_stream requires that I set its src_ptr property, which is of type UnsafePointer<UInt8>. My code will receive Data as the input to be compressed. This PR seems almost to help me, but not quite. It seems that the implicit conversion only occurs when passing a raw pointer to a function declared in C, but not when assigning one to a property declared in C. So, I can get an UnsafeRawBufferPointer from Data by calling withUnsafeBytes, and now I need to get the base address as an UnsafePointer<UInt8>. I can do this by calling pointer.bindMemory(to: UInt8.self).baseAddress!, but upon investigating this I was led down the rabbit hole of trying to understand the rules around bindMemory(to:). At first I was quite confused about what bindMemory(to:) actually does, because ChatGPT kept telling me that: 1) the underlying memory is unaffected, 2) the returned typed pointer is not the thing that makes the memory bound to a type, which is to say that I can discard the typed pointer and the memory is still bound to the type, 3) the bound type is not somehow stored in the runtime or anywhere else, and yet 4) it is essential that I respect the contract that the memory is only bound to one type at a time. Eventually I got it to clarify things a little bit when it said that the optimizer will assume that the contract is honored and might do something wrong otherwise, but it wouldn't commit to saying that the optimizer is the reason that honoring the memory-binding contract is tangibly important.

So, I have a concrete question and some more abstract questions:

Concrete Question: As far as I understand, it is technically possible that Data references memory that is already bound to a type, so it is not actually safe to receive Data as input and use withUnsafeBytes + bindMemory(to:). What is the recommended, safe way to do what I’m trying to do?

More Abstract Questions: Is it the case that the optimizer is the only thing that depends on the contract regarding binding memory? Does the contract change at all when Swift code is interoperating with C code? Does memory that was allocated by a C function have to obey the same rules? Given what I’ve written here, does anybody have any clarifying, generalized statements that might help me form a clearer mental model about binding memory?

Is it the case that the optimizer is the only thing that depends on the contract regarding binding memory?

Yes, but the question assumes that the compiler you're using today is all that matters. The point of a language spec is that we can't predict what some future compiler will do. I always assumed we would have a sanitizer mode that verified strict aliasing (because of how much grief it's caused in the C world), but that still hasn't happened. Instead there's much more focus on getting Swift developers to just stop using pointers.

At any rate, the only dangerous situation occurs when the program has two accesses to the same memory location using "incompatible" pointee types, at least one of those accesses is a write, and there is no "optimization barrier" in between them. bindMemory (and withMemoryRebound(to:)) simply serves as that optimization barrier.

Calling out to a C function is as good an optimization barrier as calling bindMemory, assuming that C function doesn't have some special annotation telling the compiler to ignore memory effects.

assumingMemoryBound(to:), on the other hand, lets you take responsibility for pointer safety without creating a new optimization barrier. That makes sense when you know the code only accesses that location consistently using the same Pointee type within some region of exclusive access. As long as the memory is (re)bound before and after that region of exclusive access then it's safe. Of course, malloc/free or UMP.allocate/deallocate always sufficiently prevent the optimizer from assuming a pointer to the same address should have the same type.

assumingMemoryBound(to:) also makes sense when converting between compatible types. A tuple, struct, or enum is always compatible with its member types. Sometimes you need to deliberately create a pointer that aliases (Int, ...) with Int for example.

Does the contract change at all when Swift code is interoperating with C code?

Swift's rules for Unsafe[Mutable]Pointer<T> are inherited from C, mainly because Swift code is often tightly integrated with C, and Unsafe[Mutable]Pointer<T> is primarily intended for bridging to C. C has special cases for char * and unsigned char *. Those don't carry over to Swift just to avoid complicated Swift's rules. But direct calls to C are more forgiving for this reason: SE-0324 Relax diagnostics for pointer arguments to C functions.

Does memory that was allocated by a C function have to obey the same rules?

It doesn't matter who allocated the memory, only where the memory is being accessed. As mentioned above, calling C from Swift and vice-versa is almost always a sufficient optimization barrier (unless the C function has some special annotation).

6 Likes

One of the best posts I’ve read about this topic are over at the Apple Developer Forums (I had to dig to find this post again.) @Andrew_Trick's answer above addresses a lot of this in a surmised way.

I’m gonna reproduce that thread here, because the posts over there have deteriorated a bit (code formatting has been borked) and it might help others find it:


Part 1

One really powerful optimization that the compiler wants to do whenever it can is removing accesses to memory. For example, consider this code:

func setPointerAndPrint(ptr1: UnsafeMutablePointer<Int>) {
   ptr1.pointee = 1
   print(ptr1.pointee)
}

The compiler can see very clearly that, by the time we reach print(ptr1.pointee), its value could only possibly be 1, so it can turn that line into print(1) instead without changing your program’s behavior. This is a small savings for this particular bit of code, but in the general case, this kind of optimization can enable much more powerful ones. For example, if this rule turns a guard !ptr1.pointee.isMultiple(of: 2) into guard !1.isMultiple(of: 2), then the compiler could determine that the check will never fail and delete the whole guard statement. Small, individual optimizations like this can snowball into very large performance differences.

But this only works if the compiler can tell that none of the code between those lines could change ptr1.pointee. For example, if the code looked like this:

func setPointerAndPrint(ptr1: UnsafeMutablePointer<Int>) {
   ptr1.pointee = 1
   someFunctionInAnotherModule()
   print(ptr1.pointee)
}

Then Swift can’t turn print(ptr1.pointee) into print(1), because it doesn’t know what someFunctionInAnotherModule() might do—it could set ptr1.pointee to a different value. So it doesn’t remove that memory access.


One especially vexing aspect of this problem is called “aliasing”. Aliasing is when two pointers point to the same memory, so a write to one pointer will change the value read from the other. Maybe 95% of the time, two pointers involved in the same operation don’t alias each other—but if the compiler simply assumes that they don’t alias, the code it generates will behave incorrectly in the remaining 5% of cases.

For example, consider this function:

func setPointersAndPrint(ptr1: UnsafeMutablePointer<Int>, ptr2: UnsafeMutablePointer<Int>) {
   ptr1.pointee = 1
   ptr2.pointee = 2
   print(ptr1.pointee)
}

Almost all of the time, the write to ptr2.pointee won’t affect ptr1.pointee, so the compiler would like to convert print(ptr1.pointee) into print(1). But you could pass the same pointer to both parameters, and then that change would produce an incorrect result, so the compiler can’t perform that optimization. It’s rather irritating to give up that performance improvement.


But what about this function?

func setPointersAndPrint(ptr1: UnsafeMutablePointer<Int>, ptr2: UnsafeMutablePointer<UInt>) {
   ptr1.pointee = 1
   ptr2.pointee = 2
   print(ptr1.pointee)
}

It is at least theoretically possible to make ptr1 and ptr2 point to the same address, but it doesn’t really make sense to do that, because the two are of different types! Maybe there’s a 0.001% situation where someone is intentionally doing something really weird, but we’d really like that speedup in the remaining 99.999% of situations, and we don’t want to lose such a big win for such a niche situation. 5% is us being careful; 0.001% is us sulking in our tents.

So we don’t sulk. Instead, we say that, if two typed pointers do not have compatible pointee types, they are not allowed to alias, and allow the Swift optimizer to assume this.


To be continued...

Part 2

But it’s very difficult for people and tools to reason about a global rule like “two pointers of different types cannot point to the same address”. It’s just not actionable. How do you know if your code is correct?

So instead, we break that global rule down into a bunch of local rules which have the same effect as long as everyone follows them, like:

  1. Use assumingMemoryBound(to:) only when you know there is other code which has bound that memory to that type.

  2. During withMemoryRebound(to:capacity:_:), pointers to that memory using the original type can’t be used; after it finishes, pointers using the rebound type can’t be used.

  3. Use bindMemory(to:) only when any pointer to that memory that uses a different type should no longer be used.

Unlike the global rule, which gives you no guidance for how to actually make it happen, these rules are ones you at least have a hope of verifying by looking at your own code:

  1. “Okay, I can see that up here, this was a pointer to Foo, and then I turned it into an UnsafeRawPointer and it came out down here, so it’s safe to assumingMemoryBound(to: Foo.self) here.”

  2. “Okay, I can see the code in this closure and I know it doesn’t use the pointer I called withMemoryRebound(to:capacity:_:) on, so it’s correct.”

  3. “Okay, nothing should be using the memory in my allocator’s free heap, so it’s safe to bindMemory(to:) the type we want to allocate.”

When we say that “using bindMemory(to:) invalidates existing typed pointers”, that’s just another way to phrase rule 3—that you should only call bindMemory(to:) if any existing pointers with the old type will no longer be used.


So, I think that covers why the language has this rule: it helps to make it illegal to use two incompatibly-typed pointers that point to the same memory, which allows the optimizer to assume that incompatibly-typed pointers don’t affect each other, so it can generate faster code.

So, what happens if you violate the rules? Well, anything (or nothing) could happen, but I can give you some hypothetical examples.

Remember this function?

func setPointersAndPrint(ptr1: UnsafeMutablePointer<Int>, ptr2: UnsafeMutablePointer<UInt>) {
   ptr1.pointee = 1
   ptr2.pointee = 2
   print(ptr1.pointee)
}

It could print “1” instead of “2”, because it assumed that setting ptr2.pointee couldn’t affect ptr1.pointee.

Now, how about this function?

func doScaryOperationSafely(ptr1: UnsafeMutablePointer<Int>, ptr2: UnsafeMutablePointer<UInt>) {
   print(“value was \(ptr1.pointee)”)
   ptr2.pointee = 2
   guard !ptr1.pointee.isMultiple(of: 2) else {
     print("warning: almost melted down!")
     return
   }
   doScaryOperationThatWillMeltDownIfThePointeeIsEven(ptr1)
}

It could use the value retrieved before ptr2.pointee was set in the guard statement, and therefore call doScaryOperationThatWillMeltDownIfThePointeeIsEven(_:) when the pointee is 2.

Really, the sky’s the limit on how bad it could get. Just as small optimizations can snowball into large performance improvements, small mis-optimizations can snowball into large behavior differences.

Bonus

This answer adds to the previous informative and correct answer in order to address this part of the question: "what could theoretically go wrong...considering we are dealing with primitive C compatible data types/structs"

Here's an example of why Swift needs strict pointer types, and why using bindMemory incorrectly on "primitive C compatible data types" will lead to incorrect program behavior, even if the Swift compiler was maximally conservative and did not perform any pointer-related optimization.

Building this code with for release (-O) will cause the precondition to trigger because the C compiler has optimized addTenTimes assuming that S1 * and S2 * do not alias:

file.h

struct S1 {
	int i1;
} S1;

struct S2 {
	int i2;
} S2;

void addTenTimes(struct S1 *s1, const struct S2 *s2);

file.c

void addTenTimes(struct S1 *s1, const struct S2 *s2) {
	for (int i = 0; i < 10; ++i)
		s1->i1 += s2->i2;
}

file.swift

// Initialize S1.i1.
let s1Ptr = UnsafeMutablePointer<S1>.allocate(capacity: 1)
s1Ptr.pointee.i1 = 1
// Rebind S1 to S2--they are layout-compatible.
let s2Ptr = UnsafeRawPointer(s1Ptr).bindMemory(to: S2.self, capacity: 1)
// Call a C routine with aliased pointers.
addTenTimes(s1Ptr, s2Ptr);
precondition(s1Ptr.pointee.i1 == 1024)

There are other cases in which the C compiler cannot produce unexpected behavior, but the Swift compiler theoretically could. That doesn't mean the the current Swift compiler will produce unexpected behavior. There is some leeway between what the compiler is allowed to do vs. what the current compiler version does. Imagine running a sanitizer that enforces the undefined-behavior rule for typed pointer access:

It is invalid to access a typed pointer in Swift when the accessed memory location is bound to a type that is incompatible with the pointer type.

If that hypothetical sanitizer passes when running all paths in your code, then some future compiler can do anything it is allowed to do within the rules and your program behavior will not change. That's a great property. Without such a sanitizer though, it is up to you to follow the simple rule stated above.

There are advantages to Swift being even more strict than C in this regard:

  • A single, consistent rule means that Swift programmers won't be misled when reading code that appears to break the rule, but actually takes advantage of a special case in the language.

  • Swift pointer semantics only depend on the nominal pointer type, not the generic pointee type. So code that works on UnsafePointer<T> has the same pointer safety semantics for all T.

  • Overall program type safety is improved when pointers types are fully enforced. A pointer type mismatch is more likely a legitimate programming error rather than a deliberate attempt to reinterpret a type.

  • Swift pointers won't undermine Swift's stronger enforcement of non-pointer types. Unlike C, Swift checks signed/unsigned integer conversions on values. C considers types qualified by different signed or unsigned qualifiers to be pointer-compatible, while Swift does not. If Swift allowed those types to be pointer-compatible, then programs could accidentally bypass Swift's normal type conversion checks just by using pointers.


The end! I corrected the formatting here manually, and it only occurs to me now that I probably could have asked ChatGPT to do it, but oh well, here we are. Hopefully it helps!

17 Likes

Thank you @Andrew_Trick and @mattcurtis for providing all of this valuable info. I have one more memory-binding-related question which is:

Is it legal to rebind memory that was not yet bound? In my case I’m getting the UnsafeRawBufferPointer from Data and, though it is unlikely, it could technically reference memory that is already bound, so I’m using withMemoryRebound(to:_:) rather than bindMemory(to:), which led me to wonder if this is legal even when the memory was not bound to anything in the first place.

Sorry, I realized that actually the answer to this seems to be stated pretty clearly in the documentation:

After executing body, this method rebinds memory back to its original binding state. This can be unbound memory, or bound to a different type.

1 Like