Merge the Lam IR into Lambda - #8608
Open
cristianoc wants to merge 46 commits into
Open
Conversation
`%identity`, `%component_identity`, `%ignore`, and unary `+` erase at
translation: they have no IR form. They were encoded as `Peliminated`, a
constructor present in both `Lambda.primitive` and `Lam_primitive.t` that
neither IR can hold - `Lambda.mk_prim` expanded it before it could reach an
`Lprim`, and every Lam consumer answered it with `assert false`.
Give the primitive table an element type that says what a `%name` means:
type builtin = Primitive of primitive | Eliminated of eliminated
`builtin` is a lookup type in `translcore`, not an IR type, so it needs no Lam
counterpart. Both primitive types lose `Peliminated`, along with its four
`assert false` sites, the unreachable `printlambda` case, and the `lam_convert`
pass-through for a case that could not occur.
`Unified_ops` had the same problem in its own table, where unary `+` was
`Peliminated Identity` in a field of operations. Its cells now carry a
lowering, `Lower of Lambda.primitive | Pass_through`, so `Ignore` is not
representable there and the module depends on `Lambda.primitive` only. The
`option` alone answers the type checker's question of whether an operand type
is supported.
`make_key`, `subst_lambda`, and matching's `make_prim` rebuilt an `Lprim`
through `mk_prim`, so a substitution nominally carried the power to change term
shape; they construct `Lprim` directly now.
Generated JavaScript is unchanged for the runtime, Belt, and tests/tests.
Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`%null` and `%undefined` were zero-argument primitives whose only job was to
become constants in conversion, so a value spelled `Nullable.null` was not a
constant at Lambda level. They are now `Constant` entries in the builtin table,
and `Pnull` / `Pundefined` are gone from `Lambda.primitive`.
`Const_js_null` and `Const_js_undefined` join `Lambda.structured_constant`.
Unit becomes `Const_js_undefined {is_unit = true}` rather than a constant
constructor named `"()"`, and a constructor with an optional shape produces the
undefined constant directly, so `Pt_shape_none` is gone. Conversion of all
four forms is now the identity.
Constant constructors, polymorphic variants and the module alias are built
through `Lambda.const_constructor` / `const_polyvar` / `const_module_alias`,
and `pointer_info` is `private` so nothing outside `lambda.ml` can build one.
That is what keeps the unit constructor from escaping as a pointer: the unit
check lives in `const_constructor`, and the type system now enforces that every
producer goes through it. Redefining `()` is already rejected for source and
PPX-generated declarations by `Bs_ast_invariant`.
The polymorphic-comparison warning check reads the constant forms instead of
the primitives, deliberately excluding `is_unit = true`: unit shares the
undefined constant but is not a `%null` / `%undefined` literal, and comparing
it never suppressed the warning. `tests/build_tests/super_errors` covers both
directions.
Generated JavaScript changes in two option-representation tests, as intended:
`Some(Nullable.null)` and friends are constants now, so they fold at compile
time and let calls with constant arguments inline. Runtime and Belt output is
unchanged.
Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
A polymorphic variant with a payload is `{NAME, VAL}` at runtime. The name was
stored twice: `translcore` put `Btype.hash_variant l` in the block and the name
in `Blk_poly_var`, and conversion then threw the hash away and rebuilt the name
from the descriptor. The hash is meaningless for this target - it is OCaml's
runtime discriminator, kept when the encoding changed to an object - and the
descriptor's copy was read by nothing except the printer and that rebuild.
`translcore` now emits the final name, decided once by
`Lambda.const_polyvar_name`: numeric-looking names are numbers at runtime,
everything else is a string. `Blk_poly_var` becomes a payload-free shape
marker like `Blk_tuple`, so both conversions lose their special case and the
`assert false` guarding a two-element shape they could not express. Every
code-generation site keeps reading the name where it always did, from the
block, so the JavaScript layer is unchanged apart from the constructor arity.
`Btype.hash_variant` is no longer emitted anywhere; its one remaining use is a
deterministic sort key in `matching`.
Generated JavaScript is unchanged for the runtime, Belt, and tests/tests.
Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
A polymorphic variant whose numeric name exceeds the int32 range crashed the
compiler with `Failure("Int32.of_string")` when it carried a payload or
appeared in a pattern:
let x = #99999999999("a")
switch x { | #99999999999 => 1 | _ => 2 }
The range check lived in the frontend AST pass and matched only
`Pexp_variant (s, None)`, so those two positions reached
`hash_number_as_i32_exn` unvalidated, with no location and no message.
Move it to `Typecore`, next to the integer literal decoding whose overflow
error it mirrors, and call it from both label positions. That follows what the
compiler already does for numeric literals: the parser keeps the text and never
judges ranges, and the hard error is raised where the value is decoded. It also
means the check covers a position once rather than once per syntactic form, and
lets `Lambda.const_polyvar_name` and the constant converter keep decoding with
the `_exn` function, now with a guarantee behind it.
A bare `type t = [#99999999999]` still compiles: nothing decodes a row field
name, so it cannot crash, and reporting it would belong in `Typetexp`.
Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Conversion of constants was a fan-out: one `Const_pointer of pointer_info` became five Lam constants, option blocks became `Const_some`, and the boolean names differed. All of it is decided at Lambda production now, so `Lam_constant.t` is a manifest re-export of `Lambda.structured_constant` and `lam_constant_convert.ml` is deleted - `Lconst` passes straight through. Three moves: Options. `Const_some` and the `Psome` / `Psome_not_nest` primitives move to Lambda, and `translcore` emits them instead of a block that conversion had to reinterpret. `Blk_some` / `Blk_some_not_nested` leave `tag_info`, which also retires a dead `Const_block (Blk_some, _)` case in `lam_pass_remove_alias` and an `assert false` in `js_exp_make`. Pointers. `Const_constructor`, `Const_polyvar`, `Const_assertfalse` and `Const_module_alias` become explicit constants, so `Const_pointer` and `pointer_info` are gone. The two decisions conversion used to make happen once, in `const_constructor` (unit first, then integer-tagged constructors as ordinary integers so folding sees their runtime representation) and in `const_polyvar` (numeric-looking names as integers). `const_polyvar_name`, the name field of a variant carrying a payload, is defined in terms of the latter, so the choice exists in exactly one place. Lam's `Const_pointer of string` is renamed to `Const_polyvar` to match. Booleans. Lambda's `Const_false` / `Const_true` take the Lam names. Generated JavaScript is unchanged for the runtime, Belt, and tests/tests; each of the three moves was verified output-neutral on its own. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
A call whose arity was unknown used to be emitted as `Primitive_curry._N(f, …)`, which discovered the real arity at runtime and applied, re-applied, or returned a closure collecting more arguments. Uncurried-by-default plus structural arity removed the need: every call site now knows how many arguments the callee takes. The state that drove it is unreachable. `ap_status` is only ever set to `App_uncurry` (conversion) or `App_infer_full` (alpha conversion), so `App_na` - the only source of `arity = NA` outside the applier it gated - is never constructed. With it gone, `Full` is the only value `Js_call_info.arity` can hold, so the field goes too, and with it `Curry_gen`, the curried print branch, and the `curry_id` import injection. `%curry_apply1`..`8` were declared only by `Primitive_curry.res` and used only by that module's own fast paths, so the primitive and its table entries go with the runtime module. `Pcurry_apply` already discarded its arity during conversion, making it `Pjs_apply` with a vestigial integer. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype, apart from the deleted module itself. `packages/artifacts.json` is regenerated. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Pfn_arity` emitted `f.length`. Its only declaration site in the tree was `Primitive_curry.res`, which used it to discover a callee's real arity at runtime; with the curry machinery gone nothing names `%function_arity` any more, and the arity a call site needs is now known statically. `E.function_length` had no other caller and goes with it. Note this is user-visible: an `external` declared as `"%function_arity"` was accepted before and is now rejected as an unknown builtin primitive. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The pass renames nothing - there is no alpha conversion anywhere in it. What it does is normalize applications against the callee's arity: saturate an under-supplied call by eta expansion, split an over-supplied one, and record on the rest that the arity is known. The name is OCaml lineage, where a pass in this position did rename bound variables. `alpha_conversion` becomes `normalize`, and the `-debug-ir` dump labels follow. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`ap_info` carried `ap_status`, a cache of "is the callee's arity known and does this call saturate it". Conversion stamped `App_uncurry` on every application and `Lam_pass_apply_arity` later rewrote the saturated ones to `App_infer_full`, so the field was optimizer state living in the IR, populated by one pass and read by one consumer: the emitter, deciding whether a call may be printed as `Call_ml` and so allow the printer to eta reduce a wrapper around it. The fact belongs to the callee, not to each call site. `Lam_compile_context.t` already carries `meta`, so the emitter asks `Lam_arity_analysis.get_arity` directly, and the field, the `apply_status` type and both stamping sites go. Conversion no longer attaches optimizer state to the applications it builds. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype: the query returns what the cache held, including across the `simplify_lets` that runs between the old stamping point and emission. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
With the arity fact queried where it is used, the pass had nothing left to do but re-group applications that do not match the callee's arity: eta expand an under-supplied call, split an over-supplied one. Neither happens. Instrumenting the four branches over the runtime, Belt and tests/tests - 14346 applications - gives 12510 exact, 1836 arity unknown, and zero under- or over-supplied. Deliberate attempts to provoke them (partial application `f(1, ...)`, an over-applied `mk()(1, 2)`, a partially applied argument passed to a higher order function, a partially applied external) all stay exact: uncurried ReScript saturates applications at Lambda production, and `translcore` already eta expands partial application before conversion. `Lam_eta_conversion` goes with it - `transform_under_supply` was its only export and this pass its only caller. Note the branches are removed rather than proven unreachable: `get_arity` is conservative, so a mismatched application could in principle still be built by inlining. Emitting such a call as written is correct for this target, where a JavaScript call with a mismatched argument count is well defined. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Lam_pass_collect.collect_info` writes identifier information into `meta.ident_tbl`, which `simplify_alias`, `get_arity`, `Lam_stats_export` and `Lam_coercion` later look up. Two of its five calls sit immediately before a `simplify_exits` that is immediately followed by another `collect_info`. `Lam_pass_exits.simplify_exits : Lam.t -> Lam.t` takes no `meta`, so it cannot consult the table, and nothing else runs in between. Neither call can therefore be observed before the following one rewrites what it wrote from the same term. The only entries they contributed that the following call does not are those for identifiers `simplify_exits` had just deleted - unreachable from the term, and never removed, since nothing ever removes from this table. Three traversals of the term instead of five, and fewer stale rows in a table that is only ever added to. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Hash_ident.add` conses onto the bucket, so an existing binding stays underneath: `find_opt` reads the newest and sees the update, but the old row is never reclaimed, since nothing ever removes from `meta.ident_tbl`. Both sites are updates, not new bindings. `annotate` re-records a function on every collect round with a refined arity and a fresh lambda, and `alias_ident_or_global` re-records an alias's kind. Their keys already exist by the second round, so each round leaked a row per function and per alias. `replace` is observationally identical - every consumer of the table is a `find_opt` lookup, and `find_all` is used nowhere in `compiler/core`, so the shadowing was never read. Duplicate rows for one function in a probe drop from 7 to 3; the 3 remaining are the parameter bindings, which keep using `add` because their keys are freshly stamped and genuinely new. The stale commented-out alternative above `annotate` is replaced by a note saying why it is an update. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Two rewrites conversion was doing to primitives. `Pisout` tested a range against an argument that `matching` had first shifted with `Poffsetint`, and conversion folded the shift back into `Pisout of int` - the form Lam has always had. The offset is known where the test is built: the `Switch` functor calls `make_isout` with the bound it is shifting by, so it can pass it instead. That also collapses the `l = 0` special case, since an offset of zero is no offset, and leaves `make_offset` with no callers, so it goes from the `Switch` argument signature too. `Pisnullable` and `Pnullable_to_opt` are renamed to their Lam spellings, `Pis_null_undefined` and `Pnull_undefined_to_opt`. All three conversion cases are now the identity. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Conversion rewrote `Pinit_mod` and `Pupdate_mod` to unit when the module's
shape had no fields. For `Pupdate_mod` that discarded the primitive's
arguments, and one of them is the module's right hand side, so
module rec M: {} = { let () = log("side effect") }
emitted nothing at all for M.
The elision itself is right - a shape with no fields has nothing to initialize
and nothing to patch - so make it at the producer, where the right hand side is
still in hand: `eval_rec_bindings_aux` binds unit instead of calling
`Pinit_mod`, and sequences the right hand side itself instead of calling
`Pupdate_mod`. Conversion of both primitives is now the identity.
Output for such a module gains its effects and keeps the elision: no
`Primitive_module` call is emitted.
The test is snapshot based, as the suite is - `rec_module_test` now has an
empty-signature module beside one with a field, and both `record` calls have to
appear in the checked-in output. Verified to fail before the fix.
Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Lam_primitive.Pmakeblock` carried a `mutable_flag` beside its tag info, but conversion was the only place in the Lam layer that ever built one, and it set the flag to `Lambda.mutable_flag_of_tag_info info` - the shape decides it. Drop the field and derive it where it is read, through `Lam_primitive.is_immutable_block`. Conversion of `Pmakeblock` is now the identity. The cost is at the nine sites that discriminated on the flag: they become guards rather than patterns, and one or-pattern in `lam_pass_deep_flatten` has to split, since a `when` cannot appear inside one. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
BUG FIX - needs a changelog entry when the branch gets one.
`%incr` / `%decr` lowered to `E.assign v (E.offset v n)` where `v` is the
field access on the argument, so the argument expression was emitted twice:
Int.Ref.increment(mkRef())
=> mkRef().contents = mkRef().contents + 1 | 0; // called twice
The primitive was `Poffsetref`, surviving into both IRs. It only had to be a
primitive so far as the caller's optimizer needed a form it could recognise:
`Lam_pass_eliminate_ref` unboxes a local reference when every use is a field
read or a field write, and a call through a library function would defeat that,
since the reference would appear in argument position and have to be assumed to
escape. Primitives are expanded at the application site, in the caller's own
module, so expanding to the field write directly gives the analysis exactly
what it already reads - and lets the expansion bind the argument.
`Offset_ref` joins `Lambda.builtin`, and `mk_builtin` expands it to
`r := r.contents + delta`, binding the reference unless it is already a
variable:
let ref = mkRef(); ref.contents = ref.contents + 1 | 0;
`test_incr_ref` pins the expression case.
Local references still unbox, escaping ones still compile to the same field
write, and a first-class use still eta expands. One other test changes:
`gpr_1762_test` no longer inlines a function whose body is an increment,
because `Lam_analysis.size` counted `Poffsetref` as a single node while it
stood for a read-modify-write. The inlining decision is now taken on the code
that is actually there.
Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
With `%incr` and `%decr` expanded at the construction site, nothing builds a `Poffsetref` any more. Its lowering, its conversion case and the arm of `Lam_pass_eliminate_ref` that rewrote it all go; the `Psetfield` arm above that one already covers the expanded form, and the read arm already rewrites the field access on an eliminated reference. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
With `Poffsetref` gone, the arm of `Lam_pass_eliminate_ref` that rewrote it was the only thing producing a `Poffsetint` in Lam, and `%succint` / `%predint` were the only things producing one in Lambda. Both go, with their lowering, their conversion case, the `compile_assign` peephole that recognised them (the general path emits the same `x = x + 1 | 0`) and the `for … to finish - 1` alternative, which keeps its `Psubint` form. Note this is user-visible in the same narrow way `%function_arity` was: an `external` declared as `"%succint"` or `"%predint"` was accepted before and is now rejected as an unknown builtin. Their only declaration site in the tree was `test_per`, which pins the OCaml-compatible surface; the two cases are removed there rather than reimplemented, since they existed to exercise the primitives. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Lprim (Pgetglobal id, [], loc)` encoded a reference to another compilation unit as an application of a nullary operation. A module reference computes nothing: it is a name the module system resolves, closer to `Lvar` than to any primitive - which is how Lam has always represented it, as the `Lglobal_module` leaf that later passes match against to recognise cross module field access. Give Lambda the same leaf and delete `Pgetglobal`. Every traversal that had to learn the case treats it as one, beside `Lvar` or `Lconst`. `transl_normal_path` is the single place that resolves a path, so the choice between the three forms is made there: a predefined exception is its own name at runtime and becomes that string constant, a global becomes the module reference, anything else stays a variable. Conversion was deriving the first of those itself, and no longer has to. Conversion of the reference is now structural apart from recording the module in `may_depends`. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`convert` returned the set of modules this unit refers to alongside the translated term, accumulating it as a side effect while translating. The set is a property of the term as written - a reference the optimizer later deletes still has to be imported when the module it names is impure (#3852) - so it can be read off the Lambda term directly. `Lam_convert.required_modules` does that, in twelve lines, because `Lambda.iter` already visits every subterm. `convert` becomes `Lambda.lambda -> Lam.t`: a function of its input with nothing accumulated on the side, and every case of `convert_aux` a structural rebuild. It sits beside `convert` rather than in `Lambda` because `Lam_module_ident` lives in `compiler/core` and `Lambda` in `compiler/ml`, so the traversal has to be on the core side either way. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Two constructors stood between conversion and a mechanical rebuild. `Pjs_apply` had no producer left: it was reached only from `Pcurry_apply`, which went with the curried application machinery. Delete it. `Pis_null` and `Pis_undefined` existed only in Lam, produced by `Lam_pass_remove_alias` when it can prove which half of a nullable test is needed. Give them to Lambda as well. They are not built by translation, and the comment beside them says so - unlike the constructors removed earlier in this series, they are built by a pass over the term rather than by nothing at all, which is the status `Poffsetint` had through `eliminate_ref`. All 116 primitive cases and all 22 expression cases in `Lam_convert` are now a 1-1 rebuild. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`value_kind` had a single inhabitant, `Pgenval`, passed at all 31 construction sites and ignored at every pattern. It is a remnant of the native backend's unboxing information, which this target has no use for. `Llet` now has the same shape in both layers. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Two fields carried by one layer and not the other. `Lam.lfunction.arity` was always `List.length params`: conversion computed it that way and every pass threaded it through unchanged. Its one real reader, `Lam_arity_analysis`, derives it instead. `Lswitch` and `Lstringswitch` carried a `Location.t` in Lambda that conversion discarded, so nothing downstream ever saw it. Dropping it changes one file's output for the better: `matching` shares identical match actions by comparing Lambda terms structurally, and two actions differing only in a switch location compared unequal, so they were emitted twice. `mario_game` loses 16 lines, where two constructor cases now fall through to one body. `lfunction`, `Llet`, `Lswitch` and `Lstringswitch` now have the same shape in both layers. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
`Lprim` carried a tuple in Lambda and a record in Lam, with the same three components. Make Lambda's a record with Lam's field names and order, so the constructor differs only in that Lam's is private. The 61 call sites change mechanically, patterns and constructions alike. The line count is inflated by the formatter: a record literal wraps where a tuple fitted on one line. `compiler/ml/dune` gains `-30`, since `loc` now appears in both `lfunction` and `prim_info`. Lam has the same collision and is silent because `compiler/core/dune` already disables that warning; this makes the two agree. It does disable duplicate-definitions for all of `compiler/ml`, which is the price of the field names matching. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam carries an application's location and inline attribute in an `ap_info` record; Lambda had them flat. Give Lambda the same record and make Lam's a manifest alias of it, so the two are the same type rather than the same shape. Conversion stops taking the record apart and rebuilding it. Every constructor of the two expression types now has the same payload. What remains between them is `private` on `t`, `apply` and `prim_info`. Generated JavaScript is unchanged for the runtime, Belt, tests/tests and gentype. Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The generic switch compiler guards a jump table with a range test, because on a machine target a table plus a range check beats a table carrying a default. A JS switch has a native default clause, so when the table already covers the whole guarded range the guard is pure overhead. Lam.if_ recognized that shape and merged the guard back in as the switch's failaction. Do it in matching's S_arg.make_if instead, where the switcher builds the two branches, and drop the arm and its complete_range helper from Lam.if_. This removes a subtree-traversing peephole from a shared smart constructor: once Lambda and Lam are one type, if_ is what translcore and matching build their conditionals with, and it should not be pattern-matching on shapes a specific producer happens to emit. Generated JavaScript is unchanged across the runtime, Belt and the 620 test modules. Instrumenting the Lam arm before deleting it showed zero firings over the same corpus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Pisout was a derived predicate: "arg + offset is outside [0, range]", with both bounds already compile-time constants at the only place that built it. It never became an unsigned comparison in the output either - E.is_out expanded every case into ordinary ===, ||, < and >. Pass the range across the switch functor instead of a lowered test. The Arg signature loses make_isout / make_isin and gains make_if_out / make_if_in, which take the bounds as ints along with both branches, so the producer decides how to test a range. switch.ml's four helpers become two one-line delegations, and matching emits the comparisons directly. The failaction merge now reads the bounds from its arguments rather than recovering them by pattern-matching an Lprim. Removes Pisout from Lambda.primitive, Lam_primitive.t, lam_convert, lam_compile_primitive, lam_analysis, both printers and Lam.has_boolean_type, along with E.is_out and its interface entry. Constant range tests used to be folded by E.int_comp, which reinterprets its left operand as unsigned - an undocumented coupling that is what made is_out's fallback correct despite dropping the lower-bound test. They now fold at the Lam level through the signed Lam_compat.cmp_int32, by the ordinary Pintcomp and Psequor rules. Four snapshots change, one line each: E.is_out had two arms for a two-value range that disagreed on operand order, so a test came out as [x !== 2 && x !== 1] or [x !== 1 && x !== 2] depending on whether the offset landed in an addition or a subtraction. All of them are now ascending. The rest of the runtime, Belt and the 620 test modules are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Switch.Make had a single instantiation. Upstream OCaml has two - matching.ml for Lambda and cmmgen.ml for Cmm - and the functor exists so one copy of Baudinet's algorithm serves both. ReScript dropped native codegen at the fork, so the abstraction has had one client ever since. switch.ml now opens Lambda and defines the operations it needs directly; matching.ml loses S_arg and calls Switch.zyva / Switch.test_sequence. The trivial builders were pure indirection and are inlined: make_const, make_prim, make_if and the six comparison primitives. Only emit_if_out, emit_if_in and emit_switch keep names. Three dead things the functor was hiding: - S.bind was in the signature and implemented, but the algorithm never called it. - zyva's Location.t only fed Arg.make_switch, whose implementation ignored it. Removing it also retires the loc of call_switcher and of call_switcher_variant_constant, which was dead in both matching's and polyvar_pattern_match's implementations. call_switcher_variant_constr keeps its loc - that one reaches a Pfield. - Switch.Not_simple was declared in the .ml and the .mli and never raised or caught. Lambda.Not_simple is a different exception and is still used. 'a t_ctx is now monomorphic. 'a inter stays polymorphic: it is instantiated at both lambda and t_ctx -> lambda, which is now documented rather than rediscovered. make_exit, as_simple_exit and make_catch_delayed move to lambda.ml, the shared dependency, since matching still uses them outside the switcher. The Bernstein/Spuler attribution stays; the line costing an interval test as one addition plus one unsigned test and branch described the backend the algorithm was written for, and now says what this one emits. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The two declarations had the same 115 constructors with the same payloads - Lam_compat.comparison, Lam_tag_info.t and Lambda.import_source are all already aliases of the Lambda types - and differed only in declaration order. Keeping them apart meant every new primitive had to be added twice and stay in sync by hand. Lam_primitive now re-exports the type manifestly, as Lam_constant already does for constants, and keeps its operations. Lam_convert's lam_prim was 116 lines mapping each constructor to itself; it is gone, and Lprim conversion is a direct Lam.prim call. lam_convert.ml goes from 230 lines to 95. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam.t is private so that every term the optimizer builds goes through the normalizing constructors. That guarantee cannot survive sharing the type with a public Lambda: once the two are equal, Lambda's constructors build values of Lam.t directly. Privacy has to hold on both sides or on neither. So Lambda gains the same discipline first. lambda, prim_info and lambda_apply are private - matching Lam's t, prim_info and apply - and the interface documents a constructor per variant, with signatures mirroring Lam's so the later alias is a rename. 238 construction sites move over, across translcore, matching, translmod, transl_recmodule, lambda_scc, switch, translattribute and polyvar_pattern_match. The constructors are plain wrappers for now. The folds stay in Lam until the two types become one, so there is never a second copy of the fold logic in flight; when Lam.t becomes a private re-export, lam.ml loses the ability to construct and its folds move up into these constructors. Generated JavaScript is unchanged, which is expected rather than lucky: at this stage the constructors build exactly what the call sites built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
[map_sharing] returns the original list when every element maps to a physically equal value, so a traversal that rewrites nothing allocates nothing. Same for [map_snd_sharing] and the option map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lambda's constructors were plain wrappers; Lam's normalize as they build. Since the two types are converging, Lambda gets the same treatment, and the interface documents which six normalize and what each does - so the rule is readable rather than folklore. The folds need three operation sets that lived on the Lam side, and compiler/core cannot be a dependency of compiler/ml, so they move down and are delegated from their old homes: cmp_int32 / cmp_float from Lam_compat, eq_approx from Lam_constant (as const_eq_approx), and eq_primitive_approx from Lam_primitive. One fold does not come along. [if a then raise e else c] becoming [(if a then raise e else ()); c] is code motion, not normalization: matching inspects the terms it has built after the fact, and rewriting them as they are constructed leaves static raises without their catch - the runtime build fails outright with Not_found from compile_staticraise. It stays in Lam.if_ for now and moves to a scheduled pass next. The interface states the rule this taught us: a constructor may replace a node with an equivalent one, but may not move code between branches. Generated JavaScript is unchanged. That is expected rather than lucky: at production the operands are still in source shape, so the folds almost never fire there - measured at zero firings for prim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
[if a then raise e else c] reads better as [(if a then raise e else ()); c]: the continuation stops being nested inside a branch, which is the guard clause idiom in the emitted JavaScript. It is worth keeping - dropping it changes 20 files, adding an indentation level to bodies as large as Stdlib_List.getOrThrow. But it is code motion, not normalization, and it cannot run from a constructor: matching inspects the terms it has built after the fact, so rewriting them during construction leaves static raises without their catch. As a scheduled traversal it is fine. It has to run last: instrumenting the firing site shows the opportunities come from conversion (18) and from four different passes - exits, remove_alias, lets_dce and deep_flatten (5) - so a pass placed after all of them catches every case without having to know which one produced it. Applying it only at the end of translation recovers 18 of the 20 files; the two it misses need inlining first, where invalid_arg and assert(false) become raises. Lam gains shallow_map_sharing, which maps a node's immediate children and rebuilds through the smart constructors, so a rewritten child is still normalized while an unchanged node is returned as-is. The pass is then only its own logic, and the traversal is available to other passes. The pass allocates nothing when it has nothing to do: over the corpus, 742 of 760 modules come back physically identical having allocated zero minor words; the 18 that are rewritten allocate 63 words each, the spine down to each rewritten node. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The two types had the same constructors in the same order with every payload already aliased, so Lam.t becomes a plain alias. Not a re-export: the passes never open Lam, they reach the constructors by type-directed disambiguation from the (lam : Lam.t) annotation, and that resolves them from Lambda just as well - warnings 40 and 41 are already off in this tree. lam.ml goes from 653 lines to 69: the type block is aliases and everything else delegates. shallow_map_sharing moves to Lambda, since with the type shared and private there, lam.ml can no longer construct - which is the invariant we wanted. Lam_convert.convert was the identity, so it goes and lam_compile_main feeds the Lambda term straight into the pipeline. required_modules stays. Three files reached constructors through the module path rather than by disambiguation - Lam.Lvar and friends - and now say Lambda. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam_tag_info, Lam_constant, Lam_primitive and Lam_compat had become re-exports standing between the core and types it already shared with Lambda: a manifest alias, a delegation or two, and one or two real functions each. The real functions move down - const_is_allocating, is_immutable_block, str_of_field_info - and the four modules go. Every reference names Lambda now, including the constructors the shells re-exported: Lam_compat.Strict, Lam_compat.Fld_module, Lam_primitive.Pfield and the rest. Seven files deleted, 44 touched. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The two were the same computation - one written through iter and a get callback, the other hand-recursed - differing only in the set type. Lambda's wins, because Ident_set is load-bearing beyond free variables: matching uses it for pattern variable sets and unions those with free_variables results, so moving the other way would have cascaded through matching. Converting the five core call sites was the cheap direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
subst_lambda was 49 lines of hand-written shallow rebuild, and the only place left that constructed a term without going through the constructors - it could, being inside the module that owns the type. shallow_map_sharing already does that walk, so substitution is the Lvar case and a recursion: eight lines, normalizing as it rebuilds, and returning a subterm that contains no substituted variable physically unchanged. Lam_subst was the same function over Map_ident instead of Ident.tbl, kept apart only because it rebuilt through the smart constructors while subst_lambda did not. With that difference gone it is redundant, and its one caller in lam_pass_exits switches map type. Generated JavaScript is unchanged, at both steps. Normalizing during substitution turns out to be a no-op for the same reason it is at production: translmod substitutes module identifiers, so the folds have nothing to act on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam_iter.inner_exists and Lambda.iter were two hand-written walks over the same twenty-two constructors, in different modules. Lambda gains shallow_exists - the short-circuiting query - and iter becomes three lines on top of it. iter_opt existed only to serve iter and goes with it. Lambda now has two shallow traversals rather than four: shallow_map_sharing for rewriting, shallow_exists for querying. Visit order shifts for a couple of node kinds, which nothing observes: iter's only consumers accumulate into a set and into a hash set. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lam was 69 lines of delegation: its type was an alias of Lambda.lambda and every function forwarded. Its users now name Lambda directly. Lam.t becomes Lambda.lambda, the type aliases become the Lambda ones - lambda_switch, lambda_apply, lfunction, prim_info - and the two values Lam spelled differently, unit and false_, become lambda_unit and lambda_false. 63 files touched. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Every module this tree retired named its principal type t - Lam.t, Lam_primitive.t, Lam_constant.t - as do the ext modules. Lambda.lambda was the odd one out, and only because the file arrived from upstream OCaml with that spelling. Deleting Lam had just propagated it to three hundred sites. Comments keep saying lambda, and the compound names are untouched: lambda_switch, lambda_apply, lambda_unit, subst_lambda, name_lambda. Only the type is renamed. printlambda has a value called lambda as well as the type, so inside modules that open Lambda only type positions were rewritten. The core README loses its Lam framing, and its "changing a representation" section states the invariant that actually holds now: the type is private, six constructors normalize as they build, and a constructor may replace a node but not move code between branches. The CLAUDE.md bullet warning that Lam and Lambda are distinct IRs with confusable constructors is gone, being no longer true. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Seven were left, all prose rather than code: the -debug-ir and -check-lam help text, the doc comment on check_lam, the -debug-ir dump messages, two comments pointing at a module that no longer exists, and my own note in lambda.ml saying the raise-guard rewrite "stays a Lam-side rewrite until it can be expressed as a pass" - which it now is, so it points at Lam_pass_guard_raises. The lam_ filenames stay. They read as "the Lambda backend", which is what they are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
lam_print and printlambda printed the same twenty-two constructors and hundred-odd primitives, differing on twenty-eight of them, and both were live: -debug-ir and lam_group went through one, -drawlambda and matching through the other, and the playground rendered the same term twice. printlambda is the base - it carries more information almost everywhere: field debug info (field:var/0 rather than field 0), the block tag (makeblock ext, makeblock module/exports rather than makeblock 0), the import path, unit distinguished from undefined, and readable names where lam_print had bracket noise ([null->opt], [?null], +*). From lam_print: the mutable block distinction, and the serialize and lambda_to_string entry points that -debug-ir and the playground need. Better than either, the comparisons. printlambda printed Pobjcomp, Pboolcomp, Pintcomp, Pstringcomp and Pjscomp all as bare ==, so five primitives were indistinguishable in a dump; lam_print tagged two of the six operators on Pintcomp and left the rest. One helper now tags every comparison with its operand kind - ==[int], ==[string], >[int] - which also retires the bigint comma suffix, which mirrored float's dot but was unguessable. Const_constructor and Const_polyvar also both printed as `name. The polyvar keeps the backtick, matching source syntax; the constructor prints its name. The debug_ir build test asserts dump filenames and numbering rather than content, so the new rendering does not disturb it. Generated JavaScript is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
lam_convert was down to one function with one caller, and its name had not described it since conversion became the identity. The function moves next to its use in lam_compile_main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
There were four: Set_ident in compiler/ext, used throughout compiler/core, and Set.Make (Ident) written out again in lambda.ml, mtype.ml and - under the name Id_set - parmatch.ml. They all go through Set_ident now. The orderings were already equivalent, which had to be checked rather than assumed: matching lists the elements of an intersection to name ambiguous or-pattern variables, and parmatch does the same for its ambiguity warnings, so a different order would have changed both generated code and warning text. Set_ident takes the set first where the stdlib takes it last, so all 56 call sites needed flipping. The argument types differ, so the compiler checked every one. Generated JavaScript is unchanged, and the syntax and analysis suites - which cover the warning text - pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Three bug fixes and five internal entries, linking PR 8607. Also corrects the constructor comment in lambda.mli, which said six constructors normalize where there are seven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The predicted number was taken between prediction and creation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #8608 +/- ##
==========================================
+ Coverage 76.44% 76.97% +0.52%
==========================================
Files 478 467 -11
Lines 63163 62452 -711
==========================================
- Hits 48286 48070 -216
+ Misses 14877 14382 -495
🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Continuation of #8604. That PR made
Lam_convert.convertstructural; this one finishes the job and removes the Lam layer, leaving a single Lambda IR.The merge
With conversion already a one-to-one rebuild,
Lam.tandLambda.lambdawere the same type written twice — same 22 expression constructors in the same order, same 115 primitives, every payload type already a manifest alias. SoLam.tbecomesLambda.tand conversion becomes the identity, then disappears.Retired with it:
Lam,Lam_convert,Lam_primitive,Lam_constant,Lam_compat,Lam_tag_info,Lam_free_variables,Lam_subst,Lam_iter,Lam_print,Lam_pass_apply_arity,Lam_eta_conversion, theSwitchfunctor, and the primitivesPisout,Poffsetint,Poffsetref,%function_arity,%succint,%predintalong with the curried-application machinery.What replaces it: one private
Lambda.twhose constructors normalize as they build, two shallow traversals (shallow_map_sharingfor rewriting,shallow_existsfor querying), one printer, and one scheduled pass for the rewrite that turned out not to be normalization.Where the line between constructor and pass falls
The interface now states a rule the work established empirically: a constructor may replace a node with an equivalent one, but may not move code between branches.
Porting
Lam.if_'s folds to Lambda broke the runtime build —Fatal error: exception Not_foundfromcompile_staticraise. Bisected per arm, the culprit was rewritingif a then raise e else cinto(if a then raise e else ()); c. That is code motion, andmatchinginspects the shapes it built after building them, so rewriting during construction leaves static raises without their catch. Every other fold was safe at production, and fires almost never there — measured at zero firings forprim.As a scheduled pass it is fine. It has to run last: instrumenting the firing site shows the opportunities come from conversion (18) and from four different passes —
exits,remove_alias,lets_dce,deep_flatten(5) — so a pass placed after all of them catches every case without knowing which produced it. It is worth keeping: dropping the rewrite changes 20 files, adding an indentation level to bodies as large asStdlib_List.getOrThrow. The pass allocates nothing when it has nothing to do — 742 of 760 modules come back physically unchanged having allocated zero minor words.Bug fixes
Three, each with a regression test:
module rec M: {} = { let () = Console.log("effect") }emitted nothing forM.Int.Ref.increment(mkRef())evaluatedmkRef()twice.int32range crashed the compiler withFailure("Int32.of_string")and no location.Output
Generated JavaScript is unchanged, with one deliberate exception: four snapshots change the operand order of two-value range tests (
x !== 2 && x !== 1→x !== 1 && x !== 2).E.is_outhad two arms for that case which disagreed on order, so which you got depended on whether the offset landed in an addition or a subtraction. They are now consistent.Every commit was verified against the full corpus — runtime, Belt and the 620 modules in
tests/tests— plusounit,mocha, build tests, syntax, gentype and analysis. The playground compiler builds andyarn workspace playground testpasses.Notes for review
compiler/ml/typecore.ml) is the one commit unrelated to the merge. It is a standalone crash fix and can be split into its own PR if preferred; it is also this branch's only substantial edit outside the Lambda area.Const_stringpayload through a representation that was duplicated acrossLam_constant,Lam_primitive,Lam_printand the converters. Landing this first collapses those edits into one place. Details in a comment on Normalize string literal representation across the compiler #8606.