Thought: switch the default on overflow checking and provide RFC 560's scoped attribute for checked arithmetic

Having participated in a long discussion on users.rust-lang.org I've learned how Rust currently handles overflow in (plain) integer arithmetic. The current behavior appears to be the result of a compromise involving two RFCs, RFC 146 which was streamlined into RFC 560 which is currently implemented. I have read the long and insightful discussion in that RFC that justifies the current design, which involves a combination of setting rules (Rust doesn't, by default, support wrapping) and localized enforcement (debug_asserts in debug mode, a special optional -Z option for release mode), all with an eye towards striking a compromise between safety and performance. It also acknowledges a number of drawbacks. I apologize if I missed a more recent RFC on this issue.

Here are my thoughts and opinions.

First, I find the current behavior confusing and easily misunderstood as making wrapping an acceptable default. I should be clear that I understand that's not actually the case: the RFC is clear that overflowing operations (if they don't panic) produce an "undefined" value which presumably makes all derived values undefined as well, and is considered a program error. Still, I was a bit reminded of the signs you sometimes see along US Highways: "Speed limits enforced by aircraft" - which to me at least invoke a chuckle knowing that US police departments haven't had the budget to do so for many years. Perhaps the ominous "undefined value" or "considered program error" may have similar (IMO limited) deterrent value.

Second, the existing divergence of optimized and debug code is already acknowledged in the drawback section of RFC 560. But some programs may never run in a debug environment (or may use test data sets that are not known or too large for a time budget). I'm thinking of online environments such as Leetcode, Kattis, CodeForces which are run outside the cargo environment. Moreover, users of these environments do not even have the option to add compiler switches. This was less of an issue at the time when RFC 560 was written due to the lesser adoption of Rust at that time. In the future, we all expect that Rust will find more and more uses, which may be varied.

Third, I find it really unsatisfying that the programmer lacks control regarding the semantics of plain integer operations in an actual running Rust program, especially in light of what appears to be the lackluster enforcement referred to above, that is, the status quo that it could be either a wrapping or a checked behavior, depending on how their code is compiled or embedded. I believe that few if any other language provides the same degree of uncertainty regarding the actual execution (with perhaps the exception of signed integer overflows in C, which however was not designed recently). It certainly makes it difficult for professors in CS courses to assign questions like: "what does a + b do when you run the program" when exactly this question in reality depends on a number of ifs and whens and buts about the environment.

To the best of my knowledge, this lack of certainty about the actual execution sets Rust apart from Swift, Python, Java, (unsigned ints) in C, Golang, some of which are languages Rust is aiming to take market share from.

Fourth, I would love to be able to write code that at least locally sets a particular execution semantics for plain integers, without relying on how the code will be compiled. Existing approaches include std::num::Wrapping and the checked_* methods, neither of which I find very ergonomic.

How to fix this?

I think that, perhaps, Rust should take a stance and make overflow checking the default in all compilation modes, rather than viewing overflow checking as an expendable assertion. This would yield consistent semantics and remove the perceived ambiguity present in the status quo. It would not be a breaking change in terms of language semantics based on RFC 560. (Although the analogy doesn't fully carry over, it's after all the stance Rust takes wrt safe vs unsafe: safe the default, and you opt out with unsafe.)

Then, provide various ways of opting out, such as a crate-level attribute (overflows-checks=false), a compiler switch (-Z skip-overflow-checks instead of -Z force-overflow-checks), etc. to easily mitigate any possible performance impact.

Finally, and perhaps independently of changing the default, address the problem of lack of programmer control, introduce the scoped attribute for checked arithmetic RFC 560 alluded to (but considered future work) that allows a programmer to enforce a particular semantics within a lexical scope. (This would presumably not be a context like C#'s checked context, i.e., it would not extend to functions called from within the scope.) This approach would have the advantage that programmers can choose a desired semantics for their plain integer operations independent of how their code will be compiled or otherwise combined, at least for the portions that they implement). The directive would override crate-level attributes and compiler switches.

1 Like

To the best of my knowledge most, or perhaps all, modern CPU archiectures are unable to provide hardware support for precise program abort on overflow occurrence. Thus such checking would appear to require software conditional branches after all non-declared-wrapping arithmetic operations, including after indexing operations, with all the consequent slowdown impact on instruction pipelines and concurrent ALU operations that such sequentiality implies. My guess is that adoption of such a position on default arithmetic behavior would cause Rust performance to degrade by a factor of between 1.5x and 5x. (I.e., existing programs would take 150% to 500% as long as they do today.)

Edit: Minor correction of 50% to 150% in last parenthetical.

2 Likes

In addition, it's already common to see people ask about poor Rust performance when they haven't used the release profile. I think needing an additional flag to get the expected performance would make this a lot more common, especially given all the material that already exists online that would make no mention of the new flag/attribute.

5 Likes

No, they produce a wrapped value. The rules are:

  • Debug Mode: Panic on Overflow
  • Release Mode: Panic or Wrap on Overflow (depending on implementation)

It is never undefined value or undefined behavior.

8 Likes

I'm sorry, you're correct. I was referring to nikomatsakis' original proposal that proposed an undefined value. In any event, the RFC still declares reliance on wrapping a program error.

Published numbers by Dietz et al (2012) show an overhead "Using flag-based postcondition checks, slowdown ranged from 0.4%–95%, with a mean of 30%" (I hope I picked the correct result here.)

To me those numbers imply imprecise program abort – which I would find satisfactory – and performance hits varying by architecture. Without reading the paper, it seems unlikely to me that the current-generation popular ISAs provide such cumulative result flags. In particular, how would those features be implemented in RISC-V or a recent ARM ISA? My meaning here is not that they can't be implemented, but that in the billions? trillions? of CPUs currently in use they are probably not implemented.

In my first few months of learning Rust I delved into the compiler to determine what it would take to track signed vs. unsigned values. Whether or not overflow occurs depends on the semantics of the values: is 0xFF an 8-bit -1i8 or is it u8::MAX?

Two-operand mixed-mode arithmetic has four possibilities for its arguments and two possibilities for the desired results. Even restricting to realistic combinations and using commutivity still leaves four commonly-occurring alternatives: `i8 op i8 -> i8, i8 op u8 -> i8, u8 op i8 -> u8, u8 op u8 -> u8'. Hardware "add" instructions would need to know the program's intent to correctly determine whether an "overflow error" had or had not occurred. Likewise for many other binary arithmetic operators.

I'm not a computer architecture expert but I don't have the intuition that there's a complete lack of architectural support for either traditional x86_64 nor recent RISCs. Did they remove the overflow flag?

As an example look at this godbolt for checked_add on x86_64. It basically turns

        lea     eax, [rdi + rsi]

into

        add     edi, esi
        jb      slowpath
        mov     eax, edi
        ret

which shouldn't affect the pipeline very much if correctly predicted, but presumably there are more difficult situations. In any event, there's no doubt that there's a performance impact (and that was what drove the adoption of RFC 560 perhaps). An analysis of the impact (crater?) may be required. And there are of course other options, such as grandfathering in older crates - perhaps?)

Sign-magnitude arithmetic, such as IEEE 754 floating point, makes it fairly easy to determine that overflow has occurred, which is one reason why IEEE 754 has infinities. Two's-complement (and today-seldom-used one's-complement) arithmetic use overlapping representations for large unsigned values and small negative signed values, making such hardware inferences unreliable unless the instruction stream contains indicators of intent.

I listed modern RISC ISAs above that are used in cellphones and supercomputers. I'm aware that the x86_64 architecture, which is descended from the in-order-execution single-level-memory 8086 CISC ISA that was specified by Intel in 1976, has such features. However the industry has learned a lot in the 45 years since the 8086 ISA was created. For example, the Intel ISA has a carry flag. RISC-V does not, since implementing that flag in hardware either slows down instruction execution or adds a substantial number of gates (and concomitant energy usage) to a minimal ISA implementation.

More precisely, that depends on debug_assertions being on or off.

2 Likes

I actually think it is good: this clarifies that computer arithmetic is, by nature, complicated. In fact, I think that it's even good you can't ask that: you ask because the behavior is confusing, and if you cannot ask, then Rust does a good job clearing up the fog. I don't think Rust should be a unclear so that CS professors will have what to ask; I prefer Rust to be clear and the professors to focus on more useful things.

I find unchecked {} block or something like that worse. While it may be unergonomic to use the existing solution, this solution will make each an every arithmetic operation inside overflows. You'll either end up with many blocks (which is just easier to call wrapping_X(), or wrap everything in a unchecked block, leading to less safety.

BTW, in my opinion, we made the same mistake with unsafe blocks (though here there is more rational for that, as unsafe operations are usually grouped with the same safety reasoning, and also coincidental unsafe code is less common than arithmetic, and also, unsafe code outside of an unsafe block will fail to compile, while an unchecked block silently changes the meaning).

1 Like

I would suggest starting with trying to make things ergonomic first. For example, perhaps the wrapping types could be made more ergonomic with custom literal support. (The NonZero types would also appreciate having that.)

Even changing the default just for usize was deemed too impactful in https://github.com/rust-lang/rfcs/pull/2635

3 Likes

Warning against "scoped attribute" approach: it creates a new kind of generics in the language. This is because there's a question whether the attribute takes effect through function calls:

  • if it doesn't, then you can't count on all the code within the scope to be checked. If checking is so important, then a partially working half-measure may not be satisfactory. Think for example about iterator.map - it is an indirection through several layers of function calls.

    Also a + b desugars to std::ops::Add::add(a, b), so technically all arithmetic is done through function calls. Literal a + b could be special-cased in the compiler, but it would end up behaving differently than generic code. Inlining is also a problem, because it's not supposed to affect semantics.

  • if it does take effect through function calls, then Rust will need two versions of functions: one with checks for use within the scope, and another for use outside of the scoped attribute. When this is used across crates, this is highly problematic. It either requires compiling every function twice, or makes every function effectively generic over the checks attribute, and a candidate for monomorphisation.

5 Likes

I was naively thinking of external functions, that is, those already compiled externally in a different compilation unit. I think in Rust this would be those compiled in a different crate?

Rust doesn't have a clear boundary between compilation units because of generics (same issue like C++ templates and C/C++ header-only libraries). Traits and their default implementation may be defined in one crate, but be compiled in another. Code is also automatically split into "codegen units" for parallelism, and this is a bit arbitrary and even duplicates function definitions across units.

What about #![allow(arithmetic_overflow)] for instance. Is it well-defined which regions of code such an attribute applies to in the presence of generics?

It applies to all the code declared (not executed!) inside the marked item.

Well, it's a lint, so it doesn't need to be precisely defined. Unlike semantics (like a wrapping{} block would be) lints are allowed to change what they do.

But note that, for example, #[deny(arithmetic_overflow)] (0..usize::MAX).product() will not lint because it doesn't know what the internals will do, nor does it affect them.

Not quite true.

Specifically, overflow checks are controlled by

The default, if unspecified, is indeed to match debug_assertions. So any mode of compilation is allowed to either wrap or panic on overflow, with an implementation defined default choice, and an expectation of the implementation providing a flag to control the behavior.


So if you want an actual measurement of how much overflow checks cost in practice, it's actually relatively simple: take some largeïsh Rust program that does a reasonable amount of math (which isn't primarily done via explicit overflow semantics), and run some performance test with overflow-checks set on and off.

7 Likes