Integers - how ought they work?
I recently wrote a post about integer
division, where the focus was on avoiding
undefined behaviour in the C code generated by the Futhark compiler. This post
was discussed in various august venues such as #proglangdesign on Libera Chat
(join it!),
lobste.rs,
and by the coffee machine at the department. One thing
missing from that post was reflection about why we wanted this case of integer
division to silently wrap around on overflow in the first place. Undefined
behaviour is of course unacceptable, but if i32.lowest / -1 is not
representable as an i32, why do we want it to silently overflow to
i32.lowest again, rather than signal some kind of error? In Futhark, division
by zero signals an error, so clearly we are not averse to the idea of run-time
errors. This post is not going to be about any change in the design of a
language, but rather an outline of the various options available to us, and why
Futhark ended up with this somewhat inconsistent design.
First, let us establish that C-style “undefined behaviour” is not acceptable for
Futhark. This is unrelated to Futhark’s intended niche as a high-performance
language (perhaps even a bit contrary), but
simply follows from our fundamental hypothesis that efficient parallel
programming need not be complicated, unsafe, or have weird semantics.
Compromising on safety would be a violation of the entire underlying vision of
the language. Futhark does have an #[unsafe]
attribute that can be used to disable
memory safety (it disables bounds checking), but it is an explicit opt-in
mechanism, and not something fundamental to the language.
The other extreme is that every operation must be checked, and if it cannot be
done correctly, then an error must be reported. As a classically trained
computer scientist, I of course like this in principle. We also have some
precedence: array indexing and similar operations are dynamically checked, and
invalid operations are detected and reported. Well-designed languages like
Standard ML (the language of the future) raise
Overflow exceptions on arithmetic that exceeds the range of fixed-size integer
types, and types are provided for arbitrary-sized integers that cannot overflow.
Nonsensical operations like division by zero similarly result in exceptions.
While it is fair and reasonable to discuss whether these failures should instead
be reported by a sum type or perhaps with a Flix-like
effect system, the principle is the same: whenever the “mathematically correct”
result is not representable in the chosen type, an error is signalled somehow.
This approach is technically viable for Futhark, due to the work we did on
bounds checking on GPU, which is really a
technique for synchronously aborting execution even on execution platforms that
do not really support that. It is also the mechanism we use to implement
assert and checks for division by zero, and we could use it to signal integer
overflow.
However, while the post linked above shows that bounds checking carries a mere
4% overhead on the Futhark benchmark suite, it is worth remembering that bounds
checking is relatively rare. Many array accesses in Futhark programs arise from
combinators such as map and reduce that are statically known to be safe such
that no checking at run-time is needed, and even when you perform manual array
indexing, the compiler can still in many cases remove them as an optimisation.
Integer arithmetic is, to put it mildly, a somewhat more common operation, and
stopping execution would likely have significant overhead. A clever
implementation can probably reduce the overhead somewhat, as while a failed
bounds check means we have to stop execution immediately, it is probably fine to
defer an overflow check until execution reaches a convenient stopping point,
although that does of course raise an issue of what happens if that overflow
error is all that prevents an infinite loop.
Another issue is that we really want to be able to pinpoint the precise location
that an overflow occurred, which likely means having logic after every
arithmetic operation to store pertinent information.
A question I have not even bothered investigating is how to even detect integer overflow on the various platforms that Futhark targets - that may be inefficient all on its own.
Overall, Futhark is caught in an awkward tension between performance and correctness, and since it is a language that aspires to both, how is the tension to be resolved?
One resolution is to look at how we actually define “correctness”. If error reporting is slow, why not just report fewer errors? Why should division by zero be an error anyway? Let’s just define the correct result to be something else, like zero! Pony does it that way, as does Flix. This does not fit the standard mathematical definition of division, but neither does overflow during integer arithmetic (it does model modular arithmetic, but we could define it as modeling something else where division by zero is defined in whatever way we like). The downside of this approach is the risk of hiding coding errors, as it is possible that a division by zero represents a logic bug. However, a programmer could always restore the error-rasing behaviour in their own code, by defining their own function:
def checked_division x y = assert (y != 0) (x/y)This is a nice trick that a PL designer can always use: since the designer defines what correctness means, we can just define it as whatever is convenient, as long as we are consistent about it. We must of course face the judgment of our ancestors when one day we reach Valhalla.
This leaves us with the following facts:
Wrapping arithmetic is a legitimate, precise, and conventional semantics.
Not signalling an error on
x/0and similar cases is legitimate and precise, but not conventional.Signalling an error on overflow is technically possible, but likely too inefficient for a language with Futhark’s performance requirements (or to be more realistic: performance dreams).
Since it is likely too inefficient to signal errors on integer overflow, the only completely uniform design available to Futhark is to ensure that all integer operations have well-defined results for all inputs, without failing.
That said, I am in no hurry to make the change, and I admit that I am partly too craven to face the judgment of my (living) colleagues when I have to explain why division by zero produces zero. Since Futhark does not allow errors to be handled from within the program, it is technically not a breaking change if we one day make these operations fully defined, and hence I am willing to wait and see if the idea continues to seem compelling, and whether my courage grows sufficiently. It may also be that we decide that uniformity of this kind serves no purpose, and I think Ralph Emerson once said something about foolish consistency and brain leprechauns…