Skip to content

docker: build and publish a multi-arch image (#548) - #552

Merged
ohohoreilly merged 9 commits into
masterfrom
feat/548-multiarch-image
Sep 16, 2026
Merged

ohohoreilly merged 9 commits into
masterfrom
feat/548-multiarch-image

Conversation

@ohohoreilly

Copy link
Copy Markdown
Member

Closes the last open box on #548 — the one the whole thread started from.

Why

The published image is linux/amd64 only, so an Apple Silicon user gets a platform-mismatch warning on the first command in the README. That is the audience the MCP/agent work is aimed at. #550 established the code builds and passes its full test suite on aarch64 (1753 build jobs, 203 test binaries, zero failures), which is what makes this possible at all.

Native runners, not buildx + QEMU

The reflex here is docker/setup-qemu-action + buildx --platform linux/amd64,linux/arm64. That would be wrong for this image.

This image compiles the whole source tree — 1753 build jobs, ~10 minutes natively. Under emulation that is plausibly hours and would exceed the 90-minute timeout. So: ubuntu-24.04 and ubuntu-24.04-arm, each building its own architecture natively, in parallel, at roughly x86 wall time.

That choice pays a second dividend I'd argue is the bigger one. Every gate already in this workflow now runs on the arm image:

  • MCP smoke against the containerised server (install / new_pov / call / call_batch / error)
  • in-container orlyc compile to a loadable .so
  • REPL entrypoint mode

So the published arm64 artifact is proven to work, not merely to compile. An emulated cross-build would have given us a binary nobody had ever run.

Publishing

Two stages. Each leg pushes an arch-suffixed tag; a manifest job stitches them with imagetools create.

  • Arch tags stay pullable on purpose. They are the manifest's inputs, and they are exactly what you want when one architecture misbehaves and the other doesn't. The alternative (push-by-digest + artifact passing) is tidier in the registry and more moving parts in the workflow; I took the debuggable one.
  • The manifest job requires both legs to pass. A manifest pointing at an image that failed its own smoke would be worse than no manifest.
  • It verifies rather than assumes. The last step inspects the published manifest and asserts it advertises both amd64 and arm64 — that is the actual claim being made, so it should be checked, not hoped for.
  • fail-fast: false on the matrix, because "arm broke, x86 is fine" is the useful signal and fail-fast hides it.

The Dockerfile needed no changes

Worth stating since it is the obvious place for trouble: both stages are ubuntu:24.04 (multi-arch official), every dependency is an apt package by name, nothing downloads a prebuilt binary, and node comes from Ubuntu's repo. No amd64, no x86_64, no uname -m anywhere in it.

This does not change the published image by itself

Merging only changes how it is built. The registry still serves amd64-only until someone runs the workflow via dispatch or pushes a v* tag.

So the README is deliberately untouched here. It currently says the published image is linux/amd64 and runs under emulation on Apple Silicon — which stays true until that first multi-arch publish. Updating it now would be documenting something that has not happened, and this repo has history with that particular failure mode.

Follow-up once a multi-arch image is actually live: drop the emulation caveat from the Quick start section and the "the one the published docker image targets" qualifier under Supported platforms.

Testing

This PR touches .github/workflows/docker.yml, which is in the workflow's own paths filter — so the docker workflow runs on this PR and builds both architectures with all gates, no push. That is the test.

@ohohoreilly ohohoreilly self-assigned this Sep 15, 2026
The published image was amd64-only, so every Apple Silicon user -- most of the
audience the MCP work targets -- got a platform-mismatch warning on the very
first command in the README. #550 established the code builds and passes its
full test suite on aarch64, which is what makes this possible.

Native runner per architecture, not buildx+QEMU. This image compiles the whole
source tree: 1753 build jobs, ~10 minutes natively. Emulated that is hours and
would blow the 90-minute timeout. `ubuntu-24.04-arm` builds it natively in about
the same wall time as x86, in parallel.

That choice pays a second dividend: every gate already in this workflow -- the
MCP smoke against the containerised server, the in-container orlyc compile, the
REPL entrypoint mode -- now runs ON the arm image. The published arm64 artifact
is therefore proven to work, not merely to compile, which emulated cross-builds
would not have given us.

Publishing is two stages: each leg pushes an arch-suffixed tag, then a manifest
job stitches them with `imagetools create`. Arch tags are left pullable on
purpose -- they are the manifest's inputs and the thing you want when only one
architecture misbehaves. The manifest job runs only if BOTH legs passed their
gates; a manifest pointing at an image that failed its smoke would be worse than
no manifest.

It then verifies the result rather than assuming it: the published manifest must
advertise both amd64 and arm64, which is the actual claim being made.

Note this does not change the published image by itself -- that needs a dispatch
or a v* tag. README still says the published image is amd64, which stays true
until the first multi-arch publish lands; updating it before then would be
describing something that has not happened.
The arm64 leg was killed at the 90-minute cap, still inside `jhm -c release`.
So it is >90 minutes at 4 workers, and how much over is unknown.

The same runner class does `make debug` in 14 minutes (ci.yml, merged in #550),
so aarch64 is not simply slow. The difference in the Dockerfile is `-c release`
-- an LTO build. LTO is memory-hungry and largely serial at link time, which
makes the leading hypothesis memory pressure rather than CPU: if the arm runner
has less RAM per core, four concurrent LTO jobs thrash and FEWER workers is
faster.

Hence per-arch matrix values: 4 workers / 90 min on x86 (unchanged, it builds in
~10), 2 workers / 180 min on arm. The generous timeout is so a slow-but-working
build is not killed while we are still learning what it actually costs.

That is a hypothesis, not a diagnosis, and the reason the first failure taught
us so little is that the job reported only `uname -m`. It now reports nproc and
free -g as well. "How many cores and how much RAM did that runner have" is the
first question on any slow leg and the one that cannot be answered after the
fact.

If the capacity report shows ample free memory, the hypothesis is wrong and the
next thing to look at is aarch64 LTO codegen time directly.
@ohohoreilly
ohohoreilly force-pushed the feat/548-multiarch-image branch from df1e132 to eb197b1 Compare September 15, 2026 08:17
@ohohoreilly

Copy link
Copy Markdown
Member Author

Correcting a claim in the description above

The PR body says the arm runner "builds it natively in about the same time as x86, in parallel, at roughly x86 wall time." That is wrong, and I am correcting it here rather than quietly editing it, because the reasoning it supported — native runners over QEMU — deserves to be judged against the real numbers.

What I extrapolated from: ci.yml's aarch64 job does make debug in 14m7s against x86's 18m39s. Comparable, slightly faster.

What the Dockerfile actually runs is jhm -c release — an LTO build, which is a different animal:

x86 arm64
docker image job, end to end 10m13s >90 min, killed at the cap

So the arm leg is at least 9x the x86 leg for this build, not "roughly the same".

Does that invalidate the approach?

No, and this is worth being precise about. The alternative was buildx+QEMU, which emulates the arm half. If a native arm LTO build exceeds 90 minutes, an emulated one is categorically worse — this result makes the case for native runners stronger, not weaker. What it invalidates is my estimate of the cost, not the choice.

Where the investigation stands

The same runner class does make debug in 14 minutes, so aarch64 is not simply slow. The delta is LTO: memory-hungry, largely serial at link time. Leading hypothesis is memory pressure — if the arm runner has less RAM per core, four concurrent LTO jobs thrash and fewer workers would be faster.

Latest push therefore does two things: reports nproc and free -g (the first job reported only uname -m, which is why a 90-minute failure taught us almost nothing), and drops arm to 2 workers with a 180-minute cap so a slow-but-working build is not killed while we are still measuring.

That is a hypothesis. If the capacity report shows ample free memory, it is wrong and the next thing to examine is aarch64 LTO codegen time directly.

Not ready to merge

Do not merge this until the arm leg goes green and its cost is understood. A multi-arch manifest whose arm half was never successfully built and smoked would be worse than the amd64-only image it replaces — the current image at least works under emulation, with a warning.

@ohohoreilly

Copy link
Copy Markdown
Member Author

The memory-pressure hypothesis is dead

The capacity report settles it, and not in my favour:

arm64:  nproc 4,  Mem 15GB (13 free),  Swap 2GB (0 used),  workers=2  -> killed at 180 min
amd64:  nproc 4,  Mem 15GB (12 free),  Swap 2GB (0 used),  workers=4  -> 10m13s

The two runners are identical — same cores, same RAM, both with ~13GB free and zero swap used. There was never any memory pressure, so LTO thrash cannot be the explanation.

And halving the workers made it worse, not better: >90 min at 4, >180 min at 2. That is the opposite of what the thrash theory predicts, which is the cleanest possible refutation.

What is actually going on

The remaining explanation is the one I listed as the fallback: aarch64 LTO codegen for this tree is enormously more expensive than x86. At least 18x the x86 leg, and still no upper bound — it has never finished.

Consistent with everything else we know: the same runner class does make debug (no LTO) in 14m7s, slightly faster than x86's 18m39s. So aarch64 is fine at compiling this code. It is specifically link-time optimisation that falls off a cliff.

What I am not going to do

Keep raising the timeout until it passes. Three plausible directions, none of them guesses I should make unilaterally at this point:

  1. Drop LTO for the arm image. jhm -c release without LTO, or a separate config. The image ships a database server; whether LTO is load-bearing for it is a judgement call about the artifact, not a CI detail.
  2. Find out where the time actually goes. A -ftime-report or per-target timing on the arm build would say whether it is one pathological TU or uniformly slow. That is the honest next step and it is cheap.
  3. Ship amd64-only for now and keep the arm work as the ci.yml job, which is green and fast. The multi-arch manifest is desirable, not urgent.

My inclination is (2) before choosing between (1) and (3), because right now nobody knows whether this is one bad translation unit or a systemic aarch64 LTO problem — and those have very different answers.

Status

Not ready to merge. The amd64 leg and every other check are green; the arm leg has never completed. Merging would publish a manifest whose arm half was never built or smoked, which is worse than today's amd64-only image.

…me (#548)

The multi-arch image is blocked on an arm leg that never finishes
`jhm -c release`: >90 min at 4 workers, >180 min at 2, against 10m13s for the
entire amd64 job.

What is already ruled out: resource starvation. Capacity is identical on both
runners (4 cores, 15GB, zero swap used), and halving the workers made it WORSE
rather than better, which is the opposite of what memory thrash predicts. The
same runner class does `make debug` -- no LTO -- in 14m7s, slightly FASTER than
x86, so aarch64 compiles this tree fine.

That leaves one question worth answering before choosing a fix: is the cost
spread across ~1753 compile targets, or concentrated in the LTO link? Dropping
LTO for the image and chasing one pathological translation unit are very
different pieces of work, and picking between them by intuition is how the last
two rounds were wasted.

Two instruments, both cheap:

- jhm's progress output timestamped. It prints [done/total], so the timestamps
  show directly whether the counter crawls (compile-bound) or races to the end
  and then stalls (link-bound).
- a 30s process sampler. If lto1 holds a core for an hour, that settles it.

Runs on BOTH arches so the comparison is like-for-like rather than against a
number remembered from another workflow, uploads both logs as artifacts, and
prints the progress curve even when killed -- a partial run still shows the
shape. workflow_dispatch only: this is expensive and diagnostic, not a gate.
workflow_dispatch is only dispatchable once the workflow exists on the DEFAULT
branch, and this diagnostic has to run before anything merges. Branch-scoped
push trigger, to be removed with the rest of this file once the arm question is
answered.
…as emulated (#548)

The profiling run reframes this entirely. `jhm -c release` on the BARE arm
runner completes in 8m06s -- 884 targets, steady progress, no stall, and faster
than the x86 leg. The same command inside `docker build` on the same runner
class never finished in 180 minutes.

So neither of my two previous explanations survives. It is not memory pressure
(identical 4-core/15GB runners, zero swap, and halving the workers made it
worse) and it is not aarch64 LTO codegen (8 minutes, natively, at 4 workers).
The build is healthy on arm. What is pathological is the build INSIDE docker on
arm, and a >20x gap on identical hardware is the signature of qemu rather than
of slow codegen.

Hence: pass --platform explicitly rather than trusting the daemon default, log
docker's client/server arch, the registered binfmt handlers, and what
ubuntu:24.04 actually resolves to on that runner, and assert after the build
that the image really is the architecture we asked for. If the default was
resolving to amd64, every RUN line in the Dockerfile was going through
emulation, which would explain everything. If it was already correct, pinning
costs nothing and the diagnostics say where to look next.

Workers on arm go back to 4, since the reason they were lowered has been
refuted. The timeout stays generous until a native arm image build has actually
been observed to finish.
The profiling round answered #548's question and produced a better one.
`jhm -c release` is fine on arm: 482s inside docker, 8m06s on the bare runner,
both faster than amd64's 11m05s. What hangs is the NEXT docker step -- compiling
the example packages with the just-built orlyc, which printed its header and
then nothing for 37 minutes before the job was cancelled.

Per the Dockerfile's own comment, each orlyc run stands up and tears down an
embedded mem-sim server, so that is the suspect.

This reproduces it in ~9 minutes rather than a 45-minute docker build: build
orlyc alone, run it on one package with a 300s deadline, and if it is still
alive, interrogate the live process rather than merely noting that it never
returned -- process state, kernel wait channel per thread, and all-thread gdb
backtraces. amd64 runs as the control, since orlyc demonstrably works there.

Replaces the LTO profiler, which has served its purpose.
The first run proved nothing. $GITHUB_WORKSPACE already ends in /orly (it is the
repo root, the Dockerfile's WORKDIR /src/orly), and I appended another, so orlyc
got a doubled path, exited 1 in two seconds on BOTH arches, and the job reported
green.

That is worse than a failed repro: a diagnostic that exits fast and clean while
testing nothing is indistinguishable from good news. So the inputs are now
asserted to exist before anything is timed -- if the path is wrong again the job
says so instead of quietly passing.
…554)

The last run piped gdb through `tail -80`, which cut exactly the frame that
matters -- the worker running the main job. Its stack is what distinguishes
"RunPackageTests never returned" from "server->Shutdown() wedged", and those have
different fixes.

Now: full stacks to a file and uploaded as an artifact, the teardown-relevant
frames grepped inline, and a SECOND snapshot 60s later. Identical stacks across
the two means genuinely wedged; moving frames would mean livelock or very slow
progress, which would be a different bug and worth knowing before assuming
deadlock.
@ohohoreilly
ohohoreilly merged commit 9ecf3ba into master Sep 16, 2026
15 checks passed
@ohohoreilly
ohohoreilly deleted the feat/548-multiarch-image branch September 16, 2026 01:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant