Zig: Proposal: Short Vector Primitives

Created on 3 Dec 2020  路  15Comments  路  Source: ziglang/zig

Before I get started, I would like to thank the people behind Zig for their awesome work. Zig is the first language in a long time I can even potentially see replacing C/C++ for me, which is exciting. :) There are still some issues and maturing left to do, but I have faith these will be fixed with time. Anyway, I figured I should create a proposal that would fix my primary showstopper. I apologize if this has already been proposed before and this is just spam.

Proposal: Short Vector Primitives

In game development and computer graphics we use a lot of linear algebra to do all sorts of stuff. The most important thing from linear algebra we use are vectors, specifically short vectors (2-4 elements). It's no exaggeration to say that we use them A LOT, they are incredibly versatile and useful.

Let's say you have a size of an image, store it in a 2D vector. A position in 2D or 3D? Store it in a 2D or 3D vector respectively. A velocity for something? 2D or 3D vector. An axis-aligned box? That's just two 3D (or 2D) vectors of course. A coordinate for the mouse position on the screen? 2D vector. An RGB color? A 3D vector. I could go on.

We also use vectors a lot when defining low-level data-structures with explicit memory layouts (e.g. for sharing between CPU and GPU), an example of a simple vertex in C++:

struct Vertex {
    float3 position;
    float3 normal;
    float2 texcoord;
};
static_assert(sizeof(Vertex) == sizeof(float) * 8, "Accidental padding");

// Note that Vertex has the exact same memory layout as float[8], and we reinterpret cast between them liberally.

In the most common game development languages (C++ and C#) we can simply create our own vector primitives which behave just as built-in primitives thanks to operator overloading. In shading languages such as HLSL and GLSL there is no operator overloading, so instead there are native built-in vector primitives.

This is just speculation, but I would wager 99% of all requests for operator overloading from the game and graphics communities is purely to implement vector primitives (and other linear algebra constructs such as matrices and quaternions while at it). Implementing native vector primitives would fix this need and make the language substantially more valuable to us while not opening the floodgates with general purpose operator overloading.

It's also worth mentioning that having native vector primitives would make Zig a substantially better candidate as a shading language in the future. As far as I know there is currently no shading language without vector primitives (or capability of implementing them yourself), I very much doubt anyone would bother using a shading language without them. Being able to share code between the CPU and GPU similar to what CUDA is doing would be extremely powerful and a very good selling feature for Zig, as we would no longer need separate external compilers that compile our shading code to binary blobs which we need to bundle along with our app.

Goals and non-goals

Goals

  • New primitive types in addition to existing ones (i32, f32, u8, etc)
  • New operators (+, -, *, /, ==, !=, etc) for these primitive types
  • Some built-in compiler functions (e.g. @dot())
  • Some vector primitive specific functionality, e.g. swizzling.
  • Be as "uncontroversial" as possible, avoid things which differ among common vector libraries

Non-goals

  • Quaternions and Matrices (and other Linear Algebra constructs than vectors)
  • Long vectors (more than 4 elements)
  • SIMD
  • General purpose operator overloading, keep it simple

Quaternions and matrices are not relevant for this proposal because they don't have an obvious implementation (unlike vectors). They differ quite a bit between different libraries, both in terms of memory layout, syntax and functionality. Making a choice here could easily make someone who needs something else quite mad. The good thing is that it's not at all as limiting to not have overloaded operators for matrices and quaternions as it is for vectors, so this is fine. Let someone else make a 3rd party library for these.

Vectors longer than 4 elements are not considered because it's no longer as obvious how they should work. I.e., for a 4D vector we can refer to individual elements using .x, .y, .z and .w. But there is no obvious letter for e.g. the 5th coordinate.

Many linear algebra vector libraries have SIMD implementations for some vector primitives, but this is mostly bikeshedding to be honest. I have seen examples of compilers generating the same (and at times even better) code when vector primitives are not explicitly implemented using SIMD. And if you really need highly optimized SIMD code you should write it explicitly yourself and not trust in the primitives anyway.

There's also the problem where forcing SIMD characteristics upon vector primitives can make them work weirdly in other contexts. E.g. a 4D float vector would need to have 16-byte alignment for SIMD, but that means it's easier to introduce accidental padding when placing them in structs (e.g. for memory shared between CPU and GPU). We also use 3D vectors more than 4D vectors, should they be forced to have an element of padding so that they can be 16-byte aligned? But that's terrible if we have an array of them which will then take up 25% more memory.

Overall the reason we want vector primitives is because it makes our code substantially easier to read and write, not specifically because of performance.

What

The new primitive types

In GLSL vectors are named vecN for N-dimensional f32 vectors, and ivecN and uvecN for i32 and u32 vectors respectively. Examples vec3, ivec2, etc.

In HLSL (and CUDA) vectors are named typeN where type is the normal type and N the dimension. Examples float3, int2, char4, etc.

In general I prefer the HLSL approach because it doesn't actually imply that the contents is specifically a vector (which tend to make mathematicians sad at times). It's just multiple elements of a given primitive and it's up to the user to specify meaning.

My proposed naming for the vector primitives is: tAAxN where t is i,u or f for signed, unsigned or floating point respectively, AA is the number of bits for each element (fine if only 8, 16, 32 and 64 is allowed) and N is the number of elements (2, 3 or 4). Some examples: f32x3, f16x4, u8x4, i32x3, etc.

Construction

I don't know if this syntax is the one most suited for Zig, but something very similar to this should be available:

var a: i32x3 = i32x3(1, 2, 3);
var b: f32x2 = f32x2(1.0f); // Compiler-error, not all elements specified

In many vector libraries (such as GLSL) if you only specify one element in the constructor, e.g. vec3(1.0f);, then the value is assigned to every element in the vector. This is somewhat counter-intuitive (and not standardized among all vector libraries), so it should be avoided. There should however be some compact equivalent to do the same thing. My suggestion:

var c: i32x4 = i32x4.fill(42); // Results in the vector [42, 42, 42, 42]

We also need to be able to convert between vectors with different types. I.e, we might have values in an f32x4 which we need to convert to u8x4. Or any other types of casts. I suggest extending existing cast infrastructure to also work with vector primitives (element-wise).

Swizzling and component-wise access

Swizzling should work basically the same as it does in GLSL or HLSL. Some (exaggerated) examples:

fn foo(a: f32x2) f32x3 {
    return a.xyy;
}

fn bar(a: f32x4, b: f32x2) : f32x3 {
    return a.wyx + b.xxy;
}

Operators (vector, vector)

In general, all operators work exactly the same as they would for a normal primitive, except applied to all elements. In other words, 100% element-wise. Below is a snippet with a "fake implementation" of addition between two vectors:

fn add(a: i32x4, b: i32x4) i32x4 {
    var c: i32x4 = undefined;
    c.x = a.x + b.x;
    c.y = a.y + b.y;
    c.z = a.z + b.z;
    c.w = a.w + b.w;
    return c;
}

Operators +, -, * and / are defined as above. The last two are potentially a bit controversial outside of game and graphics fields, but it's basically what makes most sense and what is most consistent when you are actually using these things in practice. If there is a big opposition to implementing * and / as element-wise operations they can be skipped entirely, but it would be a shame because it does reduce the value a bit.

Operators == and != return a single bool if all elements in the vectors are the same or not.

These operators only work when both sides of the operator have the exact same vector type, i.e. no mixing and matching.

Operators (vector, scalar)

In addition to the vector-vector operators it's also possible to multiply/divide (* and /) vectors with scalars of the same type. Example implementation:

fn mult(a: f32, b: f32x2): f32x2 {
    var c: f32x2 = undefined;
    c.x = a * b.x;
    c.y = a * b.y;
    return c;
}

Specifically, for operator * it's possible to: scalar * vector and vector * scalar. But for operator / it's only possible to do vector / scalar. Some vector libraries implement scalar / vector and implicitly expand the scalar to a vector, but this is not at all obvious and should probably be avoided.

Built-in functions

There are a number of standard functions for vectors that should be implemented. As far as I have understood it Zig does not support function overloading, which means that these need to be implemented using the special @func() syntax.

@dot()

dot(vector, vector) is probably the most common and important function. Dot product. In math syntax this is often written using the dot operator, this is however very confusing in code so game/graphics engineers tend to prefer to have it defined as a function instead. Example syntax:

var a: f32x3 = /* ... */
var b: f32x3 = /* ... */
var c: f32 = @dot(a, b);

// Example implementation for 4D vectors (need function overloading here)
fn dot(a: v4, b: v4) {
    return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
}

@cross()

cross(vector3, vector3) is another very common function. Cross product. Unlike the dot product it is defined only for 3D vectors, and the result is another 3D vector. Unfortunately the result of the cross product depends on if you are using a right-handed or left-handed coordinate system, so this could potentially cause a bit of friction. However, I think it should be safe to define this to use a right-handed coordinate system and maybe also provide a @crossLH() for left-handed cross product.

@length()

Returns the euclidean length of a vector. I.e.:

fn length(a: f32x4) f32 {
    return @sqrt(a.x * a.x + a.y * a.y + a.z * a.z + a.w * a.w);
}

Can potentially be called norm() or norm2(), but length() is probably the most common naming.

@normalize()

Normalizes a vector so that it's length is 1. In most vector libraries you will get back a vector filled with NaNs if you enter a zero-vector, this should probably be caught by Debug builds in Zig.

Example implementation:

fn normalize (a: f32x4) f32x4 {
    const len: f32 = @length(a);
    // Trap if len == 0 in debug builds only
    return a / len;
}

@min() and @max()

It's very common to need to clamp the values in vectors for various reasons. What's interesting here is that we often only want to specify a scalar as the bound, example:

const unclamped: f32x3 = /* ... */
const clamped: f32x3 = @max(0, @min(unclamped, 1.0));

Conclusion

This got a lot bigger and longer than I was intending, but I think I caught most important aspects. There are of course many other things one might want for vectors (a bunch more built-in functions for one). But I think the above proposal covers the most important parts so that the rest of the linear algebra we need can be implemented on top as a 3rd party library.

Thanks for taking the time reading this! :)

proposal

Most helpful comment

Take my upvote. This is a pretty solid extension which would make gamedev and other linalg topics in zig a lot easier.

This is just speculation, but I would wager 99% of all requests for operator overloading from the game and graphics communities is purely to implement vector primitives (and other linear algebra constructs such as matrices and quaternions while at it).

I agree, this would be my only use case if we had operator overloading

var a: i32x3 = i32x3(1, 2, 3);

This would contradict the "only one way to do things" zen as types are always constructed with T{ }. So a minor change request:

Vector creation is just initialized like any other type in zig:

var a: i32x3 = i32x3 { 1, 2, 3 };

All 15 comments

Related: the accepted proposal to add a SPIR-V target backend for zig: #2683

Take my upvote. This is a pretty solid extension which would make gamedev and other linalg topics in zig a lot easier.

This is just speculation, but I would wager 99% of all requests for operator overloading from the game and graphics communities is purely to implement vector primitives (and other linear algebra constructs such as matrices and quaternions while at it).

I agree, this would be my only use case if we had operator overloading

var a: i32x3 = i32x3(1, 2, 3);

This would contradict the "only one way to do things" zen as types are always constructed with T{ }. So a minor change request:

Vector creation is just initialized like any other type in zig:

var a: i32x3 = i32x3 { 1, 2, 3 };

I think it's a good proposal. I'd be a bit worried that the implementation would be too "arbitrary". I like how Zig decided to implement "any" bit width for uint/int instead of whatever common CPUs/languages have chosen. If you extend this idea to vector primitives, you could argue for implementing Geometric Algebra for up to 3 dimensions

https://bivector.net

As far as I've seen it's the best generalisation of vectors, complex numbers etc., and we'd have to make fewer "arbitrary" decisions about what to implement.

But perhaps doing something like that would be too ambitious for a language like Zig. I mean, there's already similar decisions made on which kind of scalars to support (int, floating point, but no fixed point). And it'd have to be a different proposal. So just food for thought.

While I agree that zig is in need of something to accommodate for writing linear algebra code, I don't think this is it. While this allows for the most common linear algebra code to be written more concisely, I think it is too narrow, too vague (why have right handed cross product by default, why are there no matrices?), and not general purpose enough to excuse the added wart or complexity to the language.

Ultimately I feel we should experiment with something that strikes a good balance between having and not having operator overloading.

I feel like I might have been a bit unclear, so to clarify: __This is not a proposal to add linear algebra support to Zig. This is a proposal to add new primitives, which in some aspects behave like vectors.__

You don't even have to think about them as lin alg vectors, they are useful anyway. There are tons of peoples in games which use vector primitives without knowing any linear algebra. They also expose low-level features which are not exposed by the language otherwise (such as shuffle through swizzling).

Even if some sort of more general linear algebra support is added later on, this proposal would still be useful to us. Not having a standardized/built-in vector primitive has hurt interoperability between different libraries in C++ for quite a while. And swizzling is probably not something the user of a language should be able to implement themselves.

(why have right handed cross product by default, why are there no matrices?)

Regarding cross product I sort of agree. It might be a good idea to not have @cross() and instead do @crossRH() and @crossLH(). Or perhaps none of them. Doesn't matter all that much, cross is easily implemented in 3rd party code.

Regarding matrices I feel like I touched upon that in the proposal, but I can clarify a bit more. The problem here is that there are many ways of doing matrices, some C++ examples:

struct Matrix4x4_RowMajor {
    float4 row0;
    float4 row1;
    float4 row2;
    float4 row3;
};

struct Matrix4x4_ColumnMajor {
    float4 column0;
    float4 column1;
    float4 column2;
    float4 column3;
};

struct Matrix4x4_Compact {
    float4 row0;
    float4 row1;
    float4 row2;
    // row3 is omitted because it is always [0, 0, 0, 1]
};

And the above doesn't even describe how the contents should be interpreted, i.e. the whole row-vector vs column-vector thing. In addition, how would you define operators such as matrix * matrix? Both matrix multiplication and element-wise multiplication are common for that operator.

Thing is, none of the above approaches are "more correct" than the others. If Zig where to choose one as the "default one" it would be a pain for people who prefer something else. As a general purpose low-level programming language I don't think Zig should impose on the user that way. With vector primitives we don't really have that same problem, because there aren't that many options.

This is a really well written proposal, thanks for putting the effort into it! After reading through the spir-v spec, I think this might be a decent candidate to be in the language. Specifically, the operators and swizzling correspond to spir-v instructions that would be difficult for the compiler to identify otherwise.

~I think the builtins should probably be in the standard library instead of in the language. Even spir-v doesn't encode these operations as opcodes, and it's not clear to me that the optimizer benefits from knowing that an operation is a dot/cross/length operation.~

There is an existing proposal that covers matrices separately (#4960), so I agree that this one should just focus on vectors. However if both are accepted we should make them consistent with each other. Either both should be limited to max 4 items on any axis or both should allow an arbitrary comptime-known length.

Edit: I was wrong, spir-v does indeed have OpDot and OpOuterProduct. So maybe these should be builtins.

Other than a handful of builtins, what does this give us that our existing SIMD support doesn't have?

I will admit that I'm a complete Zig noob, any weirdness might be because I don't fully understand all parts of the language yet.

I think the builtins should probably be in the standard library instead of in the language. Even spir-v doesn't encode these operations as opcodes, and it's not clear to me that the optimizer benefits from knowing that an operation is a dot/cross/length operation.

(I saw your edit, but I would like to add to this) There's also more optimizations to normalize() that are sort of hardware dependent. An ideal implementation would be:

fn normalize(v) v {
    return rsqrt(dot(v, v)) * v;
}

Where rsqrt() is the reciprocal square root, i.e. 1/sqrt(x) which is available on some CPUs and probably all GPUs. But the above solution is probably not wanted in a debug build because it might be harder to trap errors if v == 0.

Beyond that it's also a question about syntax. From the proposal:

As far as I have understood it Zig does not support function overloading, which means that these need to be implemented using the special @func() syntax.

But I just did a quick search through the zig documentation and realized I had completely missed anytype, if we could just write dot(a, b) instead of @dot(a, b) then of course that would be better. My bad. :)

Really, it's all about avoiding the stupid syntax I (and many others) had to deal with when we started out in Java many years ago. Examples of good syntax for the dot() function:

  • dot(a, b)

Examples of bad/terrible syntax for the dot() function:

  • dot(u32x2, a, b)
  • i16x3.dot(a, b)
  • Math.dot(a, b)
  • a.dot(b)

Other than a handful of builtins, what does this give us that our existing SIMD support doesn't have?

I'm going to admit I don't know much about how Zig's SIMD implementation works, and the docs is just a big TODO. My answer assumes it works similarly to __m128 or __m128i.

This proposal adds many new primitives:

// Unsigned
u8x2, u8x3, u8x4, u16x2, u16x3, u16x4, u32x2, u32x3, u32x4, u64x2, u64x3, u64x4

// Signed
i8x2, i8x3, i8x4, i16x2, i16x3, i16x4, i32x2, i32x3, i32x4, i64x2, i64x3, i64x4

// Floating point
f16x2, f16x3, f16x4, f32x2, f32x3, f32x4, f64x2, f64x3, f64x4

// The most common ones to use (imo)
u8x4, u32x2, u32x3, u32x4, i32x2, i32x3, i32x4, f32x2, f32x3, f32x4

Out of all these new primitives, only very few actually makes sense for SIMD at all, i.e. u32x4, i32x4 and f32x4. And even then, these ones with potential SIMD capabilities probably shouldn't be 16-byte aligned because that would mess up structs with padding and such. (Or maybe that's not a thing? I just assumed Zig used C's struct layout so you could share memory between CPU/GPU easily...)

Overall, I would expect that @alignof(i32x4) == @alignof(i32) and @sizeof(f32xN) == @sizeof(f32) * N for all vector primitive variants.

That's pretty much how existing Zig SIMD works, except with flexible rather than hardcoded length. Alignment may not work exactly like that, but that can be fixed if it's a problem -- we are pre-1.0, after all. (Also, the compiler is free to represent structs however it likes: reordering fields, inserting padding etc. You can force it to use native ABI struct layout with extern.)

The problem I have with this proposal is that it doesn't enable anything that we can't already do, and hardcodes many details that are not universal. Much like we have arbitrary-width integer types, which we trust the compiler to represent and operate on in the most optimal format, we can do the same with the existing vector primitives.

It sounds like there might be a good opportunity to merge the two then. Take the good parts from this proposal and the good parts from the SIMD one. Though I would humbly suggest not calling it SIMD if it's not guaranteed hardware SIMD. ;)

Anyway, now I'm way out of my league and this discussion is probably better taken over by people who actually work on Zig 馃槄 Thanks for taking the time with my request. I'm of course still available if there are any questions about this proposal itself or anything related. :)

SIMD types are very different from what is proposed here. The purpose of SIMD types is to tell the compiler to use SIMD registers and instructions, and for most use cases of the proposed short vectors, that would actually be quite slow, which is also why the current vector type is recommended against unless you're specifically trying to write code to take advantage of SIMD without relying on auto-vectorisation.

We already have [3]u32, [4]f32, and the like, why not have well-defined semantics for doing element-wise math operations on scalars/arrays of compatible types (i.e. same length and element type)? Like how Zig doesn't have an explicit string type but instead uses []u8.

@Sizik How would element access work in this case? Would you be able to use .x, .y, .z, .w?

@zigazeljko Element access would be just through vec[1] instead of vec.x in this case, which is a downside compared to a dedicated struct type. Perhaps some way to assign names to array slots would be nice, like how in C you can use a union to seamlessly convert between treating data as a float vec[3]; and float x; float y; float z;. Zig unions might not be the right thing to use for this, but a new way to declare struct fields might. Here's an example of how it could work (although this is starting to veer a bit into #7311):

const Vec3 = struct {
    elements: [3]f32,
    alias x = elements[0],
    alias y = elements[1],
    alias z = elements[2],
}

Perhaps some way to assign names to array slots would be nice.

This could be solved with something like #793:

const Axis = enum { x, y, z };
const Vec3 = [Axis]f32;

const foo = Vec3{ 3.0, 4.0, 5.0 };
foo[.x] == 3.0;

Additionally, we can add foo.x as syntactic sugar for foo[.x].

Was this page helpful?
0 / 5 - 0 ratings