Skip to content

Proposal: package-level parallel LLVM compilation #2284

Description

@zhouguangyuan0718

Summary

Enable bounded package-level parallel compilation for LLGo's expensive LLVM backend while preserving the existing frontend, cache, runtime-selection, archive, dead-code, and link semantics.

The central rule is:

Finish all mutable Go-side preparation before starting LLVM workers. Share that prepared state read-only, but give every worker its own LLVM-owned state.

The effective worker bound is Go's -p value. When -p is not specified, LLGo uses GOMAXPROCS. Go SSA construction and isolated LLVM backends both use this bound, in two separate phases. LLVM backend packages do not wait for their import dependencies once Go SSA and shared metadata preparation have completed.

This issue documents the architecture already split across merged prerequisites and the open implementation stack, records the current compatibility boundary, and defines the remaining validation and follow-up work.

Motivation

Large LLGo builds spend most of their wall time in per-package LLVM lowering, optimization, code generation, and archive production. The former build loop processed every package serially even though, after Go SSA construction and frontend metadata discovery, most package LLVM modules are independent.

Simply putting the former buildOne call in goroutines is unsafe. A single LLGo ssa.Program owns an LLVM Context, TargetMachine, Go-to-LLVM type caches, ABI state, symbol maps, package syntax/locality state, callbacks, and link-time package modules. Several of those were also affected by process-global configuration in the old entry path.

The implementation therefore separates three concerns:

  1. invocation and frontend state isolation;
  2. coordinator preparation of immutable Go-side information;
  3. independent LLVM backend sessions scheduled by bounded workers.

Proposed build flow

flowchart TD
    A["Build invocation<br/>clone Config; capture cwd and env"] --> B["Load packages and source patches"]
    B --> C["Prepare shared Go metadata<br/>syntax, linknames, exports, localities,<br/>closure env, nointerface, type backgrounds"]
    C --> D{{"Parallel Go SSA build<br/>up to effective -p"}}
    D --> E["Serial SSA order repair"]
    E --> F["Precompute caller tracking<br/>for all SSA packages"]
    F --> G["Serial package preflight<br/>fingerprint/cache, SFiles, Plan 9 policy,<br/>backend classification; then freeze inputs"]

    G --> H{"Backend class"}
    H -->|"patched package"| I["Coordinator backend + archive publish"]
    H -->|"Plan 9 asm, non-exe,<br/>ModeGen, or ModuleHook"| J["Coordinator backend + archive publish"]
    H -->|"ordinary executable package"| K{{"Isolated backend workers<br/>up to effective -p"}}

    K --> L["Fresh llssa.Program<br/>LLVM Context and TargetMachine"]
    L --> M["Fresh lowering/type caches,<br/>ABI state and C ABI transformer"]
    M --> N["LLVM lowering, passes and codegen"]
    N --> O["Write package archive and cache<br/>immediately in the same task"]
    O --> P["Retain successful Program + LPkg<br/>until whole-program consumers finish"]

    I --> Q["Join package results"]
    J --> Q
    P --> Q
    Q --> R["Serial link, method DCE,<br/>strong ABI overrides and finalization"]
    R --> S["Clear LPkg and explicitly Dispose<br/>all retained worker Programs"]
Loading

Normal packages are processed first. Their NeedRuntime and NeedPyInit results decide whether the runtime package group is activated, preserving the existing delayed runtime behavior. Packages are independent within each activated backend group; there is no LLVM import-dependency DAG.

Archive and cache publication is part of the package backend task. This avoids a separate publication wave and lets I/O start as soon as each package finishes code generation.

State ownership

State Ownership and lifetime
LLVM executable discovery / PATH Process background, established once before build.Do; never changed by package workers.
Invocation Config, cwd, environment Cloned or captured once per invocation and passed explicitly.
Frontend options Invocation-owned cl.Options; no process-wide debug/frontend switches or global lock.
Go packages, AST/type identity, Go SSA program Coordinator-owned. Go SSA packages are built in parallel, then treated as prepared input to the backend phase.
Package syntax metadata Linknames, exports, closure-env directives, nointerface markers, type backgrounds, parsed syntax identity, and locality metadata are fully prepared before backend sessions and then shared read-only.
Caller tracking Precomputed once for every Go SSA package before backend workers start; workers only query it.
Fingerprints, cache decisions, SFiles and Plan 9 policy Resolved on the coordinator during package preflight. SFiles are frozen before workers.
Backend llssa.Program One per isolated package; owns a fresh LLVM Context, TargetMachine/TargetData, lowering caches, type maps, ABI maps, and module.
C ABI transformer One per backend Program; never shared across workers.
Archive/cache publication Runs immediately after successful codegen inside the same package task. LLVM object bytes are archived directly from MemoryBuffer; file-backed cgo/Plan 9 members are included without first spilling the LLVM object to a temporary .o.
Successful LPkg and worker Program Transferred to the coordinator and retained through link-time DCE and ABI consumers, then explicitly released on normal, error, and panic paths.

There is intentionally no global worker lock, environment snapshot/restore wrapper, syntax delta merge, syntax validation pass, or PackageSummary prerequisite in the current design.

Parallelism and scheduling

  • -p=N sets the maximum number of package workers.
  • With no explicit -p, the default is GOMAXPROCS.
  • Go SSA construction is bounded by this value.
  • Isolated LLVM backend tasks are also bounded by this value.
  • The two worker phases are currently separated by SSA order repair, caller-tracking precomputation, and package preflight; this proposal does not pipeline SSA directly into LLVM.
  • LLVM packages are submitted as a flat set after preflight. Import dependencies do not constrain LLVM scheduling because their Go SSA and shared frontend metadata already exist.
  • Worker errors are collected concurrently but reported in deterministic package input order.
  • Patched and coordinator-only packages remain serial and do not overlap isolated workers in the current implementation.

Compatibility boundary

The coordinator path remains the fallback for:

  • patched/alternate lowering that must use the coordinator Program;
  • packages using selected Plan 9 assembly;
  • non-executable build modes;
  • ModeGen;
  • builds with ModuleHook.

Cache hits retain the old link semantics: LLGo reconstructs the frontend module required by whole-program consumers, skips backend emission, and keeps the resulting live package state through linking. This is why the implementation cannot yet destroy every worker Program immediately after archive publication.

PR #1736 adds link-time method dead-code analysis that consumes live package modules. Until that information is moved into a Go-owned summary, successful worker Programs must remain alive through linkMainPkg. Cross-context DCE constants and strong ABI types must be recreated in the destination LLVM Context; LLVM values must never cross Context boundaries directly.

Build scheduler trace

PR #2243 adds:

llgo build -debug-trace=/path/to/trace.json ...

The output uses Chrome Trace Event JSON and opens directly in chrome://tracing or Perfetto. It records:

  • package loading and shared-state preparation;
  • parallel Go SSA tasks;
  • serial SSA order repair and caller-tracking precomputation;
  • per-package preflight/cache decisions;
  • patched, coordinator, and isolated backend classifications;
  • backend plus immediate archive/cache publication;
  • direct SSA-to-backend flow edges;
  • final serial linking.

Worker lanes use the same effective -p bound as the build. The trace target is created exclusively, so an existing source, build file, or previous trace is not silently overwritten. A trace flush failure is reported as a warning and does not turn a successful compilation into a failed build.

Actual etcd trace

Measured on go.etcd.io/etcd/server/v3 at etcd commit 3e3b4c181c69, with LLGo build cache disabled and all package compilation forced:

LLGO_BUILD_CACHE=off llgo build -a -p=8 \
  -debug-trace=/tmp/etcd-p8.json \
  -o /tmp/etcd ./server
Trace observation Result
Wall time with trace 52.36 s
Go SSA tasks 592
Go SSA peak parallelism / phase window 8 / 0.40 s
Caller-tracking precomputation 14.14 s serial
Package preflight 1.56 s serial
Patched backends 11 tasks / 2.58 s serial
Other coordinator backends 11 tasks / 0.89 s serial
Isolated LLVM backends 553 tasks
Isolated backend peak / average parallelism 8 / 7.19
Time at all 8 isolated workers active 82.3% of the isolated window
Isolated backend work / elapsed window 156.41 CPU-task s / 21.75 s
Complete backend phase 25.22 s
Final link 8.37 s serial

The trace shows that the expensive LLVM region is genuinely package-parallel and mostly saturates the requested bound. It also exposes the next large serial targets: caller tracking and final link.

Without trace, three forced etcd builds averaged 134.40 s at p=1 and 53.26 s at p=8, a 2.52x wall-time speedup. CPU time increased by about 27.9%, and peak RSS increased from about 7.19 GB to 7.40 GB. Kubernetes kubeadm previously improved from 439.31 s to 254.85 s (1.72x) under the same no-package-output-cache methodology.

Repeated p=1/p=8 builds with the same output path produced identical binaries. Builds written to different paths differ only in Mach-O UUID and ad-hoc code-signature metadata, not executable code or data.

PR map

Merged prerequisites

Open implementation stack

  1. ssa: make recursive type conversion order-independent #2280 — make mutually recursive Go-to-LLVM type conversion order-independent, so independent backend Programs cannot produce order-dependent placeholders.
  2. build: prepare read-only package backend state #2282 — prepare package metadata once, share it read-only, and create fresh backend Programs without snapshot/overlay/delta machinery.
  3. build: run LLVM package backends in parallel #2182 — classify packages, run bounded isolated LLVM workers, publish archives immediately, retain successful Programs through link, and release them deterministically.
  4. build: add Chrome scheduler trace #2243 — add the Chrome/Perfetto build scheduler trace on top of build: run LLVM package backends in parallel #2182.

Closed experimental PRs such as the SSA-to-backend pipeline and a separate parallel-publish layer are not part of this proposal. Publication is already in the backend task, and the etcd trace shows only about 0.40 s of Go SSA work versus 14.14 s of caller precomputation and 25.22 s of backend work, so SSA/backend pipelining is not currently the highest-value optimization.

Alternatives considered

Run the old package build loop in goroutines

Rejected because the old loop shared an LLVM Context, TargetMachine, lowering caches, ABI state, package metadata mutation, environment/cwd behavior, and link-time modules.

Schedule LLVM packages by import dependencies

Rejected as unnecessary. Import ordering matters while constructing and preparing Go SSA/frontend state. After that phase, each LLVM package can lower independently; imposing the import DAG would reduce parallelism without adding correctness.

Protect shared backend state with a global lock

Rejected because it would make concurrency mostly cosmetic and would retain process-global coupling between builds.

Parallelize only LLVM pass/codegen inside a shared frontend Program

Rejected for now because creating and lowering the package module already touches LLVM-context-owned type/value state. Safe isolation begins at a complete backend Program, not at a late pass-manager boundary.

Require PackageSummary before enabling workers

Deferred. A complete summary remains useful for reducing retained memory, but it must carry all link-time information consumed by DCE, ABI override generation, method tables, globals, funcinfo/PCLN, C exports, runtime flags, and cache metadata. Retaining live Programs preserves existing semantics and enables parallelism sooner.

Pipeline SSA directly into LLVM

Deferred. It requires incremental caller/dependency preparation or another synchronization boundary. Current trace data shows SSA itself is a very small fraction of the etcd build; optimizing caller tracking and link offers more immediate value.

Risks and mitigations

  • Peak memory: multiple LLVM modules remain live through linking. Keep -p bounded and pursue PackageSummary as follow-up work.
  • Cross-context LLVM values: recreate types/constants from Go-owned identities and verify every worker module; never pass raw LLVM values between Programs.
  • Nondeterminism: keep package/result ordering deterministic, test p=1 versus p=8, and compare output code/data.
  • Failure cleanup: worker ownership transfers only after success; coordinator defers clear all LPkg references before disposing Programs on normal, error, and panic paths.
  • Fallback behavior: unsupported modes and packages use the unchanged coordinator path rather than partially isolated behavior.
  • Observability: keep the scheduler trace build-local and optional; disabled tracing has no shared state or output file.

Validation and acceptance criteria

Follow-up work

  1. Reduce or parallelize caller-tracking precomputation, currently 14.14 s in the etcd trace.
  2. Define PackageSummary and move link/DCE consumers to Go-owned data so worker LLVM modules can be released earlier.
  3. Investigate safe overlap of coordinator-only packages with isolated workers.
  4. Re-evaluate Plan 9 assembly parallelism only after measuring a workload where it is material.
  5. Use scheduler traces in benchmark/CI investigations to track lane utilization, long-tail packages, and regressions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions