|
| 1 | +### Fixed |
| 2 | + |
| 3 | +- **A declared numeric type is no longer treated as proof that the value is a |
| 4 | + number** (#7773, #7776). Perry does not enforce annotations at runtime, but |
| 5 | + codegen answered `is_numeric_expr` = `true` on the strength of one and then |
| 6 | + emitted bare f64 arithmetic on whatever the slot actually held. |
| 7 | + |
| 8 | + That is worse than producing a `NaN`, because arithmetic on a NaN-BOXED value |
| 9 | + is not a no-op: `fadd`/`fmul` propagate the input NaN's payload, so a |
| 10 | + NaN-boxed string comes back out of the instruction still tagged as that |
| 11 | + string and flows on as if nothing happened — `typeof (v * 2)` answered |
| 12 | + `"string"`. Four divergences from Node, all silent: `o.x + 1` gave `NaN` |
| 13 | + where Node concatenates; `const v = o.x; v + 1` looked as though the `+ 1` |
| 14 | + had evaporated; `v * 2` returned the string; and summing a `P[]` with one |
| 15 | + `as any`-stored `Q` element gave `NaN`. |
| 16 | + |
| 17 | + A new `numeric_proof_is_declared_only` separates "an annotation said so" from |
| 18 | + a real proof. It is deliberately narrower than |
| 19 | + `expr_may_return_boxed_value_from_raw_f64_fallback` (which answers "is there |
| 20 | + a raw-f64 tier worth trying" and stays true for reads with no boxed fallback |
| 21 | + at all): element-shape and class-field loop facts, `Ptr<Shape>` numeric |
| 22 | + fields, scalar replacement, POD records and typed arrays all answer `false` |
| 23 | + and keep their bare loads. `+` then lowers through an inline NaN-box tag test |
| 24 | + — `fadd` on the fast arm, `js_dynamic_string_or_number_add` on the cold one — |
| 25 | + because the spec's `+` dispatches on the runtime value; every other |
| 26 | + arithmetic operator is a plain `ToNumber` and only needed the existing |
| 27 | + residual-coerce rule taught to see a refined LOCAL. |
| 28 | + |
| 29 | + `expr/mod.rs::lower_numeric_binary_value` turned out to be a second |
| 30 | + arithmetic tier that bypasses `binary::lower` entirely and emits bare |
| 31 | + `fadd`/`fmul` with no residual coerce at all; it was the path both |
| 32 | + refined-local shapes took, and it now hands declared-only operands down the |
| 33 | + same way its two existing `Mod` cases do. |
| 34 | + |
| 35 | + Two details are load-bearing and are pinned by the test. The diamond covers |
| 36 | + the whole `+` **tree**, not one node each: per-node diamonds make the outer |
| 37 | + add of `s += o.x + 1` consume a phi that LLVM cannot prove is a canonical |
| 38 | + double, which killed the `fadd` in the loop (+38% before fusing, +8.6% |
| 39 | + after). And every leaf is tested except those `expr_produces_canonical_raw_f64` |
| 40 | + vouches for — testing only the declared-only leaves skips the ACCUMULATOR, |
| 41 | + which holds a string the moment this lowering's own cold arm concatenates, |
| 42 | + and summed `16zw1113151719` down to `16zw`. |
| 43 | + |
| 44 | + Measured on the quiet M1 mini, same runtime in both arms: element-shape clone |
| 45 | + 218 → 217 ms (−0.5%, untouched), `this.v + 1` in a method 70 → 76 ms (+8.6%), |
| 46 | + `s += p.x + p.y` with an escaped receiver 196 → 263 ms (+34.2%). The cost |
| 47 | + falls only on reads nothing could prove, which already pay an inline header |
| 48 | + precheck or a `js_typed_feedback_class_field_get_guard` call. |
0 commit comments