perf(codegen): specialize call-returned array stores #3068
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
| # CI arm for the in-process LLVM backend (#7241, merged in #7301). | |
| # | |
| # The GC knob kill-policy (CLAUDE.md) demands every shipped mode be exercised | |
| # by CI or deleted; this job is the exercise for `PERRY_LLVM_INPROCESS`. | |
| # Each step asserts its subject was LIVE (liveness line, diff verdict) rather | |
| # than merely that nothing threw — see "Four ways a gate can be unable to | |
| # fail". NON-REQUIRED until it has run green once; promote afterwards | |
| # (a new gate has never been green, so promoting first blocks every PR). | |
| name: llvm-inprocess | |
| # No trigger-level `paths:` — deliberately. A required check whose workflow | |
| # is path-filtered at the trigger never CREATES a check run for PRs outside | |
| # those paths, so the required context sits "waiting" forever and blocks the | |
| # merge (the promotion trap CodeRabbit flagged on #7304). Filtering lives in | |
| # the `changes` job instead: a job skipped by `if:` still reports a check | |
| # run (conclusion: skipped), which branch protection accepts. | |
| on: | |
| pull_request: | |
| # PR arm is OPT-IN via the `run-extended-tests` label (see the header of | |
| # test.yml and docs/src/testing/ci-tiers.md): an unlabelled PR still gets | |
| # a run, but every job in it is skipped, which costs no runner slot. The | |
| # main-line arm (schedule / tags) is unchanged. `labeled` re-fires the run | |
| # when the label lands. | |
| types: [opened, synchronize, reopened, labeled] | |
| # POST-MERGE ARM: staggered six-hourly sweep of `main`, NOT one run per merge. | |
| # `push: branches: [main]` starved this gate and nine others for two days | |
| # (#7856): ~29 jobs enqueued per merge at 58 merges/day against a ~9-job | |
| # concurrency ceiling. The pull-request arm is unchanged -- every PR is still | |
| # measured. ***DO NOT RESTORE `push: branches: [main]`.*** | |
| # Rationale, measurement and cost: docs/src/testing/ci-gate-scheduling.md | |
| schedule: | |
| - cron: "42 */6 * * *" | |
| push: | |
| tags: ["v*"] | |
| workflow_dispatch: | |
| concurrency: | |
| # ***#7966: KEY EVERY MAIN-LINE RUN ON `github.run_id`, NOT `github.sha`.*** | |
| # The previous expression read `github.event_name == 'push' && github.sha || | |
| # github.ref`. That was #7205's fix and it keyed on the event being `push` -- | |
| # correct while the main-line arm WAS `push: branches: [main]`. #7856 moved the | |
| # main-line arm to `schedule:`, which falls through to `github.ref` (constant | |
| # `refs/heads/main`), so every scheduled run shared one group again and #7205 | |
| # came straight back. Measured 2026-08-12 on all ten scheduled gates, the same | |
| # shape every time: oldest run `queued` holding the group, the two after it | |
| # `cancelled` with `jobs: 0`, newest `pending`. `github.run_id` is unique per | |
| # run, so schedule / tag-push / workflow_dispatch each get a group of their own | |
| # and none can supersede another. PR runs keep the shared per-ref group and | |
| # keep superseding themselves, which is still what we want. | |
| group: llvm-inprocess-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} | |
| # One group per main COMMIT, cancelling PR runs only. `cancel-in-progress: | |
| # false` alone does not protect a `main` run: GitHub allows at most one | |
| # PENDING run per group and cancels the previously pending one when a new run | |
| # enters, regardless of that setting. That is #7205 — measured here as EIGHT | |
| # consecutive `main` runs cancelled, zero executions. Keying push runs on the | |
| # SHA gives every merged commit a group of its own. | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| # Cheap relevance filter via the PR files API (no checkout, no third-party | |
| # action). Pushes to main always run — main executions are the gate's | |
| # anchor and the promotion prerequisite. | |
| changes: | |
| # PR arm is opt-in (label `run-extended-tests`); see the `on:` block. | |
| if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-extended-tests') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| relevant: ${{ steps.filter.outputs.relevant }} | |
| steps: | |
| - id: filter | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # Anything that is not a pull request (the scheduled `main` sweep, a | |
| # release tag, a manual dispatch) measures unconditionally: there is no | |
| # PR file list to filter on, and #7856's starvation is precisely what | |
| # happens when a post-merge arm quietly declines to run. Testing for | |
| # `= "push"` here was correct only while `push: branches: [main]` was | |
| # the post-merge trigger; under the schedule it would fall through to | |
| # the PR branch, dereference an empty PR number and fail the step. | |
| if [ "${{ github.event_name }}" != "pull_request" ]; then | |
| echo "relevant=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| files=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename') | |
| if echo "$files" | grep -qE '^(crates/perry-codegen/|crates/perry/src/commands/compile/|experiments/llvm-inprocess-spike/|benchmarks/app-patterns/kernels/batch\.ts$|\.github/workflows/llvm-inprocess\.yml$)'; then | |
| echo "relevant=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "relevant=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| native-backend: | |
| needs: changes | |
| if: needs.changes.outputs.relevant == 'true' | |
| runs-on: macos-15 | |
| timeout-minutes: 90 | |
| steps: | |
| # #7982: `fetch-depth: 0`. The "Corpus currency" step below asks git how | |
| # many IR-affecting commits have landed since the corpora were last | |
| # refreshed — and on the default depth-1 checkout that question has | |
| # exactly one commit to answer from, so `git log -- <path>` finds nothing | |
| # and the count printed was a confident **0** no matter how stale the | |
| # files were. A diagnostic that cannot report the problem it exists to | |
| # report is worse than no diagnostic; it was reassuring three readers | |
| # while the corpora sat 151 commits behind. | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install LLVM 22 (pinned major — fail loudly on drift) | |
| run: | | |
| set -euo pipefail | |
| brew install llvm@22 2>/dev/null || brew install llvm | |
| PREFIX="$(brew --prefix llvm@22 2>/dev/null || brew --prefix llvm)" | |
| # llvm-sys 221 requires major 22. If the runner's formula moves on, | |
| # this must go red, not quietly build something else. | |
| "$PREFIX/bin/llvm-config" --version | grep -q '^22\.' | |
| echo "LLVM_SYS_221_PREFIX=$PREFIX" >> "$GITHUB_ENV" | |
| - name: Build with the llvm-inprocess feature | |
| run: | | |
| cargo build --profile perry-dev -p perry -p perry-runtime-static \ | |
| -p perry-stdlib-static --features perry/llvm-inprocess | |
| - name: Unit gates (528 incl. corpus construction + RS4GC pin) | |
| run: | | |
| set -euo pipefail | |
| out=$(cargo test --profile perry-dev -p perry-codegen \ | |
| --features llvm-inprocess --lib 2>&1) || { echo "$out"; exit 1; } | |
| # The corpus gates must have RUN, not skipped: a checkout missing | |
| # the tracked .ll corpora would otherwise green vacuously. | |
| grep -q "dialect::tests::corpus_spike ... ok" <<<"$out" | |
| grep -q "dialect::tests::corpus_batch_kernel ... ok" <<<"$out" | |
| grep -q "dialect::tests::corpus_exception_handling ... ok" <<<"$out" | |
| grep -q "inprocess::tests::rs4gc_schedules_in_process ... ok" <<<"$out" | |
| # The tracked `.ll` corpora above are a SNAPSHOT of what the compiler | |
| # emitted when they were last refreshed (#7302/#7307/#7310, 2026-08-03). | |
| # `corpus_spike ... ok` therefore proves the dialect reader can build | |
| # THAT IR — not the IR this commit emits. When the end-to-end arm below | |
| # goes red while these stay green, that gap is the first thing to check, | |
| # so print it rather than leaving the next reader to rediscover it. | |
| # #7982: the FORM census is the gate (it runs in `lint` and fails when a | |
| # form the reader has a branch for is absent from the corpora). This step | |
| # keeps the age number, which is the other half of the picture and the | |
| # one a human reads when the end-to-end arm goes red while the unit gates | |
| # stay green. It needs `fetch-depth: 0` above to be anything but zero. | |
| - name: Corpus currency (diagnostic; the gate is `lint`'s form census) | |
| if: ${{ !cancelled() }} | |
| run: | | |
| set -uo pipefail | |
| python3 scripts/check_llvm_corpus_currency.py | |
| newest=$(git log -1 --format=%ct -- experiments/llvm-inprocess-spike/*.ll) | |
| if [ -z "${newest}" ]; then | |
| echo "::error::corpus currency could not read git history — is this a"\ | |
| "shallow checkout again? (#7982: depth 1 made this print 0 forever)" | |
| exit 1 | |
| fi | |
| behind=$(git log --oneline --since="@${newest}" -- crates/perry-codegen/src \ | |
| crates/perry-hir/src crates/perry-transform/src | wc -l | tr -d ' ') | |
| echo "tracked .ll corpora last refreshed: $(git log -1 --format='%h %ad' \ | |
| --date=short -- experiments/llvm-inprocess-spike/*.ll)" | |
| echo "IR-affecting commits since then (codegen+hir+transform): ${behind}" | |
| { | |
| echo "### llvm-inprocess corpus currency" | |
| echo "" | |
| echo "- corpora refreshed: \`$(git log -1 --format='%h %ad' --date=short \ | |
| -- experiments/llvm-inprocess-spike/*.ll)\`" | |
| echo "- IR-affecting commits since: **${behind}**" | |
| echo "" | |
| echo "The unit corpus gates assert the reader handles that snapshot." | |
| echo "Only the end-to-end smoke below exercises the IR this commit emits." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Native-mode smoke — liveness, behavior parity, object-byte verdicts | |
| run: | | |
| # NOTE: deliberately NOT `set -e`. Every check below reports what it | |
| # was doing and dumps the captured output before exiting. The previous | |
| # version used bare `grep -q` / `cmp` under `set -euo pipefail`, so the | |
| # 2026-08-11 `main` failures ended at "Generating code..." with a naked | |
| # `exit 1` and no diagnostic at all — three runs, untriageable (#7971). | |
| # The compiler's own message was fine and named the offending IR line; | |
| # it went to a captured file that nothing ever printed. That is #7982. | |
| # The compilers' stderr carries the liveness banner, so it is captured | |
| # to a file; that file is what went unread. It is now always dumped on | |
| # failure, and the failing COMMAND is named. | |
| set -uo pipefail | |
| export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" | |
| export PERRY_NO_AUTO_OPTIMIZE=1 | |
| BIN=target/perry-dev/perry | |
| SRC=experiments/llvm-inprocess-spike/spike.ts | |
| EH=test-files/test_gap_7302_invoke_eh_paths.ts | |
| W=/tmp/inproc; mkdir -p "$W" | |
| dump() { | |
| for f in "$@"; do | |
| [ -s "$f" ] || continue | |
| echo "--- $f (last 80 lines) ---" | |
| tail -80 "$f" | |
| done | |
| } | |
| # run <label> -- <command...> ; stdout/stderr captured per label | |
| run() { | |
| local label="$1"; shift; [ "$1" = "--" ] && shift | |
| echo "==> ${label}: $*" | |
| # Capture the status BEFORE any other command runs. `if ! cmd; then | |
| # rc=$?` would record the NEGATED status (always 0) — the failing | |
| # exit code is the one thing this whole step exists to report. | |
| "$@" > "${W}/${label}.out" 2> "${W}/${label}.err" | |
| local rc=$? | |
| if [ "${rc}" -ne 0 ]; then | |
| echo "::error::${label} FAILED (exit ${rc}): $*" | |
| dump "${W}/${label}.out" "${W}/${label}.err" | |
| exit 1 | |
| fi | |
| } | |
| # assert_grep <pattern> <file> <why this matters> | |
| assert_grep() { | |
| if ! grep -q "$1" "$2"; then | |
| echo "::error::liveness assert failed — expected /$1/ in $2. $3" | |
| dump "$2" | |
| exit 1 | |
| fi | |
| echo " ok: /$1/ present in $(basename "$2")" | |
| } | |
| # assert_same <a> <b> <why> | |
| assert_same() { | |
| if ! cmp -s "$1" "$2"; then | |
| echo "::error::behavior parity failed: $3" | |
| echo "--- diff $1 vs $2 ---" | |
| diff -u "$1" "$2" | head -60 || true | |
| exit 1 | |
| fi | |
| echo " ok: $(basename "$1") == $(basename "$2")" | |
| } | |
| echo "::group::spike.ts — textual baseline" | |
| run spike_text -- "$BIN" "$SRC" -o "${W}/spike_text" | |
| run spike_text_exec -- "${W}/spike_text" | |
| cp "${W}/spike_text_exec.out" "${W}/text.out" | |
| echo "::endgroup::" | |
| echo "::group::spike.ts — PERRY_LLVM_INPROCESS=native" | |
| run spike_native -- env PERRY_LLVM_INPROCESS=native "$BIN" "$SRC" -o "${W}/spike_native" | |
| assert_grep "in-process LLVM backend active" "${W}/spike_native.err" \ | |
| "The native path must announce itself; without the banner this arm would pass while silently serving the textual backend." | |
| run spike_native_exec -- "${W}/spike_native" | |
| assert_same "${W}/text.out" "${W}/spike_native_exec.out" \ | |
| "the natively-constructed module behaves differently from the textual one" | |
| echo "::endgroup::" | |
| echo "::group::spike.ts — PERRY_LLVM_INPROCESS=diff" | |
| run spike_diff -- env PERRY_LLVM_INPROCESS=diff "$BIN" "$SRC" -o "${W}/spike_diff" | |
| assert_grep "ir-diff. OK" "${W}/spike_diff.err" \ | |
| "The object-byte verdict is the whole point of diff mode; no verdict means nothing was compared." | |
| echo "::endgroup::" | |
| echo "::group::batch.ts — diff mode across 3 codegen units" | |
| run batch_diff -- env PERRY_LLVM_INPROCESS=diff PERRY_CODEGEN_UNITS=3 \ | |
| "$BIN" benchmarks/app-patterns/kernels/batch.ts -o "${W}/batch_diff" | |
| assert_grep "ir-diff. OK.*3 units" "${W}/batch_diff.err" \ | |
| "The multi-unit split must be exercised; a single-unit verdict here would be a narrower test wearing the same name." | |
| echo "::endgroup::" | |
| # #7302: exception handling. try/catch lowers to invoke/landingpad | |
| # with a personality on the define, so a reader that cannot build | |
| # those forms silently loses every try-containing module to the | |
| # textual path — which is exactly how this arm went red once the | |
| # EH migration landed. Assert the EH program takes the native path | |
| # AND behaves identically. | |
| echo "::group::EH program — textual, native, diff" | |
| run eh_text -- "$BIN" "$EH" -o "${W}/eh_text" | |
| run eh_text_exec -- "${W}/eh_text" | |
| run eh_native -- env PERRY_LLVM_INPROCESS=native "$BIN" "$EH" -o "${W}/eh_native" | |
| assert_grep "in-process LLVM backend active" "${W}/eh_native.err" \ | |
| "A try-containing module must take the native path, not fall back to text (#7302)." | |
| run eh_native_exec -- "${W}/eh_native" | |
| assert_same "${W}/eh_text_exec.out" "${W}/eh_native_exec.out" \ | |
| "exception-handling behavior differs between the textual and native backends" | |
| run eh_diff -- env PERRY_LLVM_INPROCESS=diff "$BIN" "$EH" -o "${W}/eh_diff" | |
| assert_grep "ir-diff. OK" "${W}/eh_diff.err" \ | |
| "The EH module must reach a byte verdict, not be skipped by the differ." | |
| echo "::endgroup::" | |
| echo "native-mode smoke: all arms exercised and green" | |
| # ── Fan-in verdict ───────────────────────────────────────────────────────── | |
| # | |
| # #7971: WITHOUT this job the workflow reported `success` on a PR where | |
| # `changes=success, native-backend=skipped` — i.e. green while executing | |
| # nothing. Three sampled PR "successes" (31505530279, 31499833415, | |
| # 31476724152) were all of that shape. That is CLAUDE.md's fourth way a gate | |
| # cannot fail, and the most dangerous one, because the job is genuinely green. | |
| # | |
| # A path filter is a COST control, not a verdict. This job separates the two: | |
| # it re-states in the log and the summary whether the backend was actually | |
| # exercised, so "llvm-inprocess ✓" can no longer be read as "the in-process | |
| # backend passed" when nothing ran. | |
| # | |
| # It can fail, in two directions that matter: | |
| # * `native-backend` failed or was cancelled -> red. | |
| # * `native-backend` was SKIPPED on a non-PR event -> red. The scheduled | |
| # sweep, a release tag and a manual dispatch all set `relevant=true` | |
| # unconditionally; if one of them ever skips, the post-merge anchor has | |
| # silently stopped anchoring, which is exactly how #7856 starved this | |
| # gate for eight days without anyone noticing. | |
| llvm-inprocess-complete: | |
| needs: [changes, native-backend] | |
| if: (always()) && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Verdict | |
| env: | |
| CHANGES: ${{ needs.changes.result }} | |
| BACKEND: ${{ needs.native-backend.result }} | |
| RELEVANT: ${{ needs.changes.outputs.relevant }} | |
| EVENT: ${{ github.event_name }} | |
| run: | | |
| set -uo pipefail | |
| echo "changes=${CHANGES} relevant=${RELEVANT} native-backend=${BACKEND} event=${EVENT}" | |
| if [ "${CHANGES}" != "success" ]; then | |
| echo "::error::the relevance filter itself did not succeed (${CHANGES});"\ | |
| "no statement can be made about the in-process backend." | |
| exit 1 | |
| fi | |
| case "${BACKEND}" in | |
| success) | |
| echo "EXERCISED: the in-process LLVM backend ran and passed." | |
| echo "✅ **EXERCISED** — in-process LLVM backend ran and passed." \ | |
| >> "$GITHUB_STEP_SUMMARY" | |
| ;; | |
| skipped) | |
| if [ "${EVENT}" != "pull_request" ]; then | |
| echo "::error::native-backend was SKIPPED on a ${EVENT} run."\ | |
| "Every non-PR event sets relevant=true unconditionally, so this"\ | |
| "means the post-merge anchor has stopped anchoring (#7856/#7971)." | |
| exit 1 | |
| fi | |
| echo "NOT EXERCISED: no IR-affecting path changed, so the backend did not run." | |
| echo "This run asserts NOTHING about the in-process LLVM backend." | |
| { | |
| echo "⚠️ **NOT EXERCISED** — no IR-affecting path changed in this PR." | |
| echo "" | |
| echo "The in-process LLVM backend did **not** run. This green result" | |
| echo "is a statement about relevance, not about the backend." | |
| echo "The post-merge sweep of \`main\` is what anchors this gate." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| ;; | |
| *) | |
| echo "::error::native-backend concluded '${BACKEND}'." | |
| echo "❌ **${BACKEND}** — in-process LLVM backend did not pass." \ | |
| >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| ;; | |
| esac |