ziglang / zig

General-purpose programming language and toolchain for maintaining robust, optimal, and reusable software.
https://ziglang.org
MIT License
33.68k stars 2.47k forks source link

`and` cannot operate on vectors of bool despite the manual claiming otherwise #14306

Open notcancername opened 1 year ago

notcancername commented 1 year ago

Zig Version

0.11.0-dev.1300+7cb2f9222

Steps to Reproduce and Observed Behavior

zig test this code:

test {
    const bv = @Vector(4, bool){false, true, false, true};
    const ma = @Vector(4, bool){true, true, false, false};
    _ = bv and ma;
}

An error is output:

error: expected type 'bool', found '@Vector(4, bool)'
    _ = bv and ma;
        ^~

Expected Behavior

The manual says that

Vectors support the same builtin operators as their underlying base types.

This isn't the case for and. Based on the manual, I expected that bv and ma would evaluate to @Vector(4, bool){false, true, false, false}.

The same applies to or and !.

rohlem commented 1 year ago

I'm surprised to see this relabeled from documentation issue to implementation bug, so I'll write down my perspective/interpretation to maybe start the discussion of what the desired resolution should be.

Background

First of all, there are two variants of the mathematical operations named here in Zig: - defined on bits ("bitwise"): `~`, `|`, `&` - defined on booleans: `!`, `or`, `and` The unary operations are less interesting, while `~` is defined to flip every bit in an integer, for a boolean it is defined to exchange `true` and `false`. While this seems identical if a boolean is viewed to have a single backing bit, in hardware they might be larger (say an entire byte or register), giving more freedom in how the two states are represented, which is why the compiler might choose to emit different instructions for each. The binary operations have a more important, fundamental distinction, in that `or` and `and` are **short-circuiting.** That means that the right-hand operand is only evaluated (its calculation executed) if the result isn't dictated by the left-hand operand: | | `x==true` | `x==false` |---:|---|--- `x and f()` | `f()` is executed | `f()` not executed (always results in `false`) `x or f()` | `f()` not executed (always results in `true`) | `f()` is executed (Note: This leads to the neat consistency that Zig's _keyword_ operators (`if`, `catch`, etc.) influence control flow, while _symbol_ operators (`+`, `!`, etc.) don't.) Now Zig defines operations on vectors to generally work element-wise. So `a = b + c` with 4-element vectors results in a vector holding the sum of each element, so `a[i] == b[i] + c[i]`. Vector types are meant to express SIMD (Single Instruction (with/on) Multiple Data) operations, and the compiler will try to lower them using appropriate instructions (where reasonably efficient). However, while SIMD instruction sets have traditionally been good at expressing parallel operations, expressing "element-wise optional" operations (f.e. by masking some elements) is more complex. Some instruction sets give more tools for this, and some architectures and implementations are more efficient at this, than others. ----

Now the crux is this: To generalize short-circuiting operations and and or to vector elements, we need to decide on their impact on control flow. I see 3 options for this (using example snippet bv and f()):

  1. (easy, but imo less regular) Make them non-short-circuit. Always fully evaluate the right-hand side f(), then do the binary operation.
  2. (a litte more costly to implement) Make them short-circuit only when the right-hand side f() would be fully discarded. This means we would need to decide control flow based on whether left-hand side bv is fully true for or, and fully false for and.
  3. (very complex, but imo the most regular) Make them element-wise short-circuit. Emit a compiler error where this is impossible, either due to theoretical limitations (ambiguity) or practical limitations in the compiler (f.e. external code).

Currently the operation isn't implemented, and afaik what Zig should choose to do hasn't been decided yet. While 3 would be really neat for aesthetic reasons (i.e. sense of accomplishment), and although there might be advantages in certain scenarios, implementing that behavior would surely be a technical challenge (and I won't go into even more detail unprovoked).

I personally dislike option 1 for the reason that, if we allowed bitwise operators ~, |, and & on bool values with non-surprising semantics, then they would be exactly equivalent to option 1 above. Having both types of operators map to the same operation here, on top of maybe being surprising, nets us no benefit over just not supporting or and and on vectors, which is why I'd personally prefer to see option 2 AND bitwise operations on bool values implemented.

IntegratedQuantum commented 1 year ago

I think Option 1 Make them non-short-circuit is the best option.

I personally think of vector instructions like this:

z = x() + y();
// is conceptually equivalent to doing this:
var temp1 = x();
var temp2 = y();
z[0] = temp1[0] + temp2[0];
z[1] = temp1[1] + temp2[1];
...

When thinking like this the and operator automatically becomes non-short-circuit:

z = x() and y();
// is conceptually equivalent to doing this:
var temp1 = x();
var temp2 = y();
z[0] = temp1[0] and temp2[0];
z[1] = temp1[1] and temp2[1];
...

So I would be really surprised if in a small number of cases y() wasn't evaluated. ​ ​

While Option 2 doesn't fit my mental model, maybe it's at least a useful optimization?

Let's consider the code x or f(y) How likely is it that a single bool x is true? ​ ​ ​ ​ ​ ​50% → we need to evaluate f(y) 50% of the time. How likely is it that a @Vector(8, bool) x is true in every field? ​ ​ ​ ​ ​ 6% → we need to evaluate f(y) 94 % of the time. This is not worth in my opinion. Especially considering that on arm @reduce() takes a lot of operations. So for small f(y) short-circuiting would be significantly slower on arm. While on the other hand even for the biggest possible f(y) we can't get more than a 6% improvement on average.

And I don't think we need to consider option 3. It defeats the point of vector operations and it would be super complicated to implement.

silversquirl commented 1 year ago

The manual says that

Vectors support the same builtin operators as their underlying base types.

and and or are not operators, they're control flow keywords.

Making them not short circuit on vectors would be inconsistent with how they normally work, which is very confusing. As for having them do partial short circuiting, that's a potentially-expensive, hidden operation, which is very much in opposition to Zig's design.

My suggestion would be to make & and | work on bools and vectors of bools as non-short-circuiting operations, and keep and and or only for single bools.

IntegratedQuantum commented 1 year ago

and and or are not operators, they're control flow keywords.

and and or are listed in the table of operators, so I would assume that they are operators.

silversquirl commented 1 year ago

While they're parsed as operators, they explicitly do not have symbolic names like operators do, and they're not treated as operators internally (eg. in Zir they use a special bool_br field rather than pl_node with a Bin payload like the binary operators do).

There have been issues about renaming them to && and || in the past, and the conclusion has been "they're named this way to distinguish them from operators, which don't do control flow"

notcancername commented 1 year ago

To summarize, I believe these are proposed solutions so far:

dweiller commented 1 year ago

The semantic effect (haven't checked any codegen) of non-short-circuiting and and or on vectors should be achievable using @select:

const a = @Vector(4, bool){ true, false, true, false };
const  b = @Vector(4, bool){ true, true, false, false};
const a_or_b = @select(bool, a, a, b);
const a_and_b = @select(bool, a, b, a);

However, this doesn't really seem to clearly communicate the intent to the compiler or reader to me, and takes a bit to understand especially with something like this

const a = @Vector(4, bool){ true, false, true, false };
const b = @Vector(4, bool){ true, true, false, false};
const any_true = @reduce(.Or, @select(bool, a, a, b));           // @reduce(.Or, a or b)
const all_true = @reduce(.And, @select(bool, a, b, a));          // @reduce(.And, a and b)
const all_true_per_lane = @reduce(.And, @select(bool, a, a, b)); // @reduce(.And, a or b)
const one_lane_both_true = @reduce(.Or, @select(bool, a, a, b)); // @reduce(.Or, a or b)