Dart AOT Gets Math Wrong: Debug Outputs 0.125, Release Outputs 2299087609772638200
Recently there's a dart-lang/sdk#64878 bug, so abstract. It's as abstract as the problem I recently encountered: the same legitimate Dart numerical computation code runs fine in Debug, but after AOT compilation it may directly crash, or not crash but directly produce a wrong conclusion.
For example, take this:
import 'dart:convert';
import 'dart:math';
num calculate(Map values) {
final result = min(0.965, values['value']);
return 0.035 + result;
}
void main() {
print(calculate(jsonDecode('{"value":0}')));
}
Normally:
min(0.965, 0)should return0.0.035 + 0should get0.035.
But after AOT compilation, the program directly gets SIGSEGV — you read that right, it just crashes.
And the second example is even more absurd:
num calculate(Map values) {
final result = max(0, values['value']);
return result * 0.5;
}
void main() {
print(calculate(jsonDecode('{"value":0.25}')));
}
The same code gives two execution results:
- JIT: normal
0.125, as expected
- AOT: actually
2299087609772638200.0
So why does the first scenario with min() crash? The problem mainly lies in how values are represented.
In Dart SDK, the function definitions of these two implementations are:
external T min<T extends num>(T a, T b);
external T max<T extends num>(T a, T b);
Then there's an easily overlooked point: T extends num means not all parameters are automatically converted to double; a num can be either int or double.
And in the Dart VM's min implementation, the relevant implementation is roughly:
T min<T extends num>(T a, T b) {
if (a > b) return b;
if (a < b) return a;
// Special handling for NaN, positive/negative zero, etc.
...
}
For example, min<num>(0.965, 0); compares and directly returns the second parameter 0, which might be an int. This behavior is fine, but the problem happens when the AOT compiler processes the subsequent calculation:
The most critical part here is the compiler IR:
v74 <- phi(
v18 T{<:X1?},
v4 T{_Double},
...
) alive T{<:*}
v102 <- Unbox(v74 T{_Double}) double
v9 <- BinaryDoubleOp(+, v102, v104) double
The rough meaning:
phiis the branch merge node in SSA; the different execution paths before it ultimately hand their computation results to it- But this result could be
intordouble, so you can't simply assume it's definitely_Double - Later at
Unbox, it directly extracts a value according to_Doubleto prepare for floating-point addition
So the problem roughly lies between step 2 and step 3: the code generated by the compiler uses a type assumption that isn't guaranteed. Then at ARM64 assembly fldrd v2, [r0, #7], the Dart VM encodes small integers (Smi) in tagged machine words; the representation for integer zero can be 0.
But the generated assembly treats this value as a reference to a Double object and tries to read the floating-point number inside the object:
r0 = 0
read position = r0 + 7
= 0x7
At this point it accesses illegal address 0x7, directly triggering SIGSEGV. In essence:
This is not a mathematical calculation producing an illegal value; the compiler treated the representation of an integer as a reference to a floating-point object, ultimately generating a wrong memory-read instruction.
Then, fortunately, I also encountered a similar problem, but not with min. It was on Flutter 3.44.9, but the symptom was similar: the compiler's "register parameter passing" (user_register_cc). When passing parameters, Dart under AOT directly treated a default value of 1.0 as an address to read. Since 1.0 (whose high bits happen to be 0x3ff00000) isn't an address at all, it crashed.
So this problem is really abstract. What's even more abstract is that Dart has a dedicated compiler optimization for min/max.
For example, in call_specializer.cc, the Dart compiler has InlineMathMinMax that specifically handles min/max. It checks the types of the two arguments:
- If both are
double, it generates a dedicated floating-pointMathMinMaxInstr - If both are
int, it generates an integer version on supported 64-bit targets - If these conditions aren't satisfied, it can't directly use this specialized optimization path
But the ARM64 backend generates different instructions for integers and floats separately, so developers have actually considered optimization for different numeric types. But the problem lies here:
min(0.965, values['value'])
Here values is a Map without a specified generic type parameter, and the static type of values['value'] is dynamic. And jsonDecode() returns an integer for 0 in JSON, and a float for 0.25.
So at runtime there are different value types, but at compile time you can't rely on this expression to guarantee which one it is. Dart's generic arguments are also inferred from the call context and parameters, and explicitly writing <num> changes this inference constraint.
But the problem is, it's still not clear exactly where it leads to this issue. What's certain is that during AOT compilation, if the compiler thinks a value can be treated as a Double, it generates more direct machine code. But at runtime, when it enters the branch returning an int, at the machine-code level, some boundary case fails to handle this correctly.
So 0.125 gets computed as 2299087609772638200.0.
It can only be said: truly abstract.
Top 3 from juejin.cn, machine-translated. The original thread is authoritative.
The only remaining master on the web who hand-rolls technical posts.
Dart's num design feels ugly.
It seems like this kind of thing—debug fine, release broken—has happened several times already.