Pitch: Implicit Pointer Conversion for C Interoperability

+1

I don’t want to derail this current thread, but one scenario where I would like to know the correct thing to do is as follows:

I start with a buffer of Floats, and I want to transform its values.

Some parts of the transformation involve floating-point operations, while other parts are best done as integer operations directly on the storage bits.

In order to achieve optimal performance, I want to use a vectorized library like Accelerate, and I don’t want to allocate any additional space.

So my algorithm would look something like this:

func foo(_ buffer: UnsafeMutableBufferPointer<Float>) {
  // use float Accelerate functions on `buffer`
  
  // start treating the memory stored in `buffer` as holding `Int32`
  
  // use int32 Accelerate functions on `buffer`
  
  // start treating the memory stored in `buffer` as holding `Float` again
  
  // use float Accelerate functions on `buffer`
}

The only APIs that looks promising are the memory-binding ones, but everything I’ve ever read on the subject says that regular programmers should never have to use them.

So what’s the right way to do this?

Since no-one else is answering I'll venture the following block for the middle of your code:

      bufffer.withMemoryRebound(to: Int32.self, capacity:?) { _ in 
           // use int32 Accelerate functions on `buffer`
      }

Though I have no clear idea what the concept of "binding" is.

Ok, I'm curious, what are you actually trying to do? That this would be necessary suggests either a missing API in accelerate or that you're doing something very niche.

I am doing something very niche, and I want to know the right way to do it within Swift’s memory model.

1 Like

To your original question, this is an especially fussy example because it involves the pointer semantics of two languages (C and Swift), I would note that the usual C "solution" to this ("just cast the pointer to int32_t *") is actually not allowed in C (it's an aliasing violation--see Shafik's excellent intro here: What is Strict Aliasing and Why do we Care? · GitHub); it just happens to work most of the time, especially when the function is compiled in a separate module, because compilers mostly let people get away with it.

So if this were all in C, you'd formally be dead in the water, but in practice most people would probably use a cast and everything would be fine until it wasn't (probably when someone vended libraries compiled for whole program optimization someday in the future).

withMemoryRebound has the requirement: "The type T must be the same size and be layout compatible with the pointer’s Pointee type", which you do satisfy. So far so good. After which you have a pointer of the correct type to use the C API, so that's fine.

Now, there's still a question of the interaction of this with the C memory model; the fact that we laundered the pointer through Swift does not obviously get us out of the strict aliasing rule. Specifically, does withMemoryRebound change the effective type (in the C standard sense) of the pointed-to object? If yes, everything is hunky-dory. If no, you're in the same situation that you were in with C (it probably "works" today, but is formally outside the language semantics). This is a somewhat subtle question; my inclination is to say "yes", but I will defer to @Andrew_Trick.

9 Likes

So, I have looked at the actual PR, and we need to be careful about how we extrapolate type checker performance from the experiments on hand. What you've established with your testing is that the presence of this mechanism, when not actually in use, isn't a drag on performance. That's good, but we can't extrapolate from that to address the concerns about exponential behavior expressed, e.g., here and here. Those are more about worst-case behavior, e.g., when overloading and implicit conversions interact to blow up the potential solution space.

If I wanted to implement user-defined implicit conversions, I would start by subsuming the existing implicit conversions into a new implementation. While CGFloat <-> Double cannot all be subsumed, one could pick a direction to use the new implementation (say, CGFloat -> Double, which is at least never lossy) and leave the other direction with the old implementation. Then, subsume other implicit conversions as appropriate... can T -> T? be handled by the model? What about the inout to pointer conversions? Doing this lets one validate both the design (does it subsume the bespoke conversions we have?) and the implementation (because now lots of code will go through the new path). Moreover, this is the kind of refactoring that could be merged into the Swift repository as soon as it's ready, rather than hanging out in an all-or-nothing feature branch.

Doug

9 Likes

This is the currently documented requirement but it's too onerous. We didn't know how to make the API clear about which type the capacity referred to, so punted on it at the time. We need to formally drop the "same size" requirement and fix the documentation.

Right. Those two C APIs are formally incompatible. Actual correctness in C relies heavily on library boundaries. It's safe for both APIs to work on the same memory as long as they only share pointers through their argument lists and the compiler never optimizes across both APIs in the same compilation unit.

Swift's responsibility in this situation is providing a way to handle those incompatible pointers so they aren't accessed in the same "region" of optimization. In particular, we don't want programmers passing two conflicting pointers to the same C API. It does that by giving you a way to bind memory to a type for some duration. In fact, as long the C APIs are only called from Swift, we're no longer relying on library boundaries for correctness.

4 Likes

This is the intended use case for withMemoryRebound(to:capacity:)--deliberately bridging C APIs that use incompatible pointer types. It has the semantics you outline above. The best way to understand it is just to read the 4 line implementation. Binding memory throws up an optimization barrier. On each side it's the programmer's responsibility to only use the correct pointer type. The compiler could concievably catch most violations of this API. So it's ugly but relatively safe.

3 Likes

I don't think this slope is all that slippery. I was looking at the actual set of implicit conversions modeled in the implementation, as well as the two recent pitches for adding conversions (this one, as well as mutable-to-immutable pointer conversions), and there is an overarching theme: C compatibility.

There are 21 different "conversion kinds" in that list, ignoring DeepEquality which means the types are equivalent. Here's how they break down into categories:

Intrinsic to Swift subtyping:

  • Superclass: subclass-to-superclass.
  • Existential: converting a concrete type to an existential type, e.g, Int to Encodable.
  • MetatypeToExistentialMetatype: converting a concrete metatype to an existential metatype, e.g., Int.Type to Encodable.Type.
  • ExistentialMetatypeToMetatype: converting an existential metatype to a class metatype, e.g., because a generic parameter has a superclass bound.

Swift implicit conversions:

  • ValueToOptional: converting T to T?.
  • OptionalToOptional: converting T? to U? when T can be converted to U.

Motivated by C:

  • InoutToPointer: allows passing &x to a function taking a pointer, to aid in C interoperability
  • ArrayToPointer: allows passing an array or address of an array to a function taking a pointer, to aid in C interoperability
  • StringToPointer: allows passing a string to a function taking a pointer to Int8, UInt8, or Void, to interoperate with C string functions
  • PointerToPointer: internally models something like mutable-to-immutable pointer conversions, in support of the above four points.
  • DoubleToCGFloat: Double -> CGFloat, motivated by the C typedef for CGFloat
  • CGFloatToDouble: CGFloat -> Double, motivated by the C typedef for CGFloat

Motivated by Objective-C:

  • ClassMetatypeToAnyObject: This makes SomeClass.Type convertible to AnyObject, because Objective-C classes are objects
  • ExistentialMetatypeToAnyObject: Similar to the above, but for an existential meta types
  • ProtocolMetatypeToProtocolClass: SomeProtocol.Protocol converts to the Objective-C Protocol class.
  • ArrayUpcast: Implicit conversion from [T] to [U] when T converts to U. We added this conversion because all of the imported Objective-C APIs in Swift 1.0 came in as [AnyObject] (Objective-C generics didn't exist yet), and it caused a big impedance mismatch with Swift code because one always had to manually convert [SomeClass] to [AnyObject] to use any system frameworks. So, we added an implicit conversion to smooth it over.
  • DictionaryUpcast: Same as the above, but for dictionaries.
  • SetUpcast: Same as the above, but for sets.
  • HashableToAnyHashable: Implicit conversion from a value of Hashable type to AnyHashable, introduced in Swift 3 with AnyHashable because of the impedance mismatch with untyped NSDictionary parameters imported into Objective-C.
  • CFTollFreeBridgeToObjC: Implicit conversion from a toll-free-bridged CF type to its Objective-C class.
  • ObjCTollFreeBridgeToCF: The opposite of the above conversion.

Swift has a lot of hard-coded implicit conversions, but nearly all of them are motivated by improving interoperability with C and C-derived APIs. Indeed, outside of the basic subtyping relationship for classes and protocols (the first section above), the only implicit conversions that we have in Swift that weren't motivated by C interoperability are for optionals (the second section). And the two new proposals for implicit conversions on the table continue that trend by being primarily useful for improving the ergonomics of C interoperability. Even the integer-promotion proposals (allowing Int16 to implicitly convert to Int32, for example) tend to be motivated by C APIs and C-derived data types.

This is why I don't buy the slippery-slope argument, because we're not adding implicit conversions to the "core" language. We're adding them for the same reason we always have, to make C interoperability more ergonomic because it's an important part of Swift's value proposition. I suspect this will naturally tail off because the surface area of C is more-or-less constant.

Your other argument is this:

This is the real discussion to be had. From my perspective, Swift very deliberately has a rigid type system with relatively few implicit conversions, and the implicit conversions we do have come from pragmatic decisions around reducing the impedance mismatch with (Objective-)C APIs. I do not want Swift to allow library authors to define their own implicit conversions, because I find reasoning about implicit conversions to require too much additional cognitive overhead. My experience in C++ with implicit conversions has been almost uniformly bad---it rewards API designs where concepts are split into many similar bespoke types whose differences are papered over with implicit conversions, and the presence of implicit conversions makes type inference less efficient and less predictable. Over time, I hope that the implicit conversions Swift already has will fade in relevance, as the C APIs we depend on get wrapped up or reimplemented in more-strongly-typed, more elegant Swift APIs.

Doug

20 Likes

You we're right the first time. assumingMemoryBound is the closest equivalent today to what an implicit pointer conversion would do. But this proposal does not add implicit conversions to the Swift language. It adds interoperability with C.

An raw or typed unsafe pointer, Unsafe[Mutable]RawPointer or
Unsafe[Mutable]Pointer , will be convertible to a typed
pointer, Unsafe[Mutable]Pointer , whenever T2 is
[U]Int8 . This allows conversion to any pointer type declared in C
as [signed|unsigned] char * .

This text only makes sense when you precede it with "For imported C functions".

Is it not better this conversion would be documented in Swift? Bear with me. I want to understand.

Please see the Add more implicit conversions to Swift part of the proposal.

Programmers are often tempted to circumvent Swift's compiler diagnostics by defining their own UnsafePointer initializers with a hidden assumingMemoryBound. I want to stress how wrong that is. assumingMemoryBound(to: T.self) is only valid when the programmer knows that the pointer's memory is already bound to T. It shouldn't be necessary to use asssumingMemoryBound(to:) for C interoperability. This and other upcoming proposals are designed to fix that.

@gwendal.roue
:grimacing: Indeed we know that we hold it wrong but this does not tell how to hold it correctly. It's very difficult to infer correct code from the rules, even assuming the rules are correctly understood. For what it's worth, I still do not have one location online where the maze of raw/pointer/buffer jungle is correctly explained, with both a minimum amount of real use cases, and also a clear explanation of what turns wrong exactly when we make mistakes (and why the correct code fixes it).

I think the important information could be clearly summarized in the API docs with some effort, and I'll try to make that happen. There's not much guidance today, mainly jargon. That WWDC talk would be more useful as a blog post. One thing at a time.

Since no-one else is answering I'll venture the following block for the middle of your code:

  bufffer.withMemoryRebound(to: Int32.self, capacity:?) { _ in 
       // use int32 Accelerate functions on `buffer`
  }

Correct.

Though I have no clear idea what the concept of "binding" is.

At any program point a memory location is bound to a single type. When you dereference a typed pointer, the element type needs to match.

7 Likes

If I may try my hand at an explanation, would something like the following be in line with what binding memory does?

“The act of binding a pointer to a data type constitutes a promise from the programmer to the compiler, that the memory referenced by that pointer will not be accessed as another type, either directly or through a differently-typed pointer, for as long as that pointer remains bound to that type.

The compiler is then free to reorder accesses through that pointer relative to any differently-typed accesses, and perform related optimizations, without needing to reload memory from the pointer, because it has been promised that differently-typed operations will not affect the memory referenced by that pointer, and vice versa.

If the programmer breaks the promise and accesses the same memory as a different type, then the contents of that memory end up in an undefined state, because there is no guarantee about which accesses will be performed in which order. This can result in malformed instances, corrupted data, broken invariants, unintended application state, and arbitrary security breaches.”

…am I close?

12 Likes

That's a very good explanation, yes.

Obviously. The key phrase you uttered in another reply seemed to be "Binding memory throws up an optimization barrier.” which has moved my understanding of its operation on a bit. The “strict aliasing rule” is unfortunate in my opinion but we have to live with it. The best description I found was this link from my little book where even then its consequences seem under dispute seeing the colourful language used.

This likely precludes trying to implement your pitch using any universal approach where conversions are expressed in Swift but I'm wondering where will this magic be documented?

Thanks for taking a look @Douglas_Gregor, with respect to extrapolating benchmarks they were also unchanged when I added the declarations for implicit conversions to the source even though they were not used. The bidirectional CGFloat <-> Double conversion already works in the prototype as you can see from the tests as the way it's implemented is a post type-checking operation only applying one conversion at a time. I've looked though the existing conversions you enumerate later and very few could be re-expressed in Swift. My principal interest was simple type conversions but the prototype was able to implement the mutable to immutable conversions at a pinch.

I'd spend more time on it but you say in a later reply you'd be reluctant to put that power in the hands of developers out of bad experience from C++ which is a perfectly valid position. I only wanted to prove that fears about type checker performance shouldn't drive the decision on their own.

3 Likes

This is often suggested, but I'm not aware of a case where this worked out in practice - we usually end up with both the old thing and the new thing (e.g. lazy and property wrappers both).

The reason they don't work out is that the new general features isn't as powerful as the special case things, don't behave exactly the same way (and we can't take a source break), or because no one goes back and cleans things up. Are you aware of any examples where this has worked out?

In this specific case, these types directly interact with other existing implicit conversions around inout. Understanding how these work when starting from a general model is likely to produce a principled and composable solution - implementing more special cases for this isn't likely to be subsumable into a general model later.

Sure. From my perspective, CGFloat conversions were done and justified as a special case because (just like this) there was a specific narrow need for them. Work went into solving that one problem, which introduced a lot of complexity into the compiler (see the post above) as well as compile time issues. These all have nothing to do with conversions being extensible etc, these are a result of how the CGFloat conversions were designed.

I'm observing that we have accumulated a lot of these special cases already (see Doug's summary above), and believe we are at the point where we should define a model for this to guide these things. Unrelated to the language feature, I also think we should have a set of principles for why and when we add one set of conversions but not another (e.g. why isn't String convertible to Substring?). Without that we have a collection of special cases without a unifying theory.

To answer your question, I am arguing (without data, this is just my opinion) that a "zoom out and look at the big picture" will make Swift a better and more consistent language, and that that view is far more important than smoothing a few weird cases in imported C code.

This is also arguably really important to figure out before Swift 6, because it is very unlikely that a new feature is going to be compatible with the existing ad-hoc conversions we have (see above). However, a language break would give us the ability to eat small incompatibilities in the new mode.

-Chris

5 Likes

Yes, this is the important discussion to be had, and this discussion is exactly what I'm encouraging. Your post makes two different arguments 1) implicit conversions are critical for some things, but 2) you don't like implicit conversions. Your conclusion is that they should be hard coded into the compiler as a defense mechanism.

We (folks working on Swift a long time + evolution) have gone around this "policy vs mechanism" wheel several times in the past, e.g. when SE-0195 Dynamic Member Lookup was added, early in the language design when we had (what became) the ExpressibleBy... protocols adoptable by user-defined types, user-inventable operators, emoji identifiers, more recently in the discussion about SE-0302 Sendable where we discussed locking down Sendable conformance to prevent bugs etc. Frequently there is a "fear of abuse" vs "generality" question that comes up - go read the DynamicMemberLookup reviews to see all the concerns of "everyone will start conforming to this and then no one will know what any code does" concerns.

Reasonable people have different opinions, but I have /consistently/ (across the last 11 years, across many language features from the very beginning) been of the opinion that:

  1. "Swift benefits from strong API design guidelines"
  2. We need composable language features with strong designs.
  3. "The API Author Knows Best".

The reason for the first is that you want consistency across the ecosystem (which isn't something that can be mechanically enforced in general). The second is hopefully obvious. The third is because domain-specific APIs have different constraints and the authors of those APIs are the best positioned to understand the needs of their customers. With #1 and #2, those API authors (who are professional software engineers, not children that need to be coddled) can weigh the tradeoffs and make informed decisions.

To make this concrete, come back to the majority of your post outlining how important it is to do bindings to C, and how this is all special. It turns out that C isn't the only language that Swift binds to - PythonKit is also a pretty important thing for a class of users, and it would also benefit from specific adoption of narrow implicit conversions.

I would much rather have a unifying theory and flush all those special cases out of the compiler. Are we going to start adding special case conversions into the compiler for other language bindings?

-Chris

EDIT: Also, your post and those of others make a specific assumption that what we end up with would be C++-like or __conversion like. I'm not sure why it always comes back to this, of course we wouldn't carry forward known mistakes into the future.

9 Likes

I like your explanation. Something along these lines should be in the API docs.

1 Like

I don't think we need to live with strict aliasing in Swift, although we might opt into it in some places for peak performance. The issue is really only forced when working with C APIs. I do think we need to stop using typed pointers in pure Swift APIs. It's easy to build nice APIs for encoding and decoding and to generally reinterpret bytes on top of raw pointers. Designing fully general utilities for those things is challenging and will take a bit more time.

As usual, the proposal itself the authoritative documentation. Of course, we can document it wherever else is appropriate. The special rule is explained in a diagnostic message in case someone tries to do the same conversion in Swift.

1 Like

From what @hborla told me, the reason why lazy is not yet generalized is due to a couple of short-comings of property wrappers which are being addressed:

  • It's not as efficient because the property wrapper has to store the initialization closure in each instance. There is a pitch to make this more efficient (Add shared storage to property wrappers).
  • Allowing lazy as a property wrapper to access the enclosing self. This requires syntax changes which haven't been completely flushed out.

If we are talking about implementation, and not the language then - yes. There are multiple ongoing efforts in the type-checker to do just that: generalize all of the performance hacks, closure, result builder handling etc.

1 Like

Right, I was just explaining why I'm not a fan of "add more special cases with the expectation that a future generalization will subsume them" - even though it is theoretically possible, I haven't seen it work in Swift in practice. It is much better (in my opinion) to start with the general feature and avoid having all the special cases. And yes, I realize the tradeoff here - designing and building general things is more difficult than putting duct tape on specific problem.

Beyond technical issues, there are also very real social and project management issues: there will always be a press for new features and capabilities, and often not enough time to go clean up existing debt. This is unfortunate, but also a reality of software project management. Continuing the example above, if we had property wrappers first, then there would be a specific reason to make them more efficient/powerful to enable the @Lazy modifier.

These are all reasons why Swift has said "no" to key things for years (e.g. concurrency) rather than scattering little things in over the course of the years. Taking time to do something right - even at the expense of having to go without the short-term improvements - works out better in the end, because you can design the systems together with the big picture in mind.

-Chris

9 Likes