Repository navigation
Build once and distribute, instead of every shard building for itself #14
Description
Every shard currently runs julia-buildpkg for itself, so an N-shard run instantiates and precompiles the same project N times. The obvious fix is to build once and hand the result to the children.
The distinction that decides whether this is worth it
Redundant precompilation across shards is concurrent. Eight shards each spending 50 s precompiling costs 400 runner-seconds but only 50 s of wall clock. Building once and distributing serialises that work:
today: precompile ∥ precompile ∥ … → test = precompile + max_bin
build-once: build → distribute → test = build + download + max_bin
So on wall clock this is neutral at best and usually slightly worse. On runner-seconds it is a large win — Nx precompile becomes 1x.
Writing this down because it is the kind of claim that otherwise gets argued from intuition each time it comes up. If the goal is a faster PR, this is not the lever. If the goal is cheaper CI, it is a good one.
Where it does buy wall clock
When shards start staggered — see #8 — a late starter that can restore a prebuilt depot is cheap instead of paying full startup. Build-once and work-stealing compose; neither substitutes for the other.
Options
| approach | effect | cost |
|---|---|---|
| Prebuild job uploading the depot as an artifact, shards restore it | Nx precompile becomes 1x | serialises the build; a large artifact transferred N times |
julia-actions/cache with an explicit depot input |
same cache, one location | still restores per job |
| Sysimage via PackageCompiler | biggest cut to per-shard startup | slow to build, and must match the --check-bounds setting or Julia will not use it (see #10) |
| Persistent depot on a self-hosted pool | already does this | why the private repos' fixed cost differs from the hosted ones |
Prerequisite
Do not start here. #10 has to establish where the fixed cost actually goes first — there is a live hypothesis there that --check-bounds=yes makes the test step recompile a stack julia-buildpkg already precompiled under different flags. If that holds, it is a larger lever than distributing a build, and cheaper to act on.
TODO
- Settle Confirm the shared depot cache actually lowered the per-shard fixed cost #10 first: measure a warm steady-state run and find where the per-shard startup actually goes.
- Only if precompilation is still a significant share: prototype a prebuild job with a depot artifact, behind an input.
- Report both wall clock and runner-seconds in the comparison. Reporting one without the other is how this decision gets made wrongly.
Activity
sotashimozono commentedon Jul 27, 2026
Reversing my earlier recommendation on this issue
Earlier in #10 I recommended closing this as measured-not-worth-building. That measurement was taken on this repository — one dependency,
julia-buildpkgat 3–4s — and I said at the time it needed re-checking against a heavy consumer. Here is that check, and it goes the other way decisively.QAtlas.jl, 16 shards, measured on three consecutive
mainrunsInside the
julia-runteststep of a single shard:QAtlas itself: 219 s. Every run. Every shard. Confirmed across runs: 220 s, 211 s, 220 s — all on a warm cache hit (
Cache restored from key: shard-s01;...), with 60–61 dependencies correctly served from it.So roughly 35% of QAtlas's CI is one package being precompiled sixteen times.
Why the cache does not help
The dependencies are cached and restored (60 of 65). What is not is the package under test, precompiled for the coverage configuration — a different configuration from the one
julia-buildpkgproduced, and rebuilt from the workspace checkout each run. The 219 s is@compile_workload; see the note in the QAtlas memory that 98% of its precompile time is the workload block.That is exactly the shape this issue proposes to fix: build the artifact once, distribute it to the shards, rather than having each shard rebuild it.
Where that leaves the gate
The gate on this issue was "measure it on a repository whose
buildpkgis large". Measured:Reopening the direction. Related: #29 (the org's runner budget is shared, so 3,440 s of avoidable work in one repo is taken from every other repo's queue) and #19 (coverage cost per shard), which is the same configuration boundary seen from the other side.
One caveat worth stating: whether a coverage-instrumented precompile artifact can be moved between jobs at all is not established here. This measurement says the prize is large; it does not say it is collectable.
sotashimozono commentedon Jul 28, 2026
The gate is passed: precompiled output DOES transfer between jobs
That was the one thing that could have killed this direction, and it was genuinely open — in #19 two shards in the same run rejected a byte-identical cached artefact from each other, cause still unknown. If
.jifiles did not survive a move between machines, no amount of arithmetic about the prize would have mattered.Measured on this repository, same branch, same shard count,
prebuildthe only variable:s1logprebuild: falsePrecompiling for configuration --code-coverage=@…→1 dependency successfully precompiled in 1 seconds. 31 already precompiled.prebuild: trueThe shards adopted a cache built in a different job, on a different machine, and skipped precompilation entirely.
The cost of adopting it is negligible
Per shard, averaged over four:
download-artifactOn this suite it is a net loss, exactly as predicted
prebuild: falseprebuild: trueThe prebuild job costs ~24 s plus its own job overhead, to save ~1 s of precompilation on each of four shards. Predicted before running: a loss here, a win at scale. It is a loss here.
The wall clock figures from these two runs are not usable — 522 s against 223 s, while the shard durations were 85 s and 88 s. That is the queue spread, not the strategy, and reading it as a result would repeat the mistake this repository has already documented twice.
Extrapolation, and what remains unmeasured
At QAtlas.jl's numbers (~219 s of precompilation inside every one of sixteen shards):
Two things are not established and should be before this is turned on there:
~/.julia/compiledfor a package with one dependency. QAtlas has sixty-odd, and the transfer is paid once per shard. If it is 400 MB the adoption cost stops being 1.8 s. Shipping only the delta over the depot cache is possible and not implemented.Shipped in #36 as
prebuild, off by default.sotashimozono commentedon Jul 29, 2026
(a) is answered, and the premise it rested on was wrong
This issue and the handoff both assumed QAtlas has "sixty-odd dependencies", which is what made
artifact size look like a risk. It has 4 direct deps, no
[sources], and the prebuild stepinstantiates
--project=.— not the test env — so the eight[extras]never enter. Measured byreproducing the prebuild step verbatim (same command, same flags) in an isolated
JULIA_DEPOT_PATHagainst a fresh resolve, sinceManifest.tomlis gitignored there:compiled.tgzcompiled/unpacked~25 MB × 16 shards ≈ 400 MB a run. Not the transfer that was feared. Two limits worth stating:
CI's artifact will exceed this, because the job runs
julia-actions/cache@v3and then tars allof
~/.julia/compiled, so restored default-configuration entries ride along — which raises theseparate question of whether the step should tar only what it built. And per-shard adoption cost
at this size is not measured; scaling 1.8 s on 4.4 MB by size gives ~10 s, but that is arithmetic.
What actually needs establishing: the bridge, not the trade
The interesting thing about
prebuildis not whether it pays on a given suite — that is thecaller's configuration to choose and keep. It is that it carries a build across environments,
and what makes that valid is not yet written down.
Two properties the mechanism depends on, neither currently checked at runtime:
The flags have to match. The prebuild job precompiles under
--check-bounds=yes --code-coverage, deliberately, because that is a different precompile configuration fromjulia-buildpkg's and produces different cache files. A consumer whose shards run under any otherconfiguration gets an artifact that is transferred and then ignored. Worth noting how invisible
this is: the expensive precompilation lives inside the test step, not in
buildpkg— QAtlas'sbuildpkgstep is 6 s while ~219 s of its 680 s test step is compiling — so a maintainer readingstep durations would not see the cost move, or fail to move.
The paths have to match. Dependencies live under
$HOME/.julia/packages, which is the samelocation in every job. The package under test is compiled from
$GITHUB_WORKSPACE, which isidentical on hosted runners (
/home/runner/work/<repo>/<repo>) but carries the runner's own nameon a self-hosted pool (
.../actions-runners/<runner>/_work/<repo>/<repo>). The gate in #36 waspassed on
ubuntu-latest— this repository's CI passes norunnerinput — while the consumerexample in the workflow header is
runner: '["self-hosted","rosina"]'. If Julia rejects a cachewhose source path moved, then on a self-hosted pool the artifact would transfer and the package's
own
.jiwould be rebuilt anyway — and for a suite like QAtlas that.jiis 99% of the cost.I am measuring the second one rather than asserting it; I will post the result either way. The
first is checkable by construction.
And the adopt step cannot tell. It is
tar -C "$HOME/.julia" -xzf compiled.tgzfollowed byecho "Adopted the shared compiled cache."— it reports success for having unpacked a file, notfor the cache being used. Adoption was confirmed for #36 by reading a shard's log for the absence
of a precompile line. That is the property worth making the mechanism state about itself, since a
silently-rejected cache costs the transfer and returns nothing.
(An earlier revision of this comment proposed decomposing
Diagnosis.fixedso the package couldreport whether
prebuildpays. Withdrawn — choosing the configuration is the caller's, and thepackage should not abstract over which option is optimal.)
sotashimozono commentedon Jul 29, 2026
Measured: the bridge carries the dependencies and drops the package
Promised above, so here it is either way — it went the way the hypothesis feared.
Three arms against one prebuilt depot (QAtlas,
--check-bounds=yes --code-coverage, isolatedJULIA_DEPOT_PATH). Trees are byte-identical and copied withcp -a, so timestamps arepreserved and the only variable is location:
✓ QAtlasrebuilt✓ QAtlasrebuiltThe third arm is the disambiguation: with the
--code-coverage=@dirargument held at the valuethe cache was built under, a moved project path still forces a rebuild. The invalidator is the
absolute source path, not the coverage argument.
Every arm reported
17 already precompiled. So the split is clean, and it is structural ratherthan incidental:
$HOME/.julia/packages, the same location inevery job on every machine.
$GITHUB_WORKSPACE.$GITHUB_WORKSPACEis/home/runner/work/<repo>/<repo>on hosted runners — identical on everymachine, which is why the #36 gate passed: this repository's CI passes no
runnerinput and runson
ubuntu-latest. On a self-hosted pool it is.../actions-runners/<runner>/_work/<repo>/<repo>and carries the runner's own name, so a prebuild job on one runner and a shard on another do
not agree. The workflow header advertises exactly that configuration:
runner: '["self-hosted","rosina"]'.The consequence is the bad one. For a suite like QAtlas the package's own
.jiis 99% of theprecompilation (521.6 s of 527 s). So on a self-hosted pool the artifact would be built,
uploaded, downloaded once per shard — and then the expensive half rebuilt anyway.
There is a second-order twist on the pool this workflow points at: the nine
panza-*runners runas one user with no
JULIA_DEPOT_PATHoverride, so they already share/home/souta/.julia(17 GB of
compiled). The half of the artifact that can cross is a half that machine alreadyhad; the half that would pay for itself is the half that cannot.
What this says about the mechanism
Not that
prebuildshould be on or off anywhere — that is the caller's configuration to pick andkeep. It is that the bridge has a precondition it does not state and cannot check: the
workspace path must agree between the job that builds and the job that consumes. Hosted runners
satisfy it by construction, which is precisely why it was invisible.
And the adopt step cannot notice when it fails. It is:
which reports success for having unpacked a file. A rejected cache costs the full transfer and
returns nothing, silently. Given this package's rule — say what the constraint is, do not resolve
it — the mechanism should be able to state whether the bridge held, rather than leaving it to be
confirmed by reading a shard's log for the absence of a precompile line.
Environment: Julia 1.12 locally, matching the
julia-version: '1'the sharded workflow defaultsto. Not re-derived on CI.
sotashimozono commentedon Oct 14, 2026
Hermes の整理(自動。何も変更していません)
状態: 要求「build once and distribute」は
prebuildinput として出荷済みです (#36, 2026-07-28 merge、default false)。#39 (2026-07-29) で「hosted runner 専用」の説明も入りました。論点: 2026-07-29 の計測 (コメント 3 件目) では、依存は job 間で使えるが、被試験 package は
$GITHUB_WORKSPACEの絶対 path のため self-hosted では使えない、でした。その後Adopt the precompiled cachestep はtar -xzfの後に "Adopted the shared compiled cache." と出すだけで、bridge が実際に効いたかを検査していません (main の該当 step で確認)。コメントが「mechanism 自体が成否を述べるべき」とした点は未実施です。close 候補(提案のみ。閉じていません): 本来の依頼は #36 / #39 で実現済みで、残りは adopt step の自己検査 (と、tar に入れる範囲を built 分に絞るか) の 2 点です。それらを別 issue にして本 issue を閉じる形が考えられます。確度 medium。要確認: 残り 2 点を本 issue に残すかはオーナー判断です。
関連: #36, #39 (出荷した PR), #10 (CLOSED, 前提の計測), #60 (依存だけ共有する案の根拠), #72 (depot cache)。