Repository navigation
ci: compiler-output-regression has been red on every main sweep since 2026-09-18 — six structural verdicts, and nobody has established whether they are regressions or stale expectations #10784
Description
Activity
Correcting my own framing above before anyone acts on it. I wrote "the gate was green at some point before 2026-09-18T17:56, and the transition commit names the cause." I did not verify that, and it does not hold within any window I can query.
Sampling 60 consecutive
test.ymlruns onmain,compiler-output-regressionisfailurein every one that ran it, back tofcd108bfb0(2026-09-16T07:55, v0.5.1579) — four days, no green. The job itself is long-standing (it traces back to the v0.5.1117 era), so it was surely green once, but the last green is outside whatgh run listwill return.So there is no known-good endpoint, and a bisect has nothing to bisect between. Starting one would burn a release build per hop searching a range whose upper bound is unknown. Ignore that instruction.
Do this instead
Answer the regression-vs-stale-expectation question directly from the artifacts, which needs no known-good commit:
- For each of the three
vectorization_expectationworkloads, read the fixture and the emitted IR and decide whethercontrol_flowis an honest reason for the vectorizer to decline that loop. If the loop genuinely contains control flow the vectorizer cannot handle, the expectation is stale. If it does not, it is a regression. - Same for
raw_numeric_field_get_guard/native_reps_required_raw_class_field_get_*onraw_numeric_object_fields, andno_dynamic_property_runtimeonnative_owned_typed_views: does the compiler's current output actually violate the property the verdict names, or has the property's definition drifted? - Only if (1) or (2) says "regression" is a bisect worth running — and by then you have a bounded hypothesis about which subsystem changed, which makes
git log -Sover that subsystem far cheaper than a build-per-hop bisect.
The standing caution still holds, and is now more important, not less: do not refresh the expectations to make the gate green. A four-day-red gate with six stable verdicts is exactly where re-baselining turns a compiler regression into permanent invisible behaviour. If they turn out to be stale, say why each one is stale in the PR that refreshes it.
One more thing this changes: a gate nobody can show green in four days is not obviously providing signal. Whether it should remain a
main-gateinput while in this state is a maintainer call — but leaving it required-and-always-red means every merge tomainis already bypassing it, which is the "gate theatre" pattern CLAUDE.md documents.- For each of the three
My retraction above was itself wrong. There is a known-good endpoint, and I have verified it.
I said "the last green is outside what
gh run listwill return, so there is no known-good endpoint — ignore the bisect instruction." The REST API pages pastgh run list's cap. Walking/actions/workflows/test.yml/runs?branch=mainand checking the job conclusion on each run:run head compiler-output-regression336262272780b24670dd92026-09-02T11:45Zsuccess 336575706633a7c0b0a812026-09-02T16:52Zfailure git rev-list --count 0b24670dd9..3a7c0b0a81= 45. So the gate has been red for 18 days, not four, and a bounded 45-commit window existed the whole time. Two wrong claims from me on this gate in one hour, both from treating an exhaustedgh run listas proof of absence.It is three events, not one — which is why the failing set looked stable
onset verdict(s) cause 2026-09-02, window 0b24670dd9..3a7c0b0a81vectorization_expectation× 3b13a20095c(#9441) — proven by IR A/B≤ 2026-09-05 no_dynamic_property_runtimenot attributed; window is 267 commits 2026-09-12 → 09-15 raw_numeric_field_get_guard+ 3 ×native_reps_…fc54e1b181— self-documentedTwo artefacts of the job's own history hid this. Until #8855 added
if: !cancelled(), the job aborted at the first failing step, so from 09-02 to 09-04 only the native-region step ran andno_dynamic_property_runtimewas structurally invisible — its onset window cannot be narrowed from artifacts at all. And the failing set churns:fdc437f966also failedh1_native_rep_equivalence, which does not fail now. The "six stable verdicts" in the issue body is the last-4-days shape, not the original one.Verdict: all six are stale expectations. None is a compiler regression.
The three
vectorization_expectationfailures are not about the fixtures at all. Thecontrol_flowremark comes frommain's compiler-emitted event loop, which is in every Perry binary.dynamic_fractional_array_index.tscontains no loop and still reportscontrol_flow: 1; a bareconsole.log(1)produces the same five remarks;opt -passes='loop-simplify,print<loops>'finds exactly one loop inmain(event_loop.header/check_pending/body/body_check/body_wait/exit), and excising it drops the remark count 29 → 0. The reason is honest: uncomputable trip count, no induction variable, two uncountable early exits, 25 side-effecting calls, and a predicatedbody_waitthat cannot be if-converted. Vectorizing a program's event loop is meaningless. All three positive halves pass.b13a20095c(#9441, "the event loop looks before it sleeps") added thebody_check/body_waitdiamond; reconstructing the pre-#9441 3-block loop in the IR removes exactly theControl flow cannot be substituted for a selectremark and leaves the other four, which are already allowed. That was a real fix (1082 ms vs node's 132 ms on a 20 mssetTimeout). The expectation should move, not the codegen. Corroborating:control_flowis already allowed in 22 of 25 workloads — the only three lacking it are the three that fail, and theirvectorizationblocks have not been touched since June/July while the others were widened piecemeal (#8490, #8854 are the precedent).The four
raw_numeric_object_fieldsfailures are one changed string, seen twice. That workload is a member of both suites with identical arguments, so 4 errors × 2 suites = the 8 occurrences in my table above — one defect, not two and not four.fc54e1b181collapsed the class-field GET tower to one exit (−7.09 %.texton @babel/parser), updated its own codegen unit tests, and missedworkloads.toml. Every one of the nine required facts is still recorded; the only field that differs isconsumer: expectedjs_object_get_field_by_name_f64, actualjs_class_field_get_ic. The propertyraw_numeric_field_get_guardnames still holds — the sibling check that asserts the structure directly,raw_numeric_field_get_scoped_raw_load, passes.no_dynamic_property_runtimeis an over-broad detector. It is a raw substring sweep of the whole module; exactly one helper hits,js_dyn_index_set_strict, in the coldtav.set.slowarm of a guarded typed-array store — the fixture's designed negative case, whose ownallow_materialization_reasonslists"mutable_alias", the very reason recorded. The workload declares the fallback legal in one knob and illegal in another. Tellingly, the matchingTypedArrayGetalso falls back formutable_aliasbut its slow arm callsjs_typed_array_get, which is not on the watched list — so the read is "clean" and the write is not, for no semantic reason.This is the weakest of the six calls and I am flagging it as such rather than presenting six equal answers:
const alias = viewis a trivial copy, so if the native-owned-view analysis should see through it, this would be a minor analysis regression rather than a designed fallback. Leading suspect for the routing change isa2ffa80a4c("retire receiver fact tables", 2026-09-04), which migrates exactly the eligibility predicate inexpr/index_set.rsand claims to be behaviour-neutral — unconfirmed, and confirming it costs two debug builds.One incidental correction
The existing
#8489comment inworkloads.tomlattributesnative_owned_typed_views'control_flowto #8457's-O3 → -Osswitch. Measured at-O3,-O2,-Os,-Oz: it appears identically at -O3, -O2 and -Os; only-Ozsuppresses it, by disabling the vectorizer wholesale. The opt-level is not the cause. Anyone editing that comment should fix it.The structural finding worth more than the fix
allowed_missed_reason_kindsis a module-wide sweep, andmain's event loop guarantees a floor of{control_flow, call_instruction, generic_not_vectorized ×2, unknown_trip_count}in every program. For any fixture without un-vectorizable loops of its own, that list is a detector of compiler boilerplate, not of the fixture — which is exactly why 22 of 25 workloads have quietly accumulated the same five entries (13 → 22 over time). Scoping the remark pass to the fixture's own functions, excludingmain, would make it a real detector again.And the obvious one: required, and red for 18 days, means every merge in that period bypassed it.
Fix implemented and pushed —
fix/10784-stale-compiler-output-expectations@2079501cdc, 4 files, +493/−22. It will land via a merge train.Both
suite --suite native-region-proofand--suite native-abi-proofnow exit 0 withfailed_workloads: [], andpython3 -m unittest tests.test_compiler_output_regressionis 85 OK.run_lint_gates.shis 78 of 79, the one failure being the known-red public-baseline step, verified identical on a pristine tree.Every refreshed expectation was sabotage-tested back to red, because a refreshed expectation that cannot fail is worse than the stale one it replaced:
- dropping a different allowed kind (
call_instruction) → red; raisingmin_vectorized_loopsto 99 → red on the positive half. Both halves stay live. - reverting one
consumerstring → red; pointing the guard regex at a non-emitted symbol → red; keeping the guard load but requiring a branch into a non-existent block → red. - injecting
js_dyn_index_getintonativeOwnedPositive→ red, naming the function; intonativeOwnedKinds→ red; intodisposedFallback(which declaresuse_after_dispose) → green, as intended; removingmutable_aliasfromallow_materialization_reasons→ red, so the licence comes from the workload's written allowance rather than a hardcoded name.
no_dynamic_property_runtimewas scoped, not blanket-allowed —verification.pynow sweeps function bodies and exempts a function whose own native-rep record declares a fallback the workload already allows. Noallow_dynamic_property_runtime = true, and deliberately no exclusion by block label, since a label exclusion would hide the same degradation innativeOwnedPositive. Twenty untargeted workloads are byte-identical; the twenty-first differs only in a temp scratch path echoed into a detail string.Three corrections to my diagnosis comment above
I posted that comment, so these are mine to correct.
1.
native_owned_typed_views' onset was wrong — 2026-09-15, not "≤2026-09-05", and the window is ~20 commits, not 267. Bracketed by job log:6bab431189(17:18Z) passes,ea42912d50(21:26Z) fails, and the workload appears infailed_workloadsfor the first time there.2.
a2ffa80a4cis not the cause. Itsbuffer_views.rsdiff is a mechanicalbuffer_view_slots→receiver_descriptorstable migration, and the gate was green for eleven days after it. I named it as "leading suspect" on the strength of it touching the right predicate; that was pattern-matching, not evidence.3. The real cause is
d792c0a761("inline element access on declared typed arrays with unproven indexes", 2026-09-15). I verified independently that it is the only commit in6bab431189..ea42912d50— 27 commits — that touchesexpr/index_set_typed_array.rs.And the substantive point: nothing regressed, and the analysis never saw through
const alias = viewin the first place. At the last-green commit0b24670dd9,mutableAliasFallback'sTypedArraySetrecord was identical in every field to today's —access_mode: dynamic_fallback,materialization_reason: mutable_alias,fallback_reason: mutable_alias. Only the helper it lowered to changed: a barejs_typed_array_set, which is not on the watched list, became the guarded inline arms whose cold exit isjs_dyn_index_set_strict, which is. A codegen improvement turned a helper's name into a watched prefix. So my "weakest call" was also the one I got most wrong — in the safe direction, but wrong.A caveat on the structural finding, which I also understated
I wrote that scoping the remark pass to exclude
mainwould makeallowed_missed_reason_kindsa real detector again. Necessary but not sufficient. The GC loop back-edge poll (load volatile @PERRY_GC_POLL_ARMEDguardingjs_gc_loop_safepoint(), default-on since #7721) injects compiler-emitted control flow into the fixture's own functions — it accounts for 2 ofnative_owned_typed_views' 3control_flowremarks, insidenativeOwnedPositive. A function-scoped list would still have to accept those, or the poll needs excluding structurally too. Anyone budgeting that work as "just dropmain" will find it does not close.The mechanism for doing it properly does exist:
-fsave-optimization-record=yaml -foptimization-record-passes=loop-vectorizegives exact per-function attribution with stable machine-readable keys, strictly better than the current English-prose regexes over<unknown>:0:0stderr. ~150–250 lines. The real cost is the re-baseline — it changes what all 25 workloads measure, so every list must be re-derived and each surviving entry re-justified, including the three this PR just widened. Its own issue, so the 25-workload diff is reviewable on its own.Also corrected in the file: #8489's comment blaming
-O3 → -Osfornative_owned_typed_views'control_flow. Measured at-O3,-O2,-Os,-Oz— it appears identically at the first three; only-Ozsuppresses it, by disabling the vectorizer wholesale. The three remarks are 2 from the GC safepoint poll and 1 frommain's event loop.- dropping a different allowed kind (
- added a commit that references this issue
on Sep 20, 2026 Fixed on
mainas of v0.5.1616 (8e9f6f09e8), via merge train 237 (#10792).compiler-output-regressionnow passes both suites — the first time since 2026-09-02, eighteen days. It is amain-gateinput, so every merge in that window was bypassing it.The refreshed gate was proven able to fail, on the assembled tree rather than only per-branch:
clean → rc=0, "failed_workloads": [] sabotaged → rc=1 (one require_records consumer reverted to js_object_get_field_by_name_f64)That check took four attempts, and the three that failed are worth recording because each one looked like a pass: BSD
sedhas no0,/re/address so the file was never modified; the next edit landed on a comment rather than theconsumer =line; and the third used the wrong entry point, which produced zero bytes of output and exited 0. Asserting that an edit landed is not sufficient — it has to land on the load-bearing line, and the harness has to actually be running.Both fixes are in the train body in full, including the reasoning that
PERRY_NO_AUTO_OPTIMIZE=1is set at the HIR probe and deliberately not at the linking compile, sinceruntime_budgetsare maxima derived from the executed binary'sPERRY_GC_TRACEoutput and would pass vacuously against a runtime that emits no trace.Two things left open rather than quietly closed:
The cold build still happens, just in the linking compile's 300 s budget instead of the probe's. Only one
perry-autohash is created across all 23 workloads, so CI pays exactly one cold build — the fix changes which step absorbs it, not whether 300 s is enough. The durable answer is a pre-warm step intest.ymlwith its own explicit budget, assertingtarget/perry-auto-*exists afterwards or it is a no-op nobody notices.--no-linkcould decline to auto-optimize outright — a two-line guard atrun_pipeline.rs:6169— which fixes the class rather than this instance. The risk is the undefined-symbol stub scan at:6179, which consumesoptimized_libs.runtimeto seed its defined-symbol set; skipping the rebuild falls back tofind_runtime_libraryand could change synthesized stubs in an emitted object.repsel_determinismandrepsel_knob_isolationhash--no-linkobjects, so their baselines would move once. Worth its own issue.And the structural finding, which this PR deliberately did not attempt:
allowed_missed_reason_kindsis a module-wide sweep andmain's event loop guarantees a five-remark floor in every program, so for any fixture without un-vectorizable loops of its own the list detects compiler boilerplate rather than the fixture. Excludingmainis necessary but not sufficient — the GC loop back-edge poll injects control flow into the fixture's own functions, accounting for 2 ofnative_owned_typed_views' 3control_flowremarks.-fsave-optimization-record=yamlgives exact per-function attribution with stable keys and is the right mechanism, ~150–250 lines, but the real cost is re-deriving all 25 workloads' lists. Its own issue, so that diff is reviewable on its own.
compiler-output-regressionis red on everymainsweep, and has been continuously since at least60922041cd(2026-09-18T17:56) — 26 consecutive sweeps with no green. It is amain-gateinput, somain-gateis red on every train.The cause is a stable set of structural verdicts, not a timeout. (#10782 originally claimed the timeout was the cause; that was measured from a single run and corrected — it appears in 1 of 6 recent runs, while these six verdicts appear in all of them.)
The failing set
vectorization_expectation—unexpected_missed_reason_kinds: ["control_flow"]dynamic_fractional_array_index,vectorized_buffer_transform,hir_fact_rewriteraw_numeric_field_get_guard+ 3 ×native_reps_required_raw_class_field_get_*raw_numeric_object_fields(fails in both the native-region and native-ABI suites)no_dynamic_property_runtimenative_owned_typed_viewsOccurrence counts per job log, six recent sweeps — note how stable they are, which is what distinguishes this from the intermittent timeout:
unexpected_missed_reason_kindsno_dynamic_property_runtimeTimeoutExpireddd00a003053f5b5b1424bd24fb32a214ca31448114ca3144812f9dc8e692All six reproduce locally against the v0.5.1611 pinned binary, identically — so this is not a runner artifact.
What is not yet known
Nobody has established whether these are compiler regressions or stale expectations. Both readings fit:
unexpected_missed_reason_kinds: ["control_flow"]says the vectorizer is now declining a loop it used to take, which would be a real regression; but it could equally be an expectation file that a legitimate lowering change outdated and nobody refreshed. The raw-field-get and dynamic-property verdicts have the same ambiguity.Answering that is the first task, and the way to answer it is a bisect over
main— the gate was green at some point before 2026-09-18T17:56, and the transition commit names the cause. Do not refresh the expectations to make the gate green without doing that first: if these are real regressions, re-baselining them is how a compiler regression becomes permanent and invisible.Why it went unnoticed for so long
A gate that is red every day stops being read. This one was additionally shielded by an intermittent timeout in the same job that looked like a sufficient explanation — I filed #10782 on exactly that mistake. The lesson for the next person reading a long-red gate: check whether the failing set changed, not just whether it is non-empty.
Related: #10782 (the intermittent timeout in this same job, real but separate).