Repository navigation
Closed
Labels
bugSomething isn't workingSomething isn't working
Description
Problem
In the FastLED repo, fbuild's warm-pass (incremental, nothing changed) compilation takes ~30 s per sketch. The build pipeline is designed around a re-hydratable cache; in the best case the no-op rebuild should be sub-second. Somewhere between "nothing to do" and "done" there is ~30 s of wall-clock time that is not explained by actual compilation.
Acceptance criteria
- Identify the dominant stall(s) with concrete numbers (e.g., "7 s in X", "12 s in Y").
- Produce a prioritized fix list with expected savings per item.
- Drive warm-pass wall-clock down to <3 s for a representative FastLED example and document the new baseline.
Investigation checklist
1. Reproduce & baseline
- Pick a representative FastLED sketch that builds through fbuild (e.g.,
examples/Blink,examples/DemoReel100). - Run cold build twice to populate cache; time each phase.
- Run warm build; record total wall-clock (expect ~30 s).
- Repeat on Linux/macOS/Windows — stall may be platform-specific.
2. Macro profiling
- Instrument
BuildOrchestratorphase boundaries withInstant::now()checkpoints; log per-phase elapsed on warm run. - Classify time into buckets: daemon handshake, config parse, fingerprint hashing, compile scheduling, per-file staleness check, linker invocation, objcopy/size, disk cache lease acquisition, framework validation, response-file regeneration.
3. Fingerprint / staleness checks
- Measure
build_fingerprinthashing cost — does it mtime-scan every source & header? - Check whether header dep-files (
.d) are being re-read serially for each compile unit even when no source changed. - Verify
compile_database::write_and_copy_identical_contentactually short-circuits identical-content writes (tests exist — prove runtime behavior matches). - Look for redundant filesystem stats across framework/core/sketch trees (especially ESP-IDF which has tens of thousands of files).
4. Toolchain / package resolution
- Cold-path code that resolves toolchain binary paths — does it re-validate on every warm build via filesystem probes?
- Check whether
ensure_toolchain()andensure_framework()short-circuit fully on hot cache, or re-touch manifest / re-hash archives. - Inspect
library_manager— does it re-scan every lib dir on each build?
5. Daemon / IPC overhead
- Measure HTTP request round-trip from CLI → daemon → response for
buildop. - Check WebSocket log streaming startup latency; is there a race where the CLI blocks waiting for the first log line?
- Verify daemon isn't re-initializing state (device manager, serial manager, context) between requests.
6. Process spawn cost
- Count subprocess spawns on a warm build (
strace/Procmon). Each compiler probe, eachfileinvocation, eachsize/objcopycall adds up. - Check whether tool-version probes (
gcc --version,ar --version, etc.) are cached across builds.
7. Build graph execution
- Is there a serial bottleneck (e.g., single-threaded pre-flight before the parallel compile pool starts)?
- Does the pipeline wait on
cargo check-style early rejection before spawning parallel workers? - Measure gap between "build request accepted" and "first worker starts" vs "last worker finishes" and "response sent".
8. ESP-IDF / framework-specific
- ESP-IDF sdkconfig hashing — is the full tree walked?
- SDK lib scan (
libxxx.afiles) — cached or re-globbed? - Include path validation — does it
stateach directory on every build?
9. Disk cache
-
disk_cache::leaseacquisition time on warm build. -
disk_cache::gc::reconcile_on_open— does it run on every build or only at daemon start? - Archive/extract paths — confirm nothing is being re-extracted on warm run.
10. Cross-reference FastLED CI badges
- Pull recent CI build durations from the FastLED repo badges/actions.
- Compare warm-rebuild time per platform across the badge matrix; outliers point at platform-specific stalls.
- If any badge shows a regression trend, correlate to recent fbuild commits.
Deliverables
- A
docs/PERF_WARM_BUILD.md(or issue comment) summarizing the findings with concrete timing tables. - One or more follow-up issues/PRs per identified stall, with the fix scope and expected savings.
- Updated warm-build baseline captured in a benchmark under
tests/or acrates/fbuild-build/benches/entry so regressions are caught early.
Context
- Reference: CLAUDE.md "Key Constraints" — cache re-hydration is a core design goal.
- Related: perf(deploy): replace esptool subprocess with espflash native crate #66 (esptool subprocess overhead in deploy path — same class of problem, different phase).
- Related: Outstanding work: ledger removal, send_op errors, async API, B5a hardening, espflash native, sector-selective flashing #18 historical tracker for pipeline perf work.
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't working
Type
Projects
- StatusShow more project fieldsTriage
Activity
zackees commentedon Apr 18, 2026
Issue #91 — Macro-profiling results (warm ESP32 build)
TL;DR: The 30-second warm-build stall is almost entirely post-response process-teardown, not compile/link/IPC. The actual build work is 34-71 ms (fingerprint cache hit); the daemon HTTP round-trip is 50-70 ms; the rest (up to 120-180 s in PowerShell) is the CLI process waiting for its detached grandchild daemon to release a shell-pipe handle that leaks via
bInheritHandles=TRUEonCreateProcess.Test setup
target/x86_64-pc-windows-msvc/release/fbuild.exe.tests/platform/esp32dev(ESP32 + Arduino 3.x, 57-file Arduino core archive, firmware.elf 4.99 MB, .bin 288 KB). Behavior indistinguishable from FastLED/Blink.ino on the same board since both hit the same fingerprint fast-path..fbuild/build/esp32dev/release/*artifacts intact and the fingerprint sidecar valid.%TEMP%/fbuild-diag.log) at each major CLI/daemon checkpoint —run_buildentry/exit,ensure_daemon_runningsub-phases,build_streaming(POST, headers, first byte, result-event parse, stream end).Summary table — warm run, hot daemon (idle + running)
ensure_daemon_runninghealth checkSummary table — warm run, cold daemon (must spawn)
health()probe (timeout)spawn_daemon_process-> first healthy poll2>/dev/nullSubprocess-spawn count on warm run
orchestrator.rs:392-412. Verified viaGet-WmiObject Win32_Processduring the build window.fbuild-daemon.exe).Top 3 stalls (ranked by user-visible impact)
1. Grandchild daemon inherits parent shell's stderr pipe -> 120-180 s process-exit stall on Windows
crates/fbuild-cli/src/daemon_client.rs:851-900(spawn_daemon_process).Command::spawnon Windows setsbInheritHandles=TRUEinCreateProcesswhenever any custom stdio is provided (we provide all three:Stdio::null/null/from(log_file)). The daemon is spawned withCREATE_NO_WINDOW | DETACHED_PROCESS, but those flags govern console attachment, not handle inheritance. Every handle in the CLI markedHANDLE_FLAG_INHERIT— including the shell's stderr pipe that PowerShell/MinTTY gives the CLI on startup — flows through into the daemon. When the CLI exits, the shell still holds a read handle to that pipe and blocks until all writers close. The daemon lives 120 s (SELF_EVICTION_TIMEOUTincontext.rs:53), so the shell reliably hangs ~120 s on PowerShell and sometimes on mintty-bash.powershell -c "& fbuild.exe build tests/platform/esp32dev -e esp32dev"-> 125 s. Add2>$null(PS) or2>/dev/null(bash) -> 4.2 s. Confirmed by timing:main()enters at T+0 and returns at T+2.2 s; the shell only unblocks at T+125-187 s. The stall duration matchesSELF_EVICTION_TIMEOUTexactly.CreateProcessWviawindows-syswithbInheritHandles=FALSEand only the three explicit STD handles inSTARTUPINFO. Alternatively, beforecmd.spawn(), callSetHandleInformation(GetStdHandle(STD_ERROR_HANDLE), HANDLE_FLAG_INHERIT, 0)(and same for stdout/stdin) to strip inheritability from the CLI's stdio handles; restore after spawn. Effort: 1-2 hours.2. 2-second socket-connect timeout when the daemon isn't running
crates/fbuild-cli/src/daemon_client.rs:390-398(DaemonClient::health), called twice per CLI invocation (display_daemon_stats_compact+ensure_daemon_running). The Windows TCP stack returnsWSAECONNREFUSEDnearly instantly on a dead port (verified:Test-NetConnectionRTT <1 ms), but reqwest's.timeout(2s)applies to the whole send-await and waits the full timeout before surfacing failure.std::net::TcpStream::connect_timeout(_, 100ms)probe before the reqwest call, or addreqwest::ClientBuilder::connect_timeout(Duration::from_millis(100))to the shared client. Effort: 15 minutes.3.
display_daemon_stats_compactduplicates the health probe on every commandcrates/fbuild-cli/src/main.rs:445. The compact status banner is printed before every command — includingbuild, which then callsensure_daemon_running()and repeats the same health check. On a cold daemon that's 2+2=4 s of pure health-probe timeout before any work starts.Unexpected findings
Esp32Orchestrator::build(crates/fbuild-build/src/esp32/orchestrator.rs:315-415) correctly short-circuits on warm builds: fingerprint match returns in ~34 ms with zero compiler invocations.compile_database_pathis preserved and reused.fbuild-packages::ensure_installedis never reached on the cache-hit path.orchestrator.rs:315happens beforeensure_installedfor toolchain and framework.lib_depsis empty for this sketch, but the code path shows the manager is never called on fingerprint-match.compile_db_is_current+expected_output_pathavoid rewriting the JSON when unchanged.crates/fbuild-daemon/src/context.rs:53). The exact duration of the observed PowerShell stall — smoking gun that the shell waits for the daemon itself to die.health()timeout is 2x the Python reference daemon's 1 s — worth parity-checking.Top follow-up fixes (not done here — deferred)
HANDLE_FLAG_INHERITfrom CLI stdio before spawning daemon on Windows; or switch to rawCreateProcessWwithbInheritHandles=FALSE.connect_timeoutbefore the 2 s request timeout onhealth()/daemon_info().display_daemon_stats_compacton commands that will callensure_daemon_running.SELF_EVICTION_TIMEOUTto 30 s so that even if F1 is deferred, the worst-case stall shrinks.Reproduction commands
Key files referenced
crates/fbuild-cli/src/daemon_client.rs:851-900—spawn_daemon_process(root cause)crates/fbuild-cli/src/daemon_client.rs:390-411—health/health_full(2 s timeout)crates/fbuild-cli/src/daemon_client.rs:769-848—ensure_daemon_runningcrates/fbuild-cli/src/main.rs:445—display_daemon_stats_compact(redundant probe)crates/fbuild-daemon/src/context.rs:53—SELF_EVICTION_TIMEOUT = 120s(the value the shell ends up waiting for)crates/fbuild-build/src/esp32/orchestrator.rs:315-415— the warm-path guard (works correctly)crates/fbuild-build/src/esp32/orchestrator.rs:395-411— the 0-compile fast returnInstrumentation was added locally for measurements and then removed; no PR filed.
perf(cli): fix Windows daemon-spawn handle inheritance + fast health …
perf(cli): fix Windows daemon-spawn handle inheritance + fast health …
perf(cli): drop redundant pre-command daemon banner; shorten self-evi…
perf(cli): drop redundant pre-command daemon banner; shorten self-evi…
docs(ci): add CI_CACHING.md — save/restore design for CI runners (closes
docs(ci): add CI_CACHING.md — save/restore design for CI runners (closes
docs(perf): warm-pass build investigation + FBUILD_PERF_LOG instrumen…
docs(perf): warm-pass build investigation + FBUILD_PERF_LOG instrumen…
docs(perf): warm-pass build investigation + FBUILD_PERF_LOG instrumen…
docs(perf): LOOP.md — warm-deploy agent loop spec (partial #91)
docs(perf): LOOP.md — warm-deploy agent loop spec (partial #91) (#113)
perf(daemon): sub-1s warm deploy — image-hash memo + refresh-skip window
perf(daemon): sub-1s warm deploy — image-hash memo + refresh-skip win…