Simd vector types

Another approach which was discussed earlier, but I haven't really seen in this thread is to map these types to tuples (e.g. Instead of an Int.Vector3 we just say (Int, Int, Int)), and then:

  1. Allow extensions and protocol conformances on structural types
  2. Provide a shorthand for creating long tuples of the same type. e.g. (Int8 x 16)
  3. Provide built-in methods of quickly indexing, slicing, and joining tuples

I understand why this is appealing, but while these things are abstractly very much like tuples, they are not at all like tuples from the point of view of low-level considerations of a processor and how they are actually used. Specifically:

(a) they are fundamental types where most operations can be done with a single instruction
(b) you really want the calling conventions to pass them in a single vector register when they fit, whereas you really want tuples (even homogenous tuples) to be split apart into a sequence of registers, because otherwise you end up unpacking and repacking them all the time.
(c) you may want them to have higher alignment guarantees than a tuple would have.

The syntax you're describing is interesting, and may be something that we should add, but it is not a syntax that can bind the desired semantics of simd vectors. It's something much more like a fixed-size array.

3 Likes

This is something that I've considered, because:

  • there will be a lot of API surface here, that many people don't need to think about
  • we should establish a precedent for provided-with-the-language-but-not-automatically-imported stuff anyway, because we'll get into more things like this as the language grows.

The reason I haven't pursued this (yet) is that we would like the importer to map clang ext_vectors to these types, and if we go that route we would either end up conditionally importing the API or not depending on whether the SIMD module was imported first, or implicitly importing the SIMD module anytime you import a C header that uses those types, either of which is somewhat confusing.

For what it is worth, I am +1 on the proposal as a whole (especially if we can find a syntax without the dot).

I just find it most useful when designing (even the design of API) to have alternating wide and narrow views, with the wide view having lots of variation as raw material for ideation. It helps to keep us out of local minima.

Two more options for naming:

  1. Just skipping the dot: Vector16Int8 or Int8Vector16
  2. If we must have a dot, namespace everything under Vector: Vector.Int8x16

In either case, I like starting with the word Vector because it will come up in autocomplete for people who are looking for Vec3, etc...

1 Like

I think either Vec8xInt16 or Vec8.Int16 could work, if we want to do something like that. These have the virtue that they avoid the ambiguity of Int16x8 and also don't pollute the namespace as badly as some other options would.

I'll make a branch following this convention and see how it ends up working out.

3 Likes

i like the dot because the digit followed by the capital letter is really hard to read because the digit looks like a capital. i like Vec8.Int16 i think that one makes the most sense because the “container” comes first, its “length” comes after it as a digit (just like Int8, Int16, Int32, etc), it’s nicely abbreviated (Vector → Vec, just like Integer → Int, so Vector can be used for a future protocol)and it has a nice symmetry

7 Likes

You know, I really think this is doable. I've been playing with converting this branch, and it's so tedious to write that a few hours later I still haven't compiled it, but I haven't hit a blocker yet.

Basically, the design I'm playing with has each type conform to VectorizableN protocols corresponding to the vector sizes it supports:

protocol Vectorizable4 {
  associatedtype _BuiltinVector4
  static func _vector4ExtractElement(at i: Int, in vector: _BuiltinVector4) -> Self
  static func _vector4InsertElement(_ value: Self, at i: Int, in vector: inout _BuiltinVector4)
  static func _vector4(_ lhs: _BuiltinVector4, adding rhs: _BuiltinVector4) -> _BuiltinVector4
  // And so on for all common operations.
}

protocol VectorizableInteger4: Vectorizable4, FixedWidthInteger {
  static func _vector4(_ lhs: _BuiltinVector4, shiftedLeftBy rhs: _BuiltinVector4) -> _BuiltinVector4
  // And so on for all integer-only operations.
}

protocol VectorizableFloatingPoint4: Vectorizable4, BinaryFloatingPoint {
  static func _vector4(_ m: _BuiltinVector4, multipliedBy x: _BuiltinVector4, adding b: _BuiltinVector4) -> _BuiltinVector4
  // And so on for all floating-point-only operations.
}
// Now do it again for Vectorizable8, Vectorizable16...

Then you have concrete VectorN<Element> types which basically just wrap these operations:

struct Vector4<Element: Vectorizable4>: RandomAccessCollection {
  var _value: Element._BuiltinVector4
  subscript(i: Int) -> Element {
    get { return Element._vector4ExtractElement(at: i, in: _value) }
    set { Element._vector4InsertElement(newValue, at: i, in: &_value) }
  }
  static func &+ (lhs: Vector4, rhs: Vector4) -> Vector4 {
    return Vector4(Element._vector4(lhs._value, adding: rhs._value))
  }
  // etc.
}
extension Vector4 where Element: VectorizableInteger4 {
  static func &<< (lhs: Vector4, rhs: Vector4) -> Vector4 {
    return Vector4(Element._vector4(lhs._value, shiftedLeftBy: rhs._value))
  }
  // etc.
}

And finally each type conforms to the relevant protocols and provides the proper associated types and implementations. For instance, Int8 will set its _BuiltinVector4 to Builtin.Vec4xInt8, then provide implementations using Vec4xInt8 builtins.

This is a huge pain to write, but I think it ought to be workable. I am a little concerned that it might not inline as aggressively as I'd like, but it seems to me (although I haven't gotten far enough to test it yet!) that even if the complier doesn't do the right thing natively, we ought to be able to force the issue with @_specialize.

So, is there some reason this won't actually work? Because if it will work, it neatly sidesteps all of the naming and code completion pollution issues we've been discussing.

7 Likes

So, is there some reason this won't actually work?

It would absolutely work. My concern is not that it cannot be done, but rather that:

  • it's a way more machinery (you actually end up with all of the machinery of the current approach, but have wrapped it with weird _-prefixed types with all operators replaced by weirdly named inits) for only slightly-better names. I haven't heard a convincing argument for why this spelling is better, beyond it being more familiar. What does this spelling enable that the other options do not? What are we buying with that added complexity?

  • it moves essentially all operations (not just the default implementations, but the operations themselves) onto conditional conformances, which are not the most intuitive thing for most people to work with.

Because if it will work, it neatly sidesteps all of the naming and code completion pollution issues we've been discussing.

So does Vec8.Int16, without any added complexity.

To me, using < > feels like fighting against the language just to make something that looks a little more like C++.

5 Likes

I think you're discounting a lot here. Vector8<Int16> is the natural spelling for this type. It runs parallel to other collections like Array<Int16> and Set<Int16>. It is utterly unsurprising and requires no explanation to users.

I also think that something like Vec8.Int16 works poorly with generic functions. For instance, suppose you have a summing operation:

extension Collection where Element: BinaryInteger {
  func sum() -> Element {
    var result = 0
    for elem in self { result += 0 }
    return result
  }
}

And you now want to provide a vectorized implementation. How do you do that? With my design, you'd say something like:

extension Collection where Element: VectorizableInteger4 {
  func sum() -> Element {
    var result = Vector4<Element>()
    ...
  }
}

Something like Vec8.Int16 provides no natural way to generically get from a type like Int16 to a vector type it supports. We could add one, but this would end up making Vec8.Int16 and Int16.Vec8 be aliases for the same type.

Types like this—types which need to be parameterized with other types—are exactly what generics are for. To be honest, using generics should be the default position here, and I have not yet seen an adequate explanation for why we should deviate from that default.

14 Likes

I hope in a far future, we will be even able to write this as Vector<Int, size: 4>...

4 Likes

I think you're discounting a lot here. Vector8<Int16> is the natural spelling for this type. It runs parallel to other collections like Array<Int16> and Set<Int16> . It is utterly unsurprising and requires no explanation to users.

Thinking of simd vectors as just another collection is almost always a mistake. This draft happens to have them conform to Collection, because it was convenient, but it's really not obvious that they should, since much of the Collection API is something of an attractive nuisance when applied to simd vectors, due to operations on slices being wildly less efficient than the type itself, and there being more efficient ways to do most of the operations that Collection provides, with no easy way to shoehorn them into the Collection API.

Additionally, the way you want to interact with them as much as possible is as abstract objects, not as things that you iterate or map over. That's how you get efficient code.

Because of this I'm very OK with these being visibly distinct from "other collections".

And you now want to provide a vectorized implementation. How do you do that? With my design, you'd say something like:

extension Collection where Element: VectorizableInteger4 {
  func sum() -> Element {
    var result = Vector4<Element>()
    ...
  }
}

A diversion: this is precisely the wrong way to vectorize such an operation--your "generic" implementation will not be close to optimal for element sizes smaller than what you originally wrote for, because you'll waste half or more of your bandwidth, and you're likely to end up with spilling on x86 for larger element sizes (though you may get away with it on arm64). You don't want Vector4<Element>. You want VectorMachineWidth<Element> or similar.

We can also do this with any of the designs discussed as something like Element.VectorMachineWidth. It's a little bit more cumbersome in the VecN.T design, but it's still only adding protocol and a few typedefs, rather than near-complete duplication of all the machinery. I am still unconvinced. I don't think that VectorN<T> is worse, but I also don't think that it's enough better to justify the added overhead.

To be honest, using generics should be the default position here, and I have not yet seen an adequate explanation for why we should deviate from that default.

That it requires fighting the language the whole way and writing almost 2x as much code is pretty convincing to me, but apparently not you.

5 Likes

+1 to actually removing the RandomAccessCollection and MutableCollection conformances. It looks like you are only using subscript and iteration (and indices on HalfVector, but it doesn't need to be written that way). I think it would be a better idea to make the (Int) -> Element subscript a protocol requirement (that you already have implementations for), and make a SIMDVector extension to conform to just Sequence by utilizing a size: Int {get} requirement and the iterator go over subscripts 0 ..< size.

As is, I think a lot of the responses here are reaching for "how do we make this more like a regular collection", and the current design is sort of contributing to that mindset.

4 Likes

I agree with Brent that VectorN<Element> is the more natural, more discoverable spelling.
Collection conformance is an orthogonal issue.

Okay, so we'll also have that - similar to how we have Int8/16/32/64 and also the machine's natively-sized Int. I don't think that's in your prototype (forgive me if I'm wrong), so it would be an improvement to both designs.

Not to me, either. To me it says that the language is deficient and unable to express the best design. It's nice that we have a clever workaround in this case - it means that we can at least keep source compatibility if generic types ever do get the ability to customise layout based on constraints to their generic parameters (as I mentioned before, that would be a generally useful enhancement to the language).

If we had that feature in the language, would you still have designed the prototype as you did?


One more thing (I mentioned it above as well) - it would be nice to be able to rebind an Unsafe(Mutable)BufferPointer<Int> as a VectorN<Int>, so you could perform vector operations in-place instead of copying the integers to and fro.

3 Likes

I personally really like the proposed IntM.VectorN notation. Even though the MxN notation is common I (as someone who only infrequently touches vectors) suspect I would much prefer the proposed one.

2 Likes

I haven't seen it brought up, what is the expectation w.r.t. literal representation of vector types? If there was compiler support for conversion from array literals, for example, would that change the shape of the naming discussion because type inference could be used to avoid writing the name in more cases?

func notSuperUsefulFunction(_ a: Float.Vector3) -> Float.Vector3 {
  return a * [1, 2, 3]
}

In what way does Vector8<Int16> imply that this is a collection? It's just another generic type.

2 Likes

Well, actually:

(swift) func notSuperUsefulFunction(_ a: Float.Vector3) -> Float.Vector3 {
            return a * [1, 2, 3]
        }
(swift) notSuperUsefulFunction([4,5,6])
// r0 : Float.Vector3 = Vector3(4.0, 10.0, 18.0)
1 Like

We can definitely add load/store to/from UnsafeBufferPointer<Element>. I think that's a no-brainer. "Binding" so that updating the vector updates the storage without any interaction is a little more subtle.

1 Like

I'm honestly not sure. Not everything that can be explicitly parametrized actually should be. If we had generic integer parameters, and could specialize the layout, would we spell Int8 as Int<bits: 8>? Probably not. Why is that different?

Largely this comes down to a matter of taste. For me personally, I don't find Float.Vector4 to be worse than Vector4<Float>. I think both are perfectly clear, and enable basically the same generic programs to be written. Because of that, the fact that it's the simpler solution tips the balance for me. If it weren't any simpler, I think I would consider them to be about equivalent.

1 Like

In what way does Vector8<Int16> imply that this is a collection? It's just another generic type.

It doesn't. I'm only pushing back against the argument that "it should look like other collections".