Skip to content

build: run LLVM backends in parallel by package - #2182

Open
zhouguangyuan0718 wants to merge 22 commits into
xgo-dev:mainfrom
zhouguangyuan0718:agent/parallel-build-pr8
Open

build: run LLVM backends in parallel by package#2182
zhouguangyuan0718 wants to merge 22 commits into
xgo-dev:mainfrom
zhouguangyuan0718:agent/parallel-build-pr8

Conversation

@zhouguangyuan0718

@zhouguangyuan0718 zhouguangyuan0718 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • complete package preflight before backend work and freeze SFiles/Plan9 policy for worker reads
  • execute eligible package LLVM backends directly under the existing -p bound without dependency-level LLVM barriers
  • give every worker an independent backend session and the invocation-local commandEnv
  • keep source-patched and Plan9 asm packages on the coordinator while other packages run concurrently
  • preserve serial compatibility paths for generation mode, C archive/shared builds, and ModuleHook users
  • complete each backend, in-memory archive write, and cache metadata commit in the same bounded package worker without a final publish sweep
  • convert worker panics into ordered build errors
  • transfer LLVM-free runtime ABI type identities from worker Programs into final linking
  • snapshot and replay locality state into every independent worker Program
  • pre-collect and replay implicit package C export mappings in every worker Program
  • inherit build: split package compilation stages #2175 in-memory package archiving, so parallel LLVM workers do not spill package objects to temporary .o files
  • retain successful isolated worker Programs only for build: Go Linktime-like Unreachable Method Pruning #1736 deadcode-drop builds, through whole-program analysis and strong ABI override emission
  • re-intern cloned DCE types and constants in the entry LLVM Context, then explicitly dispose every retained worker Program after linking; deferred cleanup covers errors and panics

The scheduler, ABI type transfer, locality replay, and C export replay are intentionally one atomic layer: #2182 is the first layer that uses independent sessions for production package builds, and enabling them without these state bridges creates known reflect, locality, and cgo regressions.

Dependency and review boundary

Depends on #2180.

The intended diff for this PR is:

Please review only 8d7ec246..da5685a0.

Tests

  • go test ./internal/build -count=1
  • focused -race tests for backend sessions, bounded workers, immediate backend-to-publish task order, ordered errors, worker panic recovery, runtime ABI transfer, package-C export replay, and in-memory archive success/error cleanup
  • clean -p=8 cgo + Plan9 build: 78 LLVM archives, no external ar, no LLVM package temporary .o, runtime output verified
  • ordinary, Thin LTO, and Full LTO archive/runtime checks inherited from build: split package compilation stages #2175
  • cross-Context LLVM verifier, complete TestBuildAndCheckSymbolsFromTestdrop, and concurrent deadcode invocations under -race
  • cold and hot cache builds with -p=1 and -p=8
  • 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: build: run isolated LLVM backends by package

Reviewed the changes in this PR's own commit (build: run isolated LLVM backends by package) across internal/build/{build,collect,package_build,plan9asm}.go and the added tests. The isolation design (per-worker backendSession, immutable template, snapshot-before-dispose, serialized Plan9 asm path) is coherent and the level scheduling / cache-hit finalization reuse are handled carefully. The findings below are ordered by severity.

Correctness (concurrency)

  • Cache-hit packages still compile on the shared ctx.prog while a level runs concurrently. In buildPreflightedPackageLevel, when canUseIsolatedBackend() && len(level) > 1, every spec in the level runs buildPreflightedPackage(ctx, ...) on the shared coordinator ctx from multiple goroutines. Inside executePackageBuild, the isolated-backend branch is gated on !aPkg.CacheHit, so a cache-hit package falls through to buildPkg(ctx, ...)preparePackageModulecl.NewPackageExWithEmbed(ctx.prog, ...) (the early return for cache hits happens after that call). Two or more cache-hit packages in the same level therefore register into the same llssa.Program concurrently — a data race. This is the common incremental-build case (warm cache). See inline note at internal/build/build.go. Worth confirming with go test -race ./internal/build on a build whose ready level contains ≥2 cache-hit packages.

Robustness

  • Panics in worker goroutines are not recovered. preparePackageModule (and other frontend paths) use check(err) which panics. On the serial path this unwound to a returned error; on the new pooled path (buildPreflightedPackageLevel, preflightPackageBuilds) a compile error panics inside a worker goroutine with no recover, crashing the process and hanging wg.Wait() instead of being aggregated into firstErr. Consider a defer recover() in each worker that converts the panic into a result{err: ...}.

Performance

  • Each worker Program replays the entire program's syntax metadata. newProgram() iterates the full immutable syntaxInputs set (ParsePkgSyntax + PreCollectLinknames over every file of every package) plus preCollectRuntimeLinknames for every per-package session. Since a session is created per compiled package, this is O(packages²) frontend replay and each concurrently-live Program holds its own copy of the program-wide maps (peak memory ≈ parallelism × program-metadata). Consider building this metadata once and sharing it read-only, or pooling one session per worker goroutine rather than one per package. Inline note at internal/build/build.go.
  • plan9asmGlobalContextMu is held across module transforms and mod.String(). Only the llvm.GlobalContext-based TranslateSourceModuleForPkg call needs the global lock; holding it across LowerLargeAggregates, TransformModule, and the (potentially expensive) mod.String() serializes that work across all workers for asm-heavy packages (runtime, syscall, internal/*). The sibling plan9asmSigsForPkg already scopes its lock narrowly to just the translate call. Inline note at internal/build/plan9asm.go.

Maintainability

  • Unsynchronized lazy nil-init of shared ctx.sfiles / ctx.plan9asm. plan9asmEnabled, getCachedSFiles, and cacheSFiles do if ctx.X == nil { ctx.X = &...{} } as an unguarded write on a shared ctx, reachable from the concurrent worker path. It is currently benign because Do()/initializePackageBuildState always pre-populate these fields, but the dead nil branches imply a safety that isn't there. Recommend removing them and relying on eager init (and consolidating the three sfilesState construction sites, two of which leave cache nil).
  • Stale comments. preparePackageModule still says it "remains serial because it updates Program-wide registration state" (internal/build/build.go:1825-1826) — it now runs concurrently against a per-worker Program on the isolated path. compilePackageModule's comment "so later PRs can give it a worker-local backend context" (internal/build/build.go:1873-1875) is now stale since this PR already provides that worker-local context. Please update both.

Comment thread internal/build/build.go Outdated
// each package, so packageBuildPlan can schedule ready transactions under -p.
func executePackageBuild(ctx *context, spec packageBuildSpec, verbose bool) error {
aPkg := spec.pkg
if !aPkg.CacheHit && ctx.canUseIsolatedBackend() {

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.

Data race on the shared ctx.prog for cache-hit packages. This gate uses the isolated backend only when !aPkg.CacheHit. A cache-hit package falls through to buildPkg(ctx, ...) (the shared ctx passed by buildPreflightedPackageLevel) → preparePackageModulecl.NewPackageExWithEmbed(ctx.prog, ...), which runs before the cache-hit early return. When canUseIsolatedBackend() && len(level) > 1, workers run concurrently on the shared ctx, so ≥2 cache-hit packages in one level register into the same llssa.Program simultaneously — a race. Route cache-hit frontend registration through an isolated/serialized path too.

Comment thread internal/build/package_build.go Outdated
for index := range jobs {
preflight := preflights[level[index].pkg.ID]
value, err := buildPreflightedPackage(ctx, preflight, verbose)
completed <- result{index: index, value: value, err: err}

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.

No panic recovery in the worker. buildPreflightedPackage reaches check(err) (which panics) via preparePackageModule/clFile/appendExternalLinkArgs. On the old serial path this surfaced as a returned error; here a panic in a worker goroutine has no recover, so it crashes the process and wg.Wait() never returns instead of being aggregated into firstErr. Add a defer func(){ if r:=recover(); r!=nil { completed <- result{index:index, err: fmt.Errorf("%v", r)} } }() (or return errors instead of panicking on this path).

Comment thread internal/build/build.go Outdated
if t.pythonPackage != nil {
prog.SetPython(t.pythonPackage)
}
for _, input := range t.syntaxInputs {

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.

O(packages²) frontend replay + duplicated memory. Because a session is created per compiled package, this loop re-walks the full immutable syntaxInputs set (ParsePkgSyntax + PreCollectLinknames over every file of every package), and preCollectRuntimeLinknames runs again below, for every worker Program. Each concurrently-live Program also keeps its own copy of the program-wide linkname/type-background maps, so peak memory scales with parallelism × program-metadata. Consider building this metadata once and sharing it read-only, or pooling one session per worker goroutine instead of one per package.

Comment thread internal/build/plan9asm.go Outdated
if shouldSkipDarwinDynimportTrampolineAsm(skipDarwinDynimportTrampolines, sfile, src) {
continue
}
plan9asmGlobalContextMu.Lock()

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.

Lock scope is wider than the GlobalContext dependency requires. Only TranslateSourceModuleForPkg touches llvm.GlobalContext; holding plan9asmGlobalContextMu across LowerLargeAggregates, TransformModule, and mod.String() (below) serializes those across all workers for asm-heavy packages. plan9asmSigsForPkg already scopes its lock to just the translate call — matching that here would recover most of the parallelism for runtime/stdlib builds.

Comment thread internal/build/plan9asm.go Outdated

func (ctx *context) plan9asmEnabled(pkgPath string) bool {
ctx.plan9asmOnce.Do(func() {
if ctx.plan9asm == nil {

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.

Unsynchronized write to a shared ctx field. if ctx.plan9asm == nil { ctx.plan9asm = &plan9asmState{} } (and the equivalent for ctx.sfiles in getCachedSFiles/cacheSFiles) mutates the shared ctx without a lock, and this path is reachable from concurrent workers. It's currently benign only because Do()/initializePackageBuildState always pre-populate these fields — so the nil branch is dead code that implies a safety it doesn't provide. Recommend removing the lazy init and relying on eager initialization.

@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch from 70052aa to 8aeb8a1 Compare July 25, 2026 05:36
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch 9 times, most recently from e1dbc60 to c175b7f Compare July 27, 2026 10:13
@zhouguangyuan0718 zhouguangyuan0718 changed the title build: run isolated LLVM backends by package build: run LLVM backends in parallel by package Jul 27, 2026
@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch 7 times, most recently from 0a057c6 to 4454cde Compare July 30, 2026 15:18
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

1636aca55f18 | workflow run | long-term charts

Program measurements

Platform Workload File size vs main Build vs main Run vs main
Linux cprintf 18544 B new 316.245 ms new 1.413 ms new
Linux fmtprintf 2249008 B new 3.940 s new 2.749 ms new
Linux println 71504 B new 311.979 ms new 1.731 ms new
macOS cprintf 84672 B new 345.943 ms new 2.964 ms new
macOS fmtprintf 2377936 B new 3.033 s new 18.340 ms new
macOS println 125712 B new 333.090 ms new 4.104 ms new
Core language and compiler benchmarks
Platform Benchmark ns/op vs main
Linux BenchmarkLookupPCRandom 12.350 ns/op new
Linux BenchmarkMergeCompilerFlags 145.600 ns/op new
Linux BenchmarkMergeLinkerFlags 95.480 ns/op new
Linux BenchmarkChannelBuffered 36.290 ns/op new
Linux BenchmarkChannelHandoff 25831 ns/op new
Linux BenchmarkDefer 43.660 ns/op new
Linux BenchmarkDirectCall 1.757 ns/op new
Linux BenchmarkGlobalRead 1.758 ns/op new
Linux BenchmarkGlobalWrite 2.809 ns/op new
Linux BenchmarkGoroutine 37661 ns/op new
Linux BenchmarkInterfaceCall 8.790 ns/op new
Linux BenchmarkRuntimeGetG 2.109 ns/op new
macOS BenchmarkLookupPCRandom 12.920 ns/op new
macOS BenchmarkMergeCompilerFlags 113.900 ns/op new
macOS BenchmarkMergeLinkerFlags 69.820 ns/op new
macOS BenchmarkChannelBuffered 22.210 ns/op new
macOS BenchmarkChannelHandoff 6935 ns/op new
macOS BenchmarkDefer 27.330 ns/op new
macOS BenchmarkDirectCall 1.026 ns/op new
macOS BenchmarkGlobalRead 1.017 ns/op new
macOS BenchmarkGlobalWrite 1.029 ns/op new
macOS BenchmarkGoroutine 22597 ns/op new
macOS BenchmarkInterfaceCall 4.414 ns/op new
macOS BenchmarkRuntimeGetG 2.190 ns/op new

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

Warning

  • The rendered benchmark data could not be pushed.

@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch 2 times, most recently from d49a547 to 356136d Compare July 31, 2026 01:51
@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch 3 times, most recently from 4501ff4 to 7a66581 Compare July 31, 2026 23:04
@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch 5 times, most recently from 2e5bfea to 5d1e8e7 Compare August 1, 2026 23:22
@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch from 5d1e8e7 to da5685a Compare August 2, 2026 01:33
@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch from da5685a to 1636aca Compare August 2, 2026 03:21
@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/parallel-build-pr8 branch from 1636aca to 0d38bb4 Compare August 2, 2026 05:09
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