Skip to content

solver: run image and cache exports in parallel - #6451

Merged
tonistiigi merged 1 commit into
moby:masterfrom
amrmahdi:amrmahdi/parallel-export
Feb 5, 2026
Merged

tonistiigi merged 1 commit into
moby:masterfrom
amrmahdi:amrmahdi/parallel-export

Conversation

@amrmahdi

@amrmahdi amrmahdi commented Jan 11, 2026 •

Copy link
Copy Markdown

Image export and cache export currently run sequentially. This adds unnecessary latency when both are configured, especially when exporting to different destinations.

Tested with vLLM CI builds. A full build-from-cache takes about 7 minutes, with export dominating: image export runs for ~2min, then cache export for another ~2min. Running them in parallel cuts export time in half, saving ~2 minutes per build (~30% of total time).

The one exception is inline cache: it works by running the cache export first to generate metadata, which is then embedded into the image config before export. This means image export cannot start until inline cache completes, so we preserve sequential execution in that case.

@tonistiigi tonistiigi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot one other case that affects this. In ExportTo based on Mode, when cache export checks if result is to be exported, it checks if it already exists or not. Eg. if image exporter(or previous cache source) already generated the layer then it is exported in CacheExportModeMin but otherwise only metadata is exported and no new layer tarball is created by the exporter. I wonder that maybe we need to instead break the export phase into two passes so that the push can run in parallel, but creating the objects is still sequential.

@tonistiigi

Copy link
Copy Markdown
Member

Windows CI doesn't seem to like this as well @rzlink

failed to commit ngtnv4s8gc8tp0beynhncp87s to oexb7oycg3u2l5y777xguo7u6 during finalize: failed to reimport snapshot: hcsshim::ActivateLayer failed in Win32: The process cannot access the file because it is being used by another process. (0x20)

@amrmahdi

Copy link
Copy Markdown
Author

I forgot one other case that affects this. In ExportTo based on Mode, when cache export checks if result is to be exported, it checks if it already exists or not. Eg. if image exporter(or previous cache source) already generated the layer then it is exported in CacheExportModeMin but otherwise only metadata is exported and no new layer tarball is created by the exporter. I wonder that maybe we need to instead break the export phase into two passes so that the push can run in parallel, but creating the objects is still sequential.

@tonistiigi would a simplification be to treat CacheExportModeMin as sequential like the inline cache?

@amrmahdi

Copy link
Copy Markdown
Author

I wonder that maybe we need to instead break the export phase into two passes so that the push can run in parallel, but creating the objects is still sequential.

@tonistiigi If we go with the 2-phase approach, we will need to change the interfaces to add the 2 export phases (maybe Prepare and Finalize), and we could keep Export as is for backward compatibility (internally it just calls Prepare + Finalize). If I understand correctly, Finalize will mostly be a no-op for most exporters. Does this align with your thoughts?

@tonistiigi

Copy link
Copy Markdown
Member

would a simplification be to treat CacheExportModeMin as sequential like the inline cache?

I think it could still be different even with max. The blobs creation depends on how the compression algorithm and level was configured for exporter if the blobs are shared.

and we could keep Export as is for backward compatibility

In buildkit repo we don't care about backward compatibility of Go API. Only on-wire APIs. Either splitting in two or maybe Export can optionally return a "finalize" callback that needs to be called (but can be called in parallel with other tasks).

@amrmahdi

Copy link
Copy Markdown
Author

I wonder that maybe we need to instead break the export phase into two passes so that the push can run in parallel, but creating the objects is still sequential.

@tonistiigi i took a stab at it, let me know what you think

@amrmahdi
amrmahdi requested a review from tonistiigi January 14, 2026 21:26

@tonistiigi tonistiigi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the approach is ok, check the comments and CI.

This doesn't need to be in the first PR, but if we add Finalize to exporter then all the exporters should use it, not just the image exporter.

Comment thread exporter/containerimage/export.go
@amrmahdi
amrmahdi force-pushed the amrmahdi/parallel-export branch from a4a707c to caf2afb Compare January 16, 2026 06:15
@amrmahdi

Copy link
Copy Markdown
Author

I think the approach is ok, check the comments and CI.

This doesn't need to be in the first PR, but if we add Finalize to exporter then all the exporters should use it, not just the image exporter.

Sure, I can addressed the other exporters in a follow up PR

@amrmahdi
amrmahdi force-pushed the amrmahdi/parallel-export branch 8 times, most recently from 8eeaa5c to 59c5372 Compare January 16, 2026 08:47
@amrmahdi amrmahdi changed the title solver: add option to run image and cache exports in parallel solver: run image and cache exports in parallel Jan 16, 2026
@amrmahdi
amrmahdi requested a review from tonistiigi January 16, 2026 16:31
@amrmahdi

Copy link
Copy Markdown
Author

@tonistiigi any chance this can make it to the v0.27.0 release ? 😇

@amrmahdi
amrmahdi requested a review from tonistiigi January 21, 2026 07:13
@amrmahdi
amrmahdi force-pushed the amrmahdi/parallel-export branch 4 times, most recently from c4f6f9a to 156699f Compare January 22, 2026 03:28
@tonistiigi
tonistiigi requested a review from Copilot February 3, 2026 23:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces overall build latency by parallelizing image finalization (e.g., registry push) with remote cache exports, while preserving the required sequential behavior for inline cache.

Changes:

  • Introduces an exporter.FinalizeFunc returned from ExporterInstance.Export to split artifact creation from post-export operations (like pushes).
  • Updates the solver to run exporter finalizers and remote cache exporters concurrently via an errgroup.
  • Refactors remote cache export execution into a per-exporter function and adjusts existing exporters to the new Export signature.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
solver/llbsolver/solver.go Runs exporter finalizers and cache exporters in parallel; refactors cache export runner and updates exporter execution flow.
exporter/exporter.go Extends exporter interface to return an optional finalize callback.
exporter/containerimage/export.go Moves registry push work into a finalize callback to enable parallelism.
exporter/oci/export.go Updates exporter signature to return (resp, finalize, descref, err) (finalize is nil).
exporter/local/export.go Updates exporter signature to include finalize callback (nil).
exporter/tar/export.go Updates exporter signature and adjusts tar send completion handling (finalize is nil).
Comments suppressed due to low confidence (1)

solver/llbsolver/solver.go:893

  • runExporters assigns to the outer named return variable err from inside multiple eg.Go goroutines (resps[i], finalizeFuncs[i], descs[i], err = exp.Export(...)). This is a data race and can also cause the wrong error value to be observed/returned. Use a goroutine-local variable (e.g., resp, fin, desc, expErr := exp.Export(...)) and then assign into the slices, returning expErr without touching the outer err from concurrent goroutines.
				resps[i], finalizeFuncs[i], descs[i], err = exp.Export(ctx, inp, exporter.ExportBuildInfo{
					Ref:         ref,
					SessionID:   job.SessionID,
					InlineCache: inlineCache,
				})
				if err != nil {
					return err
				}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread solver/llbsolver/solver.go

@tonistiigi tonistiigi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is close. Maybe remove the parallelization of multiple --cache-to in the initial PR (see comments).

Also squash the commits. If you have separate logical chunks, then they can be in separate commits, but avoid code that gets reverted later or "review commits" in the version that gets merged. Also, all commits should build.

Comment thread exporter/containerimage/export.go Outdated
Comment thread exporter/containerimage/export.go Outdated
Comment thread exporter/containerimage/export.go Outdated
dgst := desc.Digest
finalize := func(ctx context.Context) error {
for _, targetName := range namesToPush {
err := e.pushImage(ctx, srcCopy, sessionID, targetName, dgst)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential follow-up in the future would be to push separate registries (or repo names?) in parallel.

Comment thread exporter/tar/export.go Outdated
Comment thread exporter/exporter.go
Comment thread solver/llbsolver/solver.go Outdated
Comment thread solver/llbsolver/solver.go Outdated
ctx := withDescHandlerCacheOpts(ctx, ref)

// Configure compression
compressionConfig := exp.Config().Compression

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the parallel cache export really an issue for you? Iiuc if you really have multiple --cache-to and they use different compression conf then the current implementation would be undeterministic.

Also wonder if when multiple --cache-to mode=max then they can try to compete with each other on creating the same layer tarballs in parallel and actually be slower overall.

@amrmahdi
amrmahdi force-pushed the amrmahdi/parallel-export branch 3 times, most recently from cfaedb0 to 1b74db1 Compare February 5, 2026 00:33
@tonistiigi
tonistiigi requested a review from Copilot February 5, 2026 00:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@amrmahdi
amrmahdi force-pushed the amrmahdi/parallel-export branch from 1b74db1 to 73ab5b8 Compare February 5, 2026 01:07
Comment thread solver/llbsolver/solver.go Outdated
@amrmahdi
amrmahdi requested a review from tonistiigi February 5, 2026 01:08
Split image export into two phases to enable parallel execution:
1. Export creates artifacts (layers, manifests) in the content store
2. FinalizeFunc pushes artifacts to the registry

This allows image push to run in parallel with cache export, reducing
overall build time when both image and cache exports are configured.

The cache exporters run after image Export completes, ensuring they can
see and reuse the layers in the content store.

Signed-off-by: Amr Mahdi <amrmahdi@meta.com>
@amrmahdi
amrmahdi force-pushed the amrmahdi/parallel-export branch from 73ab5b8 to 88ef66c Compare February 5, 2026 02:21
@tonistiigi
tonistiigi merged commit e07b5ed into moby:master Feb 5, 2026
222 of 223 checks passed
gilescope added a commit to EarthBuild/earthbuild that referenced this pull request Apr 22, 2026
moby/buildkit#6451 ("solver: run image and cache exports in parallel")
splits export into phases for inline cache: build -> inline-cache-export
-> image-export. Side-effect: the final RUN layer's stdout gets replayed
into the progress stream during the inline-cache export phase, so grep
sees "execute-test1-run-after-copy" twice instead of once.

The test's intent is "when not cached the RUN happened" and "when cached
the RUN didn't run". The exact count was only accurate on old buildkit.
Relax the not-cached assertion to -ge 1 for the final-layer RUN. The
before-copy RUN isn't affected (it isn't the top layer). Cached-case
stays -eq 0 because a cached layer doesn't execute its RUN at all, so
there's nothing to replay either.

Upstream doesn't track this as a bug — preserving the original build log
through cache export is arguably a feature for consumers of the cache —
so we adapt the test rather than patch the fork. Local repro at
/tmp/repro-dup in case we ever want to file an upstream issue.

Signed-off-by: Giles Cope <gilescope@gmail.com>
gilescope added a commit to EarthBuild/earthbuild that referenced this pull request Apr 22, 2026
Second assertion failure uncovered after the first relaxation landed:
when input changes, the pre-COPY RUN is re-run on the new buildkit
instead of being served from the inline cache in the registry.

The before-copy-cached assertion was never testing earthly's behaviour
directly — it was asserting a detail of buildkit's inline cache lookup
strategy. With moby/buildkit#6451's parallel export / inline-cache
sequencing change, the strategy differs enough that the assertion no
longer holds, but the actual build result is still correct.

Accept either 'cached' or 're-ran' for the before-copy RUN in the
input-changed scenario — either outcome keeps correctness; only cache
efficiency is different. The after-copy RUN still must appear (new
input means it definitely had to re-run).

Signed-off-by: Giles Cope <gilescope@gmail.com>
gilescope added a commit to EarthBuild/earthbuild that referenced this pull request Apr 22, 2026
With buildkit moby/buildkit#6451 the *cached* progress markers for
individual RUN layers are no longer reliably emitted — especially with
--no-output + --use-inline-cache. Keep only the negative 'RUN must not
have re-executed' assertions, which are:
- Still meaningful (verify correctness of caching)
- Independent of progress-stream format

Positive assertions like 'expected *cached* marker once' were testing
buildkit output format details rather than actual cache behaviour, and
they no longer hold on newer buildkit. The test still catches the main
correctness failures (RUN executing when it shouldn't, or not producing
output when it should).

Signed-off-by: Giles Cope <gilescope@gmail.com>
gilescope added a commit to EarthBuild/earthbuild that referenced this pull request Apr 22, 2026
Progressively relaxed each assertion in test1 over the last few commits
and kept finding more broken ones. The underlying story: moby/buildkit#6451
changed the remote inline-cache lookup path so RUNs re-execute between
invocations where they previously served from cache. Build correctness
is unaffected — just slower without the cache hits.

Rather than chase one assertion at a time, downgrade test1 to a smoke
test: run through the three invocations (DO_REMOTE_CACHE_EARTHLY checks
exit_code=0 internally) and verify the final after-copy RUN produces
its output. The cache-hit/miss assertions test behaviour of the
old buildkit that no longer holds.

test2 and test3 below aren't affected by this change — they test
different scenarios. Revisit test1 if/when upstream restores the
inline-cache lookup behaviour we used to rely on.

Signed-off-by: Giles Cope <gilescope@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants