You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Browse filesBrowse the repository at this point in the historyBrowse files
Ralph Küpper
committed
fix(codegen): a declared numeric type is not a proof that the value is a number
Perry does not enforce annotations at runtime (CLAUDE.md, Known
Limitations), but codegen answered `is_numeric_expr` = true on the
strength of one and then emitted bare f64 arithmetic on whatever the slot
actually held.
That is worse than a NaN, because arithmetic on a NaN-BOXED value is not a
no-op that yields NaN: `fadd`/`fmul` propagate the input NaN's payload, so
a NaN-boxed string comes back out of the instruction STILL TAGGED AS THAT
STRING. `typeof (v * 2)` answered "string", and `v + 1` looked as though
the `+ 1` had evaporated.
Three divergences from Node, all silent:
#7773 shape 1 `o.x + 1` gave NaN (the number-context read's cold arm
coerces unconditionally). Node concatenates: `s1`.
#7773 shape 2 through a refined local (`const v = o.x`) there was no
coerce at all, so the string passed straight through.
#7776 a heterogeneous element stored via `as any`, then summed.
New predicate `numeric_proof_is_declared_only` separates "an annotation
said so" from a real proof. It is deliberately narrower than
`expr_may_return_boxed_value_from_raw_f64_fallback`, which answers "is
there a raw-f64 tier worth trying" and stays true for reads that end up
with no boxed fallback: every arm carrying a guard, a closed store
universe or scalar replacement answers false, so element-shape and
class-field loop facts, `Ptr<Shape>` numeric fields, POD records, scalar
replacement and typed arrays all keep their bare loads.
Two consumers:
* `+` with a declared-only operand lowers through
`lower_declared_only_numeric_add`: an inline NaN-box tag test, `fadd` on
the fast arm, `js_dynamic_string_or_number_add` on the cold one. The
spec's `+` dispatches on the runtime value, so this is the operator that
needs the dispatch rather than a coerce.
* every other arithmetic operator is a plain ToNumber, so the existing
residual `js_number_coerce` rule is enough — it just could not see a
refined LOCAL before.
`expr/mod.rs::lower_numeric_binary_value` is a second arithmetic tier that
bypasses `binary::lower` entirely and emits bare `fadd`/`fmul` with no
residual coerce at all; it is the path both refined-local shapes took, and
it now hands declared-only operands down to `binary::lower` the same way
its two existing Mod cases do.
Two things the first attempt got wrong, both now pinned by the test:
* ONE diamond per `+` TREE, not one per node. Per-node diamonds make the
outer add of `s += o.x + 1` consume a phi, and LLVM cannot prove a phi
over (`fadd`, runtime call) is a canonical double — the outer test never
folded and the hot loop lost its `fadd` to an unconditional call. Fusing
took that shape from +38% to +8.6%. Both arms rebuild the ORIGINAL tree
shape, because `+` is not associative across strings: `1 + (2 + "x")` is
`"12x"` and `(1 + 2) + "x"` is `"3x"`.
* every leaf is tested except those `expr_produces_canonical_raw_f64`
vouches for. Testing only the declared-only leaves skips the
ACCUMULATOR, and `let s = 0; s += r.x + r.y` holds a string the moment
this lowering's own cold arm concatenates — that summed
`16zw1113151719` down to `16zw`, the original bug one level up.
Measured on the quiet M1 mini (load 1.68, 7 alternating runs, same runtime
for both arms so only codegen differs):
element-shape clone 218 -> 217 ms -0.5% (untouched, as intended)
this.v + 1 in method 70 -> 76 ms +8.6%
s += p.x + p.y 196 -> 263 ms +34.2%
The cost falls only on reads the compiler could prove nothing about, which
already pay an inline header precheck or a
`js_typed_feedback_class_field_get_guard` call for their shape check. It
is a real cost and the alternative is silently wrong arithmetic.
test-files/test_gap_declared_numeric_field_holds_string_7773.ts covers both
reported shapes plus array elements, inherited fields, chained adds and the
accumulator, and asserts the other direction for VALUE — honest arithmetic,
an honest guard failure and a typed array must all still answer as numbers.
0 commit comments