Skip to content

build: add Chrome scheduler trace - #2243

Merged
xushiwei merged 2 commits into
xgo-dev:mainfrom
zhouguangyuan0718:agent/build-trace
Aug 12, 2026
Merged

build: add Chrome scheduler trace#2243
xushiwei merged 2 commits into
xgo-dev:mainfrom
zhouguangyuan0718:agent/build-trace

Conversation

@zhouguangyuan0718

@zhouguangyuan0718 zhouguangyuan0718 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Architecture proposal: #2284.

This PR is based on the package-worker pipeline and caller-tracking preparation
that are now present on main through #2286.

Summary

  • add an opt-in llgo build -debug-trace=<file> scheduler trace in Chrome
    Trace Event JSON format; this traces the build pipeline, not the compiled
    program and not runtime/trace
  • visualize coordinator work and a stable set of worker lanes bounded by the
    effective Go -p value (GOMAXPROCS when -p is not specified)
  • record package loading, shared backend-state preparation, parallel Go SSA,
    serial SSA repair and caller tracking, package preparation, backend/archive
    publication, and final linking
  • represent backend compilation and immediate archive/cache publication as the
    existing single backend+publish task
  • connect each completed SSA task to its backend task by
    packages.Package.ID, avoiding ambiguous flow arrows between test variants
    that share a PkgPath
  • keep all trace state local to one build invocation, so concurrent builds do
    not share output files, lanes, flow IDs, or mutable state
  • encode the complete event array with the standard library encoding/json;
    there is no custom JSON framing or process-global tracing dependency

This does not restore the superseded preflight partition/result,
PackageSummary, syntax-delta validation, state snapshot, or worker-overlay
designs.

Activation and disabled behavior

Tracing is disabled by default:

  • -debug-trace defaults to an empty path
  • the command copies the option into the build-local Config.BuildTrace
  • an empty path creates no tracer and no output file
  • nil-safe trace calls become no-ops; they do not allocate the event buffer or
    worker-lane semaphore and do not take timestamps

The flag is intentionally owned by llgo build, which has one build invocation
and one trace output. It is not added to llgo run or llgo test, whose command
layer can coordinate multiple builds.

Trace model

  • lane 0: coordinator and serial work, including patched/coordinator backends
  • lanes 1..p: bounded parallel SSA and isolated backend+publish work
  • complete events (X): stage duration and package metadata
  • flow events (s/f): SSA completion to the matching package backend
  • metadata events (M): process and lane names for Chrome/Perfetto

Relative output paths are resolved from the build invocation directory. Trace
creation uses O_EXCL, so an existing file is never overwritten. A final JSON
encoding or close error is reported as a warning and does not turn an otherwise
successful build into a failure.

Open the generated file in chrome://tracing or
https://ui.perfetto.dev/.

Actual etcd trace

Command, with LLGo package build outputs disabled and every package forced:

LLGO_BUILD_CACHE=off llgo build -a -p=8 \
  -debug-trace=/tmp/etcd-p8.json \
  -o /tmp/etcd ./server

Observed on the local etcd server checkout:

  • wall time: 38.23s (user 215.43s, sys 11.13s)
  • 592 Go SSA tasks; peak parallelism 8, average 7.78
  • 553 isolated backend+publish tasks; peak 8, average 7.20
  • all 8 isolated workers active for 82.3% of the isolated backend window
  • 164.85 task-seconds of isolated backend work completed in a 22.90s window
  • 10 patched backends (2.21s serial) and 12 coordinator backends (0.82s serial)
  • caller-tracking precomputation: 0.134s
  • final link: 8.32s
  • all 575 backend spans have paired SSA flow events
  • traced output ran successfully with --version

The generated trace contained 2,917 events and was 677 KiB. Its measured wall
time was within run-to-run noise of the 37.98s untraced build.

Validation

  • go test ./internal/build ./cmd/internal/build ./cmd/internal/flags
  • go test -race ./internal/build -run 'BuildTrace' -count=1
  • focused disabled/enabled option-path tests
  • integration test that builds a real package and validates load, SSA,
    preparation, backend, caller-tracking, and SSA-to-backend flow events
  • worker-lane test that verifies traced concurrency never exceeds effective
    parallelism
  • existing-output refusal, JSON error, idempotent close, and write-after-close
    lifecycle tests
  • forced-cold etcd -a -p=8 build with the LLGo build cache disabled
  • generated trace parsed as Chrome Trace Event JSON and analyzed for concurrency
  • 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.

FennoAI review — build scheduler trace

Reviewed the trace-specific range 475dc124..d917b0ce across code quality, performance/concurrency, security, and documentation. The design is solid: the nil-tracer convention keeps disabled builds allocation-free, span/close double-invocation is guarded by sync.Once, and flow edges are deliberately limited to direct SSA dependencies to avoid quadratic trace size.

Concurrency, verified safe (no change needed): the lanes semaphore never deadlocks — every startWorker caller runs under the same parallelism() bound that sizes the lane channel, and the serial coordinator phases never overlap the parallel worker phases. The node.traceSpan / from.end cross-goroutine reads in flow() have a valid happens-before via the pipeline events channel and ready() gating.

Findings are inline. The most impactful is the build-fails-on-trace-write-error behavior in internal/build/build.go.

Minor note not placed inline: the load-bearing invariant behind the lane semaphore ("never open more than parallelism worker spans concurrently, never nest worker spans on one goroutine") is currently undocumented — worth a doc comment on startWorker/buildTracer to protect future callers, since violating it would deadlink real build work rather than just skew the visualization.

View job run

Comment thread internal/build/build.go Outdated
buildSpan.done()
if closeErr := buildTrace.close(); closeErr != nil && resultErr == nil {
result = nil
resultErr = fmt.Errorf("write build trace: %w", closeErr)

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.

A diagnostic trace-write failure turns a fully successful build into a reported failure: on closeErr != nil the deferred func discards the built packages (result = nil) and returns resultErr. -debug-trace is a debugging aid; failing the whole build because the trace file couldn't be flushed is a heavy, asymmetric policy (a write error mid-build is already silently swallowed via the sticky writeErr, and only surfaces here at close). Consider logging the trace-write error to stderr and preserving the build result instead. If failing hard is intended, a short comment justifying it would help, since the behavior is non-obvious.

Comment thread internal/build/build_trace.go Outdated
path = filepath.Join(dir, path)
}
if filepath.Ext(path) == ".go" {
return nil, fmt.Errorf("refusing to overwrite Go source file %s", path)

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.

This guard refuses only a .go extension, then os.Create (line 86) truncates any other existing target. So -debug-trace=go.mod, -debug-trace=Makefile, an existing archive, etc. are silently overwritten, and relative paths resolve against the source dir. The message "refusing to overwrite Go source file" implies a general overwrite protection that doesn't exist. Either drop the check (and be honest it's a plain os.Create), or if protection is intended use os.OpenFile with O_CREATE|O_EXCL to refuse overwriting any existing file. Low severity since the path is a locally-supplied flag.

Comment thread internal/build/build_trace.go Outdated
}

// buildTracer is owned by one Build invocation. The lane semaphore mirrors
// the build's -p limit, so overlapping worker-lane events visualize the same

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.

The comment says the lane semaphore "mirrors the build's -p limit," but the lane count comes from conf.parallelism(), which returns BuildParallelism only when > 0 and otherwise falls back to GOMAXPROCS. When -p isn't supplied the lanes mirror GOMAXPROCS, not a user -p value. Suggest wording like "the build's effective package parallelism (-p, or GOMAXPROCS by default)."

Comment thread cmd/internal/flags/flags.go Outdated
// output file; test and run may coordinate multiple child invocations.
func AddBuildTraceFlag(fs *flag.FlagSet) {
BuildTrace = ""
fs.StringVar(&BuildTrace, "debug-trace", "", "Write a Chrome/Perfetto build trace to file")

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.

The PR stresses this is a build scheduler trace, not a runtime/execution trace, and this same file already defines -trace ("Write an execution trace to the specified file"). Neither the flag name -debug-trace nor this usage string conveys "scheduler," so the two are easy to conflate. The doc comment on AddBuildTraceFlag and the internal test both say "scheduler" — consider aligning the user-facing usage string, e.g. "Write a Chrome/Perfetto build-scheduler trace to file".

Comment thread internal/build/package_pipeline.go Outdated
} else {
traceSpan.setArg("class", "isolated")
}
if ctx.buildTrace != 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.

This explicit if ctx.buildTrace != nil is redundant with flow()'s own nil-receiver guard, and inconsistent with every other call site here (startWorker, setArg, done are all called unconditionally on a possibly-nil tracer). If the intent is to skip building the callerNodes loop when tracing is off, a one-line comment would clarify it; otherwise drop it for consistency.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.67100% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/build/build.go 88.00% 4 Missing and 2 partials ⚠️
internal/build/build_trace.go 97.43% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

695d558a24e4 | workflow run | long-term charts

Program measurements

Platform Workload File size vs base Build vs base Run vs base
Linux cprintf 18656 B +0.0% 296.436 ms +0.3% (worse) 1.293 ms -2.3% (better)
Linux fmtprintf 1881664 B +0.0% 2.664 s +0.6% (worse) 3.269 ms -2.2% (better)
Linux println 68512 B +0.0% 296.561 ms -1.8% (better) 1.621 ms -2.3% (better)
macOS cprintf 84672 B +0.0% 522.054 ms +27.1% (worse) 3.070 ms -11.7% (better)
macOS fmtprintf 1889248 B +0.0% 3.826 s -24.0% (better) 18.510 ms -13.6% (better)
macOS println 121216 B +0.0% 407.650 ms -0.0% (better) 4.257 ms -18.1% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 13.330 ns/op +0.5% (worse)
Linux BenchmarkMergeCompilerFlags 156.900 ns/op +4.0% (worse)
Linux BenchmarkMergeLinkerFlags 102.200 ns/op +8.4% (worse)
Linux BenchmarkChannelBuffered 34.850 ns/op -0.1% (better)
Linux BenchmarkChannelHandoff 27472 ns/op -0.1% (better)
Linux BenchmarkDefer 49.430 ns/op +3.1% (worse)
Linux BenchmarkDirectCall 1.561 ns/op +0.2% (worse)
Linux BenchmarkGlobalRead 1.558 ns/op +0.1% (worse)
Linux BenchmarkGlobalWrite 2.488 ns/op +0.0% (worse)
Linux BenchmarkGoroutine 32972 ns/op +1.8% (worse)
Linux BenchmarkInterfaceCall 7.798 ns/op +0.2% (worse)
Linux BenchmarkRuntimeGetG 2.182 ns/op +0.0%
macOS BenchmarkLookupPCRandom 11.840 ns/op -25.6% (better)
macOS BenchmarkMergeCompilerFlags 126.800 ns/op -31.7% (better)
macOS BenchmarkMergeLinkerFlags 79.020 ns/op -36.4% (better)
macOS BenchmarkChannelBuffered 22.410 ns/op -35.5% (better)
macOS BenchmarkChannelHandoff 8005 ns/op -34.8% (better)
macOS BenchmarkDefer 34.570 ns/op -25.2% (better)
macOS BenchmarkDirectCall 1.070 ns/op -19.3% (better)
macOS BenchmarkGlobalRead 1.104 ns/op -14.0% (better)
macOS BenchmarkGlobalWrite 1.122 ns/op -6.3% (better)
macOS BenchmarkGoroutine 42747 ns/op -26.4% (better)
macOS BenchmarkInterfaceCall 5.633 ns/op -15.5% (better)
macOS BenchmarkRuntimeGetG 2.200 ns/op -28.0% (better)

Compared with 43cf22e6fb90 measured in the same runner job.

@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/build-trace branch 14 times, most recently from e1fee5d to d6a38e5 Compare August 5, 2026 14:45
@zhouguangyuan0718
zhouguangyuan0718 force-pushed the agent/build-trace branch 3 times, most recently from ec33fd7 to a938582 Compare August 11, 2026 12:14
@xushiwei
xushiwei merged commit 1487e50 into xgo-dev:main Aug 12, 2026
42 checks passed
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.

2 participants