Skip to content

build: publish package archives concurrently - #2245

Closed
zhouguangyuan0718 wants to merge 22 commits into
xgo-dev:mainfrom
zhouguangyuan0718:agent/parallel-publish
Closed

build: publish package archives concurrently#2245
zhouguangyuan0718 wants to merge 22 commits into
xgo-dev:mainfrom
zhouguangyuan0718:agent/parallel-publish

Conversation

@zhouguangyuan0718

@zhouguangyuan0718 zhouguangyuan0718 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2243. Review range: 25c203a2..35b41702.

Summary

  • write cacheable package archives directly to their final cache path, avoiding a temporary archive plus copy
  • consume the build: split package compilation stages #2175 LLVM MemoryBuffer members directly; no LLVM package object is written as a temporary .o
  • preserve best-effort cache semantics by retrying an unwritable cache destination with a temporary .a only
  • publish completed package artifacts with at most min(-p, 2) workers after all LLVM backends succeed
  • keep cache warnings and fatal publication errors deterministic in package order
  • remove build-owned temporary archives when safe, while retaining Darwin DWARF inputs required by later dsymutil
  • emit package publication as llgo.publish worker spans in build traces

Relationship to the archive change

The old publication benchmark was dominated by external ar rereading temporary LLVM objects. That path no longer exists, so those absolute timings and the previous backend/publish-overlap conclusion are not used as evidence for this rebased PR. Pipeline overlap can be reevaluated later; it is not required for the in-memory archive optimization.

Validation

  • go test ./internal/build -count=1
  • focused go test -race ./internal/build -run 'Test(NormalizeToArchive|PublishPackagePipelineTasks|CleanupTemporaryArchives)' -count=1\n- direct-cache test uses a real LLVM bitcode MemoryBuffer\n- clean -a -p=8 cgo + Plan9 build: 78 LLVM archives, 0 external ar, 0 temporary package .a, 0 temporary LLVM package .o; runtime output verified\n- git diff --check

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: concurrent package archive publishing

Solid, carefully-reasoned change. The scheduler in package_pipeline.go centralizes all cross-goroutine mutation in the single coordinator loop (workers only communicate via the events channel), which is the right design and avoids the races a naive worker pool would introduce. Isolated backends get their own LLVM session and a fresh plan9asmSigs map; error/warning reporting is deterministic. Nice.

Findings below are one high-severity concurrency risk plus a few performance/maintainability items. Details are inline; a couple of cross-cutting notes here:

Concurrency (high). See the inline comment on internal/build/plan9asm.go — the reworked pkgSFiles writes into the shared, unsynchronized ctx.sfilesCache on its fast-path branches before the sfilesFrozen guard. During the concurrent backend phase the map is shared by reference across all backend tasks (package_build.go:354), so a cache miss on that path is an unsynchronized concurrent map write → potential fatal error: concurrent map writes. Safety currently rests entirely on preparePackageSFiles pre-populating every package during serial preflight; that invariant is fragile because preparePackageSFiles only covers pkg.Package/pkg.AltPkg.Package, while pkgSFiles is also reached via plan9asmSigsForPkg/compilePkgSFiles for other packages during the concurrent phase. Recommend moving the sfilesFrozen check to the top of pkgSFiles so it is strictly read-only when frozen, or guarding the map with a mutex. A -race build of a repo containing a package with no .s files that isn't pre-populated would exercise this.

Performance (scheduler is O(N²) in package count). Two inline items share one root cause: completed tasks are never pruned from the tasks slice, so both the loop-condition allBackendsDone() and nextBackend()'s isolated scan re-walk every task on every iteration. For the large repos the trace code explicitly anticipates, scheduler overhead grows quadratically. Both are cheap to fix with the counter/frontier pattern the code already uses for ssaDone/normalRemaining/advanceSerial.

Minor / non-blocking (not inlined):

  • internal/build/package_pipeline.go:189-209 nextBackend: when the serial head is a patched coordinator and SSA is done, returning nil while active != 0 also stalls unrelated isolated work until the in-flight set drains to zero. Intentional exclusivity barrier, but wider than necessary and undocumented — a clarifying comment (or narrowing the barrier) would help.
  • internal/build/package_pipeline.go:372 packagePublishFunc returns two bare error values (result, error, error); a doc comment noting the (result, warning, err) order would prevent caller confusion.
  • internal/build/xtool/env/env.go lookPathInEnvironment: the mode & 0o111 executable test is Unix-only and won't find .exe/.bat on a custom Windows PATH, silently falling back to process-global PATH. If Windows is in scope, mirror exec.LookPath; otherwise a comment on the assumption would help.

No blocking issues beyond the concurrent-map risk, which is worth confirming under -race before merge.

}
if v, ok := ctx.sfilesCache[pkg.ID]; ok {
return v, nil
if ctx.sfilesFrozen {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Potential fatal error: concurrent map writes (high). The fast-path branches above (lines 415-424) write ctx.sfilesCache[pkg.ID] = nil before this sfilesFrozen guard. During the concurrent backend phase sfilesCache is shared by reference across all backend tasks (package_build.go:354) with no mutex, and pkgSFiles is reachable from multiple goroutines via compilePkgSFiles/plan9asmSigsForPkg. A cache miss that lands on the "no .s files" fast path is then an unsynchronized concurrent map write, which Go turns into an unrecoverable process crash.

The only thing preventing this today is that preparePackageSFiles pre-populates the cache during serial preflight — but it only covers pkg.Package and pkg.AltPkg.Package, so any package queried via plan9asmSigsForPkg during the concurrent phase that isn't pre-populated will hit the unguarded write.

Suggest moving the sfilesFrozen check to the top of pkgSFiles (return the not-prepared error before any map write, making the frozen path strictly read-only), or guarding sfilesCache with a mutex. Please verify under a -race build.

return true
}

for ssaDone != len(ssaNodes) || !allBackendsDone() || active != 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

allBackendsDone() linearly rescans the entire tasks slice, and it is evaluated in this loop condition on every iteration (plus again at line 308). The loop runs ~once per completed event (O(N) for N packages), so this is O(N²) task.done checks over a full build, on top of the nextBackend() scan below. Completed tasks are never removed from tasks, so they're re-walked every time.

Consider maintaining a running counter of remaining (!done) backend tasks, decremented in the event.task branch — the same pattern already used for ssaDone and normalRemaining. This matters for the large repos the trace code (line ~274) explicitly anticipates.

return task
}
}
for _, task := range tasks {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same O(N²) root cause: nextBackend() scans the full tasks slice for a ready isolated task on every call, ready() additionally iterates each task's ssaDeps, and this is called up to parallelism times per outer iteration. Already-done/running tasks are rescanned every time.

A ready/pending worklist (or a frontier index over isolated tasks, like advanceSerial does for serialOrder) would avoid re-walking finished work.

if serial {
return executePackageBuild(ctx, preflight.spec, verbose)
}
return ctx.executeIsolatedPackage(preflight.spec, verbose)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

executePreflightedPackage is only invoked for the patched partition produced by partitionPackageExecutions, yet it re-invokes ctx.packageRequiresCoordinator(preflight.spec) and re-branches on serial — re-deriving a classification the partitioning already established (and potentially a second packageUsesPlan9Asm filesystem probe). Consider threading the known class through instead of recomputing it here.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

35b417025e7d | workflow run | long-term charts

Program measurements

Platform Workload File size vs main Build vs main Run vs main
Linux cprintf 18544 B new 294.466 ms new 1.289 ms new
Linux fmtprintf 2217544 B new 3.479 s new 2.440 ms new
Linux println 71504 B new 294.580 ms new 1.573 ms new
macOS cprintf 84672 B new 323.061 ms new 2.762 ms new
macOS fmtprintf 2361520 B new 2.925 s new 20.710 ms new
macOS println 125712 B new 331.210 ms new 3.868 ms new
Core language and compiler benchmarks
Platform Benchmark ns/op vs main
Linux BenchmarkLookupPCRandom 13.410 ns/op new
Linux BenchmarkMergeCompilerFlags 149.600 ns/op new
Linux BenchmarkMergeLinkerFlags 94.220 ns/op new
Linux BenchmarkChannelBuffered 34 ns/op new
Linux BenchmarkChannelHandoff 26012 ns/op new
Linux BenchmarkDefer 42.580 ns/op new
Linux BenchmarkDirectCall 1.556 ns/op new
Linux BenchmarkGlobalRead 1.869 ns/op new
Linux BenchmarkGlobalWrite 2.489 ns/op new
Linux BenchmarkGoroutine 31267 ns/op new
Linux BenchmarkInterfaceCall 8.091 ns/op new
Linux BenchmarkRuntimeGetG 1.870 ns/op new
macOS BenchmarkLookupPCRandom 11.660 ns/op new
macOS BenchmarkMergeCompilerFlags 134.700 ns/op new
macOS BenchmarkMergeLinkerFlags 78.050 ns/op new
macOS BenchmarkChannelBuffered 27.720 ns/op new
macOS BenchmarkChannelHandoff 6943 ns/op new
macOS BenchmarkDefer 28.950 ns/op new
macOS BenchmarkDirectCall 1.141 ns/op new
macOS BenchmarkGlobalRead 1.227 ns/op new
macOS BenchmarkGlobalWrite 1.085 ns/op new
macOS BenchmarkGoroutine 25414 ns/op new
macOS BenchmarkInterfaceCall 5.393 ns/op new
macOS BenchmarkRuntimeGetG 2.064 ns/op new

No main baseline exists yet; all metrics are marked new.

Warning

  • The rendered benchmark data could not be pushed.

@zhouguangyuan0718

Copy link
Copy Markdown
Contributor Author

Superseded by the restacked package worker design.

Archive publication is now part of each backend worker in #2182 (8792dab9), and #2242 pipelines that complete backend+archive+cache task directly (c81529ce). There is no longer a separate end-of-build publication sweep or publish worker pool, so this follow-up is no longer needed.

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