Skip to content

Add explicitly wrapping versions of integer arithmetic#50790

Merged
Keno merged 5 commits into
masterfrom
kf/wrapintops
Jul 10, 2026
Merged

Add explicitly wrapping versions of integer arithmetic#50790
Keno merged 5 commits into
masterfrom
kf/wrapintops

Conversation

@Keno

@Keno Keno commented Aug 3, 2023

Copy link
Copy Markdown
Member

This adds operators +%, -%, *%, which are equivalent to the non-% versions, but indicate an explicit semantic expectation that twos completement wrapping behavior is expected and correct. As discussed at JuliaCon 2014 and every year since, users have often requested a way to opt into explicit overflow checking of arithmetic, whether for debugging or because they have regulatory or procedural requirements that expect to be able to do this. Having explicit operators for overflowing semantics allows use cases that depend on overflow behavior for correct functioning to explicitly opt-out of any such checking.

I want to explicitly emphasize that there are no plans to change the default behavior of arithmetic in Julia, neither by introducing error checking nor by making it undefined behavior (as in C). The general consensus here is that while overflow checking can be useful, and would be a fine default, even if hardware supported it efficiently (which it doesn't), the performance costs of performing the check (through inhibition of other optimization) is too high. In our experience it also tends to be relatively harmless, even if it can be a very rude awakeing to users coming from Python or other languages with big-default integers.

The idea here is simply to give users another tool in their arsenal for checking correctness. Think sanitizers, not language change. This PR includes a macro @Base.Experimental.make_all_arithmetic_checked, that will define overrides to make arithmetic checked, but does not include any mechanism (e.g. #50239) to make this fast.

What is included in this PR:

  • Flisp parser changes to parse the new operators
  • Definitions of the new operators
  • Some basic replacements in base to give a flavor for using the new operator and make sure it works

Still to be done:

  • [] Parser changes in JuliaSyntax
  • [] Correct parsing for +% by itself, which currently parses as +(%)

The places to change in base were found by using the above-mentioned macro and running the test suite. I did not work through the tests exhaustively. We have many tests that explicitly expect overflow and many others that we should go through on a case by case basis. The idea here is merely to give an idea of the kind of changes that may be required if overflow checking is enabled. I think they can broadly be classed into:

  • Crypto and hashing code that explicitly want modular arithmetic
  • Bit twidelling code for arithmetic tricks (though many of these, particularly in Ryu, could probably be replaced with better abstractions).
  • UInt8 range checks written by Stefan
  • Misc

That said, I'm not sure I'll have the time to actually finish this, so I'd be grateful if someone else wanted to take this over to push it through.

@c42f

c42f commented Mar 12, 2024

Copy link
Copy Markdown
Member

In order to decide what to do about JuliaLang/JuliaSyntax.jl#408, I think we need to have the syntax debate here in JuliaLang/julia @jakobnissen

Looking through the files, I can see various cases where +% is mixed up with other operators in a fairly natural way which motivates having special syntax for this.

I'm not sure it deserves its own syntax though, and I'd like to discuss other possibilities:

A macro

We could have a macro (eg, @modular) which rewrites operators to user modular versions. Similar in spirit to @fastmath, but indicating a difference in operator semantics rather than an unsafe performance annotation.

  • Pros: no special syntax, cleaner if all arithmetic in an expression is modular, can support modular assignment operators, some analogy to the mathematical notation a + b == c (mod m) where the mod is done separately.
  • Cons: less clean than special syntax if there's a mixture of modular and non-modular

Code example from PR where it's cleaner:

    s4::UInt64 = s0 +% (3 *% s1) +% (5 *% s2) +% (7 *% s3), # internal splitmix state
# vs
    s4::UInt64 = @modular s0 + (3 * s1) + (5 * s2) + (7 * s3), # internal splitmix state

Using unicode subscripts

For example +ₘ (the subscript m being for "modular", for example)

  • Pros: clean for mixed expressions, other pros of special syntax
  • Cons: Can't do modular op-assign operators like x +=ₘ y
    s4::UInt64 = s0 +% (3 *% s1) +% (5 *% s2) +% (7 *% s3), # internal splitmix state
# vs
    s4::UInt64 = s0 +ₘ (3 *ₘ s1) +ₘ (5 *ₘ s2) +ₘ (7 *ₘ s3), # internal splitmix state

Plain old function names

  • Pros: Plain ascii names, no additional syntax
  • Cons: Less visual analogy between modular vs non-modular expressions. Can't do op-assign operators.

Special syntax

  • Pros: Only ascii - easy to type. Works well for mixed modular and non-modular expressions
  • Cons: Yet more special syntax. Compatibility issues as usual; eg can't be covered neatly by Compat.jl

Summary

Considering the above, I'm somewhat favoring the idea of a @modular macro which rewrites non-modular arithmetic operators into modular ones (with + calling some Base.add_mod or whatever) . It can do all the same things as special syntax, and arguably has some cute analogy to the mathematical notation.

(What if, as a future extension @modular m (x + y) could do modular arithmetic in base m?)

@Keno Keno added the triage This should be discussed on a triage call label Mar 2, 2026
@Keno

Keno commented Mar 2, 2026

Copy link
Copy Markdown
Member Author

I would still like to this. Tagging for triage to decide on direction.

@adienes

adienes commented Mar 26, 2026

Copy link
Copy Markdown
Member

I'm sure it was intentional but I don't see it explicitly stated yet, so just for posterity: Zig uses this syntax for this purpose (https://ziglang.org/documentation/master/#Wrapping-Operations)

@StefanKarpinski

Copy link
Copy Markdown
Member

Triage likes. We really should semantically be distinguishing between intentionally wrapping arithmetic and arithmetic that isn't expected to wrap. The biggest issue is that actually implementing error on overflow everywhere wrapping isn't supposed to happen seems like it will be expensive... so it gets put into a hypothetical --debug flag. But we're already having issues with running tests that automatically use the --check-inbounds=yes flag causing massive precompile times, and kind of want to not do that automatically. Which relegates the --debug flag to a thing that won't get tested unless someone's really at their wit's end and is willing to recompile the world in order to get some extra testing. But still, we should do this.

@oscardssmith oscardssmith removed triage This should be discussed on a triage call labels Apr 9, 2026
@Keno
Keno force-pushed the kf/wrapintops branch from 3b57645 to 5a3704c Compare July 7, 2026 03:29
@Keno Keno changed the title WIP/RFC: Add explicitly wrapping versions of integer arithmetic Add explicitly wrapping versions of integer arithmetic Jul 7, 2026
@Keno
Keno marked this pull request as ready for review July 7, 2026 03:30
@adienes

adienes commented Jul 7, 2026

Copy link
Copy Markdown
Member

I'm not sure I understand all the proposed usages in Base. most seem at best unnecessary. as one random example:

(time_ns() -% t0) / 1e9

I think would be the opposite of holding "an explicit semantic expectation that [overflow] is expected and correct," as your aggregate timing estimator will be quite sad if some overflowed value skews it

@Keno

Keno commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

I had GPT 5.5 go through and examine each site, telling it to err on the side of switching to wrapping arithmetic (since that's the non-breaking option under the flag). In this particular example, it was concerned about overflow on a system with uptime of >584 years and reasoned that a bad print was better than an error. That said, there's also an issue with time_ns not being documented as being guaranteed monotonic across suspends. But yeah, in any case, it's supposed to be conservative, but happy to discuss about any particular instance.

Keno and others added 2 commits July 7, 2026 06:25
This adds operators `+%`, `-%`, `*%`, which are equivalent to the
non-`%` versions, but indicate an explicit semantic expectation that
twos completement wrapping behavior is expected and correct. As discussed
at JuliaCon 2014 and every year since, users have often requested
a way to opt into explicit overflow checking of arithmetic, whether
for debugging or because they have regulatory or procedural requirements
that expect to be able to do this. Having explicit operators for
overflowing semantics allows use cases that depend on overflow behavior
for correct functioning to explicitly opt-out of any such checking.

I want to explicitly emphasize that there are no plans to change
the default behavior of arithmetic in Julia, neither by introducing
error checking nor by making it undefined behavior (as in C). The
general consensus here is that while overflow checking can be useful,
and would be a fine default, even if hardware supported it efficiently
(which it doesn't), the performance costs of performing the check
(through inhibition of other optimization) is too high. In our experience
it also tends to be relatively harmless, even if it can be a very
rude awakeing to users coming from Python or other languages with
big-default integers.

The idea here is simply to give users another tool in their arsenal
for checking correctness. Think sanitizers, not language change.
This PR includes a macro `@Base.Experimental.make_all_arithmetic_checked`,
that will define overrides to make arithmetic checked, but does not
include any mechanism (e.g. #50239) to make this fast.

What is included in this PR:
 - Flisp parser changes to parse the new operators
 - Definitions of the new operators
 - Some basic replacements in base to give a flavor for using the
   new operator and make sure it works

Still to be done:
 - [] Parser changes in JuliaSyntax
 - [] Correct parsing for `+%` by itself, which currently parses as `+(%)`

The places to change in base were found by using the above-mentioned
macro and running the test suite. I did not work through the tests
exhaustively. We have many tests that explicitly expect overflow and
many others that we should go through on a case by case basis. The
idea here is merely to give an idea of the kind of changes that
may be required if overflow checking is enabled. I think they can
broadly be classed into:

- Crypto and hashing code that explicitly want modular arithmetic
- Bit twidelling code for arithmetic tricks (though many of these,
  particularly in Ryu, could probably be replaced with better
  abstractions).
- UInt8 range checks written by Stefan
- Misc
@Keno
Keno force-pushed the kf/wrapintops branch from 627ebcb to 05a22d6 Compare July 7, 2026 06:25
A machine audit of the sites `make_all_arithmetic_checked` would turn
into checked operations (encode each guarded call with an SMT solver,
enumerate every wrapping add/sub/mul/neg it reaches, and
satisfiability-check each site's overflow condition) found three
unconverted sites whose overflow is reachable on VALID inputs, i.e.
calls that are correct today through wrap-cancellation but would throw
under the checked mode:

* `mod(x::T, y::T)` — `x - fld(x, y) * y`: the floored quotient times
  `y` lies in `(x - y, x + y]`, so both the product and the subtraction
  can wrap; `mod(typemin(Int), 3)` is the simplest instance (`mod1` and
  `rem(x, y, RoundDown)` reach the same formula):

      julia> Base.checked_sub(typemin(Int), Base.checked_mul(fld(typemin(Int), 3), 3))
      ERROR: OverflowError: -3074457345618258603 * 3 overflowed for type Int64

* `mod(x::BitSigned, y::Unsigned)` — `remval + (remval < 0)*y` re-wraps
  the negative remainder into the unsigned domain by design; witness
  `(-7928727881474113536, 0x6e2be66802e200c0)`.
* `divrem(a, b, RoundDown/RoundUp)` — `a - d*b` with the floored/ceiled
  quotient; witnesses `(-8070450533858541568, 2305843009750564864)` and
  `(9223371103813285121, 2198967713920)`.

Add `BitSigned`-specialized methods carrying `-%`/`*%` (and `+%` for
the mixed-sign addition), leaving the generic `Integer` methods on
plain operators: arbitrary-precision integers cannot overflow there and
custom `Integer` types have no wrapping-operator methods to dispatch
to. The `RoundToZero` branch stays on checkable operators because the
truncated quotient satisfies `|d*b| <= |a|` — that, and the analogous
products in `fld`, `cld`, `fld1` and all sixteen arithmetic sites of
`divrem(_, _, RoundNearest)`, were machine-proved unable to overflow on
valid inputs, so they are safe for the checked mode as-is.

`rem(x, y, RoundUp) = mod(x, -y)` is intentionally not converted: its
`-y` overflow at `y == typemin` is a genuine bug (the result disagrees
with `divrem` and the documented interval), fixed separately.

Behavior is unchanged; only the future checked mode is affected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread base/rational.jl Outdated
return xp
end
xd, yd = divgcd(promote(x.den, y.den)...)
Rational(+%(+*(x.num,yd), +*(y.num,xd)), +*(x.den,yd))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Rational(+%(+*(x.num,yd), +*(y.num,xd)), +*(x.den,yd))
Rational(+%(+%(x.num,yd), +%(y.num,xd)), +%(x.den,yd))

@adienes

adienes commented Jul 7, 2026

Copy link
Copy Markdown
Member

wasn't sure if the easiest format was to leave a bunch of inline comments or just one here, I chose the latter, with claude assistance for the bulleted list details

IMO the timing & counting functions should not use wrapping arithmetic, because I disagree with

a bad print was better than an error

(although maybe that's just my bias from my professional domain)

which would mean the changes to

  • Timing subtractions — asyncevent.jl, loading.jl, precompilation.jl, timing.jl (@elapsed/@timed), task.jl record_running_time!, experimental.jl task-timing, threadingconstructs.jl docstring.
  • Monotonic counters — dict.jl/set.jl h.age +%= 1, logging.jl h +%= 1 (id probe)

should remain normal arithmetic.

there are also several functions which appear to have been refactored to avoid wrapping operations in the first place, which seems like a reasonable / good thing, but might be worth extracting to their own PR:

  • Printf while b - '0' < 0x0a → explicit '0' <= b <= '9'.
  • io.jl / filesystem.jl read(::Char) 0x04 - leading_ones underflow trick → explicit 0x02 <= lo <= 0x04.
  • regex.jl UNSET-sentinel guard before +1.
  • abstractset.jl max_valuesadd_with_overflow + saturate to typemax (this site actively does not want wrapping).
  • number.jl flipsign/copysign ifelse(...,-x,+x)... ? -x : +x (so checked -x isn't eagerly evaluated on the untaken branch).

@Keno

Keno commented Jul 7, 2026

Copy link
Copy Markdown
Member Author
  • Timing subtractions — asyncevent.jl, loading.jl, precompilation.jl, timing.jl (@elapsed/@timed), task.jl record_running_time!, experimental.jl task-timing, threadingconstructs.jl docstring.

I think these could go either way. The result will still be correct on overflow for the first 500 years, so maybe that's better?

  • Monotonic counters — dict.jl/set.jl h.age +%= 1

I agree

logging.jl h +%= 1 (id probe)

No, these start at an arbitrary hash value - overflow is expected:

    h = UInt32(hash(string(modname, level, message, log_kws)::String) & 0xFFFFFFFF)

should remain normal arithmetic.

there are also several functions which appear to have been refactored to avoid wrapping operations in the first place, which seems like a reasonable / good thing, but might be worth extracting to their own PR:

Could be factored out, not sure it's necessary.

@Keno

Keno commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

I think these could go either way. The result will still be correct on overflow for the first 500 years, so maybe that's better?

GPT 5.5 argues that explicitly the only guarantee documented is that it's monotonic mod 2^64, with no guarantees on the starting point. I think that argument is convincing, so I will leave this as is. If we want something different, I think we need to strengthen the guarantees of this function first (maybe it should return UInt128?).

@adienes

adienes commented Jul 7, 2026

Copy link
Copy Markdown
Member

fair enough, I trust the combined judgement of keno + machine 😂 just providing counterpoint

@Keno

Keno commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

An extra set of eyes is always appreciated!

Keno and others added 2 commits July 7, 2026 18:21
Fix rational wrapping addition to use wrapping multiplication for cross-products, restore ordinary arithmetic for Dict mutation age counters, and correct a typo caught by the typos workflow.

CI failure: https://github.com/JuliaLang/julia/actions/runs/28849713753/job/85561793991

Co-authored-by: OpenAI <noreply@openai.com>
@Keno
Keno merged commit b6e5cb5 into master Jul 10, 2026
9 checks passed
@Keno
Keno deleted the kf/wrapintops branch July 10, 2026 01:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maths Mathematical functions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants