Integer generic parameters

This become quite complicated when the generic argument is itself an integer value:

func callee<let N: Int>(_: N) where N >= 0 {}
func caller<let N: Int>(_ n: N) where N >= 1 { callee(n) }

IMHO, general reasoning about Diophantine equations should be considered out of scope for this pitch.

4 Likes

This isn't just a compile time requirement right, this must also be enforced by the runtime type system too. Any instantiation of T where N >= 0, we must make sure that the argument N for T is actually greater than 0 (which is simple enough at runtime because we have concrete integer values to compare, but becomes way for complicated at the compile time level as Slava mentioned).

3 Likes

Swift is supposed to be a safety-forward, safety-first language. Trapping at runtime is much less safe than emitting a compile-time error. (For those of you like me with 16-bit brains: Diophantine equation.)

Per the pitch, your example declarations wouldn't compile, because N is not a type:

Maybe I missed it, but I don't see anywhere in the pitch that suggests a value passed at runtime could be used as a constraint. Rather, the pitch explicitly says this is not allowed at least initially:

I have no doubt that @Slava_Pestov is better at math than I am, but I am not convinced that it's not possible to implement basic numeric constraints at compile time. They don't strike me as any more complex than existing type constraints, but rather less complex because we don't have to deal with conformance graphs, inheritance, availability, etc. A constraint like N >= 2 is just a range, and producing the union of two or more ranges is trivial arithmetic with (what ought to be, if I did my math right) linear complexity.

1 Like

You're right, but wouldn't this require the same kind of reasoning?

struct Foo<let N: Int> where N >= 0 {}
struct Bar<let N: Int> where N >= 1 {
  let field: Foo<N>
}

I would expect this to be valid/allowed, because N >= 1 satisfies the constraint N >= 0.

If full support for comparison operators really is infeasible, perhaps carving out special case support for >= 0 now, and revisiting later, would be sufficient for the use cases we know exist that require non-negative values?

It's certainly obvious in this case. In the current pitch the generic argument for an integer parameter is either another integer parameter or a constant, but we will probably want to allow linear combinations eventually too:

struct Foo<let N: Int> where N >= 7 {}
struct Bar<let M: Int, let N: Int> where M >= 3, N >= 1 {
  let field: Foo<N + 7 * M> // is this correct?
}

None of this is impossible of course, it's just difficult.

4 Likes

Stronger type-level constraints don't by themselves eliminate the potential for runtime traps. It might statically prevent you from calling an initializer like Vector<-1, Int>(repeating: 0), but given a valid Vector value, there would still be bounds checks when indexing into that Vector, or indexing into a Span or UnsafeBufferPointer referencing part of it, and so on. So it isn't clear to me that the added type system complexity would appreciably reduce the amount of dynamic safety checking collections need.

4 Likes

Really exciting! On the topic of fixed-size types, rather than a runtime check, a compile-time check would be just as useful. For instance, instead of:

ā€¦ perhaps there exists some variant that requires a static value, and can thus make comparisons at compile time and reject hard-coded values accordingly:

extension Vector {
    // Preferred by compiler when literals are used?
    subscript<let I: Int>(i: I) -> T where I >= 0, I < N {
        get { element(i) }
    }

    // Optional runtime fallback that may or may not be provided by the library, perhaps with some `unsafe:` marker
    subscript(i: Int) -> T {
        get {
            if i < 0 || i >= N {
                fatalError("index \(i) out of bounds [0, \(N))")
            }
            return element(i)
        }
    }
}
1 Like

Since these are ultimately going to come from integer literals, is it possible we could validate them at compile-time via static assertions? I believe we already have some level of support for #assert in the compiler using the -enable-experimental-static-assert flag.

The check wouldn't be a formal generic constraint, but it could help surface errors earlier in the Vector<-1, Float> case, at least in some situations where enough specialisation occurs for the compiler to know the value of the generic parameter. When that doesn't happen it'll have to fall back to a runtime check, of course.

4 Likes

Can a Protocol conformance be constrained to have one or two or n integer generic parameters?

Not as part of this initial proposal. I can see us supporting this through static let requirements in protocols, which would let us know that the implementation is a constant:

protocol MatrixLike {
  static let rows, columns: Int
}

struct Matrix<let R: Int, let C: Int> {
  static let rows = R
  static let columns = C
}
1 Like

At the end of the day, I want fixed-size arrays very much, but I'm uneven that we're doing so through a feature that has a huge design space with considerations for one use case only. I wouldn't definitely say that it's a good or bad idea, it's rather that it's difficult for me to see why we would continue to push forward value generic parameters once we have fixed-size arrays and I think that the feature will feel incomplete for a long time. I also find it hard to evaluate whether the choices made here will line up well (or tie our hands) with related features that I think have more reach, like compile-time evaluation. I believe this wouldn't be on my mind as much if, for instance, we introduced syntax like (Int * 4) and synthesized collection conformances on that type for now (until, some years down the line, we are able to alias that to a more general FixedSizeArray<T, let N> type).

Something like this could be achieved through integer ranges as types:

extension Vector {
    // Preferred by compiler when value is known to be in range
    subscript(i: 0..<N) -> T {
        get { element(i) }
    }

    // Fallback used when range can be proven
    subscript(i: Int) -> T {
        get {
            guard let j = i as? 0..<N else {
                fatalError("index \(i) out of bounds [0, \(N))")
            }
            return element(j)
        }
    }
}

Aside from doing compile-time checks, integer range types could be useful for memory layout optimizations. E.g using 2ā€¦254 instead of UInt8 tells compiler that bit patterns 0, 1 and 255 are not used and are available for other purposes.

To illustrate another use-case: I really want closures to be supported as generic values one day. They don't need to have a very elaborate notion of equality or anything; I just want a type that is parameterised by a closure literal.

Currently every LazyFilterCollection<[Int]> has the same type, so if you have a generic algorithm and it gets specialised, that specialisation applies to every LazyFilterCollection<[Int]> regardless of what their filter predicates actually do.

If the predicate were able to become part of the type, the specialisation would apply to this specific predicate, allowing it to be fully inlined. I've seen significant performance improvements my "manually specialising" lazy-filter collections in this way (e.g. an IgnoringASCIISpacesCollection<[UInt8]>). And with opaque result types, even such highly-specialised wrappers can still be ergonomic to use - you'd just pass them around as some/any Collection<Int>.

I'd hope those kinds of things would motivate us to push forward beyond Ints and fixed-size arrays.

3 Likes

There is a closure specialization pass that does this: swift/lib/SILOptimizer/IPO/ClosureSpecializer.cpp at main Ā· swiftlang/swift Ā· GitHub

I donā€™t know if the optimizer is smart enough to completely ā€œzero-costā€ LazyFilterCollection, though.

Yeah that only applies to function arguments:

/// The purpose of the algorithm in this file is to perform the following
/// transformation: given a closure passed into a function which the closure is
/// then invoked in, clone the function and create a copy of the closure inside
/// the function.

So it would help with the eager .map and .filter functions, but not the lazy versions.

Once you escape the closure in to a stored property and start passing the aggregate around, the compiler quickly loses the ability to say definitely which closure is in a given LazyFilterCollection.

It seems to me that if you want firmer assurances that that information is preserved and propagated even in complex applications, it needs to become part of the type. Otherwise you could just reassign a variable holding one filter collection with another that uses a different predicate.

But if there are ways to do it without generic value parameters, I'd take that too, of course :slight_smile:

1 Like

An interesting future direction would be to hoist preconditions with constant or constant-folded inputs into compile time. E.g.

struct Vector<let N: Int> {
  static func empty() -> Self {
    precondition(N >= 0, ā€œNegative count not supportedā€)
    // ā€¦
  }
}

Vector<-1>.empty() 
// Constant folding results in:
// ā€˜precondition(false, ā€œNegative count not supportedā€)ā€™
// So we get the following warning:
āš ļø Code crashes at runtime: ā€œNegative count not supportedā€
3 Likes

I've recently been experimenting with exactly this design (only implemented with macros). The problems that I ran into early on are that I needed

  1. SIMD alignment if I'm going to use this for highly parallel operations. SIMD alignment however varies dependent on the 2nd generic parameter and this was tricky to overcome.
  2. Very careful control over ~Copyable behavior. Types like: Vector<N, Vector<M, Double>> really didn't work for me since the outer Vector uses an inner Vector that is ~Copyable and in general I want the 2nd parameter of a Vectors to be Copyable. This problem only gets worse as you build out higher order Tensors.
  3. To limit the second parameter to be something approximating AlgebraicField from the Numerics packages. There's a lot of AI stuff that needs these types to follow pretty strict rules from linear algebra that most of us don't think about as we are writing apps.

Has any thought been given to making sure that these alignment, ~Copyable and Numerics concerns can also be specified in this proposal?

1 Like

I think there's definitely something that could be done here, but probably not in the form of precondition. We'd most likely want some new maybeStaticPrecondition that the optimizer can specifically look for with regards to statically diagnosing invariants that are false. Of course, this isn't super useful for clients who don't expose the function implementation to outside modules because then it's just precondition. And yeah, if the condition isn't statically known it'd have to be a runtime check being performed because we still need the following to trap:

func something<let N: Int>(x: Vector<N, Int>, y: Int) -> Int {
  x[y]
}

if y is ever out of bounds which isn't statically known (of course it can be if this gets inlined etc etc.

3 Likes

Iā€™m wondering about the overall nature of generic values: whatā€™s their relation to compile-time constants? Generics currently abstract over types which are constant upon declaration. However, if library ā€œAā€ accesses types across the module boundary from, say, an evolving dynamic library ā€œB,ā€ the layout of types in library ā€œBā€ is unknown, i.e. not constant at compile time.

So what about generic values? Is the requirement that they be constant upon declaration or are they always thought of as compile-time constants? Are they perhaps considered constants in the same module? If theyā€™re not constants, then is their utility solely that the compiler can prove the equality between two generic values.

I apologize if my post is confusing, Iā€™m still grappling with the concept of generic values. I think others are also questioning the role of generic values, which is why I think a clear narrative about their place in the language is crucial.

2 Likes