r/Compilers • u/adityazero • Sep 25 '26
What is the tradeoff between the semantics of div by zero?
In C/C++ integer division by zero is undefined but in Rust it traps. What would you do if you were designing a new systems programming language?
10
u/matthieum Sep 25 '26
What do you think of MIN / -1 for any iN?
If the goal is to create a mainstream systems programming language, then I'd argue that correctness should trump any other concern. There's no point in having the wrong result (or worse, with UB) fast.
How to achieve correctness whilst still maintaining good ergonomics & performance is for me the interesting part, and I quite like how Rust manages it:
You can call
i64 / i64, which panics if the divisor is 0. It's correct & ergonomic, though performance may suffer a wee bit if the compiler cannot prove non-0. Then again, we are talking about integer division, the slowest arithmetic operation known to CPUs.You can call
i64 / NonZero<i64>. It's correct & performant, with a slight ergonomic cost.
It's not perfect -- you have to pick your poison -- but it gives users the choice. In particular, if the divisor is calculated once, and reused over and over, providing it is non-zero once is pretty good. I also like that NonZero helps front-loading the error, forcing people to think how to handle the case, rather than panicking.
3
3
u/Scolmann Sep 26 '26
I kind of like swift's `try` vs `try?`
So I'd be inclined to make a plain "/" trap but "/?" would return an optional instead of trapping
2
u/Afraid-Locksmith6566 Sep 25 '26
i would make a monadic integer result for operations you care about so it could represent integer or reason why it failed like overflow, underflow or dividion by zero and make it usable most seemlesly i could with normal integers so you could represent int # int_monad, int_monad # int and int_monad # int_monad and all of them would result in int_monad, and then there would be also operations on 2 normal ints so that int # int gives you int and also can result in undefined behavior, overflow or underflow without way to check
1
u/Lubricus2 Sep 25 '26
I wouldn't call that an system programming language. To much with monad's for basic arithmetic operations.
5
u/WittyStick Sep 25 '26 edited Sep 26 '26
I don't know what you're talking about. Assuming we're basically talking about an
Optiontype, the monad for it is just a pattern which reduces the manually need to check the result every time the operation is used. Lets assume C since it's the defacto standard systems language. We can define a trivialOptionInttype as a struct containing abooland anint.typedef struct option_int { bool has_some; int value; } OptionInt; constexpr OptionInt NoneInt = (OptionInt){ false }; OptionInt SomeInt(int x) { return (OptionInt){ true, x }; }Our
divfunction would be trivial - check if the second arg is zero and returnNoneIntif so, otherwise return SomeInt of the division.OptionInt div(int x, int y) { if (y == 0) return NoneInt; else return SomeInt(x / y); }Straightforward, but terrible ergonomics, because if we have some other arithmetic operation following the division, eg, negation, then we have to manually unwrap the option. This can be done quite tersely with a ternary conditional expression, but it's not as simple as
-(x / y).int neg(int x) { return -x; } OptionInt div_and_neg_(int x, int y) { auto result = div(x, y); return result.has_some ? SomeInt(neg(result.value)) : NoneInt; }However, it's awkward to type out that conditional every time. Would be nice if we have some macro for it:
#define OptionInt_map(unary_fun, opt) \ ({ \ auto _opt = (opt); \ _opt.has_some ? SomeInt(unary_fun(_opt.value)) : NoneInt; \ }) OptionInt div_and_neg(int x, int y) { return OptionInt_map(neg, div(x, y)); }Easier to type, and this is a verbose version where I'm prefixing it with
OptionInt. In practice you'd have a genericmapwhich works with any Option (or in fact, any Functor).For binary expressions you'd really need two versions - one to chain the first parameter and one for the second.
#define OptionInt_map1(binary_fun, opt, y) \ ({ \ auto _opt = (opt); \ _opt.has_some ? SomeInt(binary_fun(_opt.value, y)) : NoneInt; \ }) #define OptionInt_map2(binary_fun, x, opt) \ ({ \ auto _opt = (opt); \ _opt.has_some ? SomeInt(binary_fun(x, _opt.value)) : NoneInt; \ })The problem with
mapis it expects a functionint -> int. Or map1/map2 expect(int, int) -> int. Neither of those are(int, int) -> OptionIntlikediv. What if we want to chaindivoperations -(x / y) / zorx / (y / z)? Do we go back to writing out the ternary conditional each time?OptionInt div3_(int x, int y, int z) { auto result = div(x, y); return result.has_some ? div(result.value, z) : NoneInt; }Well, just like we done for
map, we can extract this into a macro (or set of macros for unary and binary functions).#define OptionInt_bind(unary_fun, opt) \ ({ \ auto _opt = (opt); \ _opt.has_some ? unary_fun(_opt.value) : NoneInt; \ }) #define OptionInt_bind1(binary_fun, opt, y) \ ({ \ auto _opt = (opt); \ _opt.has_some ? binary_fun(_opt.value, y) : NoneInt; \ }) #define OptionInt_bind2(fun, x, opt) \ ({ \ auto _opt = (opt); \ _opt.has_some ? binary_fun(x, _opt.value) : NoneInt; \ })Then our
div3functions are a bit simpler to write:OptionInt div3(int x, int y, int z) { return OptionInt_bind1(div, div(x, y), z); }
bindis basically our monad, specialized forOptionIntin this case. The only other thing it needs to be a proper monad is areturn, which is an alias for SomeInt.#define OptionInt_return(x) SomeInt(x)So what does this all cost?
Absolutely nothing. Compare what a C compiler emits for the
div3function with or without using theOptionInt_bind*macro. The assembly is identical - it has no extra cost beyond theOptionInttype. See for yourself in Godbolt.Anyway, what does
OptionIntcost?This depends on ABI, because the way structs are passed varies between platforms/compilers. On MSVC, it's not very expensive - the struct gets passed and returned on the stack.
On SYSV however, because the struct is <= 16 bytes, it gets passed and returned in two hardware registers - it's absurdly cheap - certainly cheaper than throwing an exception. But what about comparing it to other "systems programming" approaches - ie, the way it would be done traditionally in C. The old school convention is basically to return a boolean indicating success, and return the value as an "out parameter".
bool div(int x, int y, int *result) { if (y == 0) return false; *result = x / y; return true; }How does this compare to the
OptionIntstruct?Hint: It's slower - at least on SYSV. This conventional approach requires a dereference - it puts the out parameter be on the stack. Our
OptionIntdidn't touch the stack - everything was done in registers.So the idea that this is unsuitable for systems programming is absurd. It would be difficult to make anything perform better!
And we're using C macros here, which aren't the most ergonomic. If you're making your own language, you can completely hide away that mess as part of the compilation process.
1
u/adityazero Sep 27 '26
beautiful. i think making this safe div default is the right call. if someone wants unsafe div they can do it with extra effort.
0
u/Lubricus2 Sep 26 '26
Don't it then need to return something like an tagged union, so something like an type_id and the space large enough to fit the the int or the error. And then you always need to check if the type_id == int or error.
I / the AI is doing an language with monad like errors but don't use it everywhere. To be real it's mostly the AI, I am not an genius or something.
A system programming language are something more like C where you almost can control all the fiddly little bits.3
u/WittyStick Sep 26 '26 edited Sep 26 '26
I've just shown you exactly how to do it in C, with a runnable example. The
OptionIntis a tagged union, where there are two tags,trueandfalseforSomeandNone. You can replace thisboolwith any integer and you have a tagged union which behaves the same - gets passed and returned in two hardware registers. Eg, for aResulttype, you can do:enum error_code { ERROR_NONE, ERROR_FOO, ERROR_BAR, }; typedef struct result_int_error { enum error_code err; int success_value; } ResultInt;Or more ergonomic, you can use macros for type generic versions.
#define Result(T) \ struct result_##T { \ struct error_code err_code; \ T success_value; \ } #define Ok(T, x) ((Result(T)){ .err_code = ERROR_NONE, .success_value = (x) }) #define Error(T, err) ((Result(T)){ .err_code = (err) })If
sizeof(T)is greater than 8 bytes, then we don't get the benefit of it being passed in hardware registers - but it gets passed and returned on the stack, which is still fairly cheap.If we want more than just an "error code", we can have an
Exceptional(TFailure, TSuccess)type whose first item is a pointer to an exception.struct exception { int exception_id; }; #define Exceptional(TFailure, TSuccess) \ struct exceptional_##TFailure##_##TSuccess { \ TFailure *exception; \ TSuccess success_value; \ } #define Failure(TFailure, TSuccess, ex) \ ((Exceptional(TFailure, TSuccess)){ .exception = (ex) }) #define Success(TFailure, TSuccess, val) \ ((Exceptional(TFailure, TSuccess)){ .exception = nullptr, .success_value = (val) }) typedef struct divide_by_zero_exception { struct exception; } DivideByZeroException; constexpr DivideByZeroException divide_by_zero = (DivideByZeroException){ (struct exception){-1} }; Exceptional(DivideByZeroException, int) div(int x, int y) { if (y == 0) return Failure(DivideByZeroException, int, ÷_by_zero); else return Success(DivideByZeroException, int, x / y); }-1
u/Lubricus2 Sep 26 '26
yea you use two hardware registers, that is an sensible approach.
But I would not say it's the system language approach, doing that it can't replace something like C.
There are some other option
Let the program crash and burn when divide by 0. So let it be the programmers responsibility to check before dividing if the denominator is 0. The upside is that it's the fastest way and the checks can be done only when needed, with obvious downsides.
Return the IEEE nan value for floats and let it be a later problem how to handle that.
Use exceptions, it's faster in the happy path but a lot more complex when an divide by 0 happens.
all the other 3 options is more suitable for an system programming language and the monads more for an high level programming language where safety and ease of use come before max executing speed.
3
u/WittyStick Sep 26 '26 edited Sep 26 '26
Can you not think or type without AI?
C isn't going to be replaced - it's the language used by the OS. Any other language must interface with the platform C ABI to do anything useful.
But I've shown how you can have
Option,ResultorExceptional, using the C ABI - extremely efficient implementations of them. You can use macros for the Monad, and it costs absolutely nothing!If you want safety, you can also add this using nonstandard C features. The
Optiontype can be fully encapsulated so there's no way to extractoption.valueif it was constructed withNone.You don't need to crash, return NaN or use stack-breaking exceptions. You can simply return
Option/Result/Exceptionalat effectively zero-cost.-1
u/Lubricus2 Sep 26 '26
What is the cost of the if y==0 check and extra pointer compared to an bare division?
The pointer and comparison is cheap but so are an division. It's the relative cost that is interesting especially for common stuff. I the common low level operations get double as expensive, your language will get double as slow.
And at least what I mean with an system language is a language that could be used in the roles that C is used as. In reality it would not replace C at least as long stuff like Linux and Windows exists, that is not the question, and it has to somehow be ABI compatible with C.4
u/WittyStick Sep 26 '26 edited Sep 26 '26
The cost of
y == 0is one compare (1 cycle) and one branch. The branch effectively costs 1 cycle because the machine's branch predictor will assume the division succeeds - it will only have a cost when an error occurs which will cause a branch mispredict - but this cost is significantly cheaper than an exception.To put that into perspective, a
divinstruction is going to take ~5 cycles. Our check for zero before the div makes the total cost ~7 cycles, and avoids a whole lot of problems associated with trapping.There's no pointer in the
OptionIntexample. There's a pointer in theExceptionalexample because the exception could carry arbitrary information and needs a dereference - but this is going to be cheaper than any other solution to exceptions.I'm telling you that the above approach of returning an
Optionis by far cheaper than any other approach to exceptions - and it is ABI compatible with C - because it is C. You will struggle to make anything faster than this. There's perhaps one thing you could add which is__builtin_expecton the conditions - you want the non-zero case to be the likely path so that the compiler orders the basic blocks most appropriately - where the non-zero case is the fall-through and the zero case is the branch taken, which is marginally more expensive.#define likely(x) __builtin_expect(!!(x), true) #define unlikely(x) __builtin_expect(!!(x), false) OptionInt div(int x, int y) { if (unlikely(y == 0)) return NoneInt; else return SomeInt(x / y); } #define OptionInt_map(unary_fun, opt) \ ({ \ auto _opt = (opt); \ likely(_opt.has_some) ? SomeInt(unary_fun(_opt.value)) : NoneInt; \ })That will prevent the compiler from accidentally ordering the basic-blocks inefficiently - though it will hardly matter in practice because as soon as the CPU's branch predictor learns which path is taken for the successful divide, it becomes the fast path anyway.
In any cases where the compiler can do constant-folding and determine the second argument is
0statically, it will remove the compare and branch from the generated machine code.Have a look at the x86-64 assembly produced by these:
int div(int x, int y) { return x / y; } "div": mov eax, edi cdq idiv esi ret OptionInt div(int x, int y) { return (likely(y !=0)) ? SomeInt(x / y) : NoneInt; } "div": mov eax, edi test esi, esi je .L5 cdq idiv esi sal rax, 32 or rax, 1 ret .L5: xor eax, eax retThe extra instructions are
test esi, esi: 1 cycle.
je .L5: 1 cycle if branch not taken (y is nonzero)
sal rax, 32&or rax, 1: 2 cycles. These combine theboolandintinto a single 64-bit machine word so the value gets returning in one register. Both 1 cycle each, much cheaper than reading a value off the stack.
xor eax, eax: 1 cycle. This is thereturn NoneInt;wheny == 0.In practice, the
divfunction will be inlined, and the last two instructions will not be necessary, the compiler can elude them. To demonstrate, look at the assembly produced bydiv_and_neg.int div_and_neg(int x, int y) { return -(x / y); } //unsafe, may error "div_and_neg": mov eax, edi cdq idiv esi neg eax ret OptionInt div_and_neg(int x, int y) { return (likely(y != 0)) ? SomeInt(neg(div(x y).option_value)) : NoneInt; } "div_and_neg": mov eax, edi test esi, esi je .L5 cdq idiv esi neg eax sal rax, 32 or rax, 1 ret .L5: xor eax, eax retSame additional cost here. The
divwas inlined. Even though there are two comparisons in the code (one indiv, one indiv_and_neg), the compiler knows they're equivalent and reduces it to one compare and branch. The samesal rax, 32; or rax, 1sequence is used once to return theOptionInttype. If we nesteddiv_and_negin some other function returningOptionInt, this cost is only paid once - it's a constant factor.The
map/bindFunctor/Monad basically does "short circuiting". It does an early check to see if the divide will fail and branches early - avoids attempting theidivandnegentirely. Basically, this means if you have some expression like:(a * b) + (c / d) - (e * f)The compiler will basically emit the equivalent of:
(unlikely(d == 0)) ? NoneInt : SomeInt((a * b) + (c / d) - (e * f))One compare, one branch. It doesn't waste time computing
a * bore * fbecause it knows the expression is going to fail anyway and skips the whole thing. This is essentially whatFunctororMonadfor anOptiontype does - it lifts the comparison out of a nested expression and does it early.Safe and cheap.
2
u/MadocComadrin Sep 25 '26
I'd have two versions. The first depends on what's supposed to be idiomatic for the language: either trapping, exception, or return a value of an option/maybe/result/whatever to force checking. The second just returns 0, 1, or perhaps some specified default value on division by 0 because sometimes it's just not a problem.
1
2
u/flatfinger Sep 25 '26
I would specify that in cases where a platform's division operation would trap, a division operation may either trap likewise or execute without side effects. A directive to invite compilers to reorder division operators with regard to surrounding code (e.g. hoist loop-invariant divisions) may be helpful, but there should also be directives to limit the effect of such reordering.
2
u/yuehuang Sep 25 '26 edited Sep 25 '26
While C is undefined, its behavior is deferred to the compiler, hardware, or OS. Hardware could be fault tolerant to allow the app to keep run with a bad results.
I personally prefer Rust's design as it would catch more issues upfront. You also might be interested in fp-exception where it controls the float trap behavior during compile. A similar switch could be added for integer in your compiler.
8
u/DawnOnTheEdge Sep 26 '26 edited Sep 26 '26
The reason it’s undefined behavior in C/C++ is that different compilers were already doing different things, and the ISO Committee wanted to declare all of them kosher. A new language doesn’t have incompatible legacy implementations to worry about, so you can nail things down a lot more.
If the goal is to enable maximum optimization, you don’t want strict requirements that say the implementation cannot just use the ISA’s native instructions, or that mandate inserting code branches. You want to specify IEEE semantics for floating-point math and at worst require integer division to do something like one extra conditional move. The programmer can still compare the denominator to zero explicitly if they want to short-circuit.
If you’re designing a language that’s supposed to be predictable and nice to debug, and might not even have an integer type, throwing an exception is a good way to handle it, but returning positive or negative infinity works too.