Repository navigation
Description
Splitting this out of #1411. That issue was a 161s no-op caused by the ESP32-S3 SDK-libs completion check failing forever and reinstalling 298 MB on every build; fixed in #1413. This is what is left underneath it, profiled on the same machine and project.
Method
Bare blink sketch, no libraries, esp32-s3-devkitc-1 (opi PSRAM, 16MB), everything installed and the framework core cache warm. Each measurement is a brand-new project directory — the "second brand-new project directory" scenario from #1347 — built once with FBUILD_PERF_LOG=1. Linux, 32-core. fbuild at 87493b2.
Three independent fresh dirs: 14.1s, 7.2s, 7.0s. The 14.1s is a first-touch outlier (first build after the 298 MB SDK tree was re-extracted, so cold page cache). Steady state is ~7.1s to compile one file and link a blink.
Where the 7,175 ms goes
| Phase | ms | Note |
|---|---|---|
link-convert-size |
1,801 | link + elf2image + size |
compile-sketch |
1,306 | one TU |
core-cache-hydrate |
1,169 | copy 118 cached core objects into the new build dir |
compile-core-variant |
1,137 | zero files compiled |
| unaccounted | ~930 | gap between BuildContext::new and the named phases |
boot-artifacts |
349 | |
pioarduino-resolve |
295 | |
fw-libs |
144 | |
compile-db |
30 | |
core-cache-store |
11 | |
fingerprint-save |
2 |
Raw line:
[perf-log esp32-orchestrator] config-parse=0 ms, board-load=6 ms, build-dirs=0 ms,
flag-collect=0 ms, pioarduino-resolve=295 ms, fp-watches-collect=0 ms,
fast-path-check=0 ms, fw-libs=144 ms, scan-sources=0 ms, core-cache-hydrate=1169 ms,
compile-core-variant=1137 ms, core-cache-store=11 ms, compile-sketch=1306 ms,
compile-local-libs=0 ms, compile-db=30 ms, link-convert-size=1801 ms,
boot-artifacts=349 ms, fingerprint-save=2 ms, total=7175 ms
Linking is 25%. The other 75% is the interesting part.
Findings, most actionable first
1. The sketch TU is recompiled in every new project dir — zccache never serves it
The zccache journal shows exactly one real compiler execution per fresh-dir build: duration_ns=2022065630, 924681032, 890555906 for the three builds above. Same sketch bytes, same flags, different directory, and the object is never reused.
This is the same mechanism #1347 documents for the framework stage (persist_failed: "same cache key produced different staged output bytes", DWARF DW_AT_comp_dir embedding the per-build path) reaching the sketch stage. #1347's investigation note already worked out that -fdebug-prefix-map is the portable flag (avr-gcc 7.3.0 has it, -ffile-prefix-map is GCC 8+ and would break every AVR build) and that the map target must be chosen in BuildLayout where the layout is known.
Worth ~1.3s here and far more on the framework stage. It carries a one-time full cold rebuild on every platform when the flag lands, so it should go in on its own.
2. compile-core-variant costs 1.14s having compiled nothing
Compiled 0 files and 1,137 ms of wall clock. Whatever this is doing — re-deriving command lines, re-stat'ing 118 objects, rebuilding the include set — it is not compiling. Should be near zero on a fully hydrated core cache.
3. core-cache-hydrate byte-copies 118 objects, 1.17s
framework core cache hydrate key=... copied=0 skipped=118 costs 1.17s even when it copies nothing on the second pass; on a fresh dir it copies all 118. zccache logs reflink_count=0, copy_count=2 on this filesystem, so the copies are not reflinked. Reflink (FICLONE / copy_file_range, CopyFileEx COW on ReFS) or hardlinks into the build dir would make this near-free on any CoW filesystem, and hardlinks work everywhere for read-only inputs.
4. pioarduino-resolve 295 ms is mostly a subprocess spawned on every build
Esptool::ensure_installed runs esptool version on every build to prove a cached binary is launchable (verify_esptool_binary, added for #1213). The tasmota build is a PyInstaller onefile — it unpacks itself to a temp dir and starts Python every time. 295 ms here; on Windows with Defender scanning the unpack, materially worse, and that is where the users in #1411/#1347 are.
The check is worth keeping, but the result should be stamped next to the install (e.g. a .verified marker keyed on the binary's mtime+size) rather than re-proven per build.
5. ~930 ms is unattributed
total=7175 minus the named phases leaves ~930 ms with no phase covering it — BuildContext::new_with_perf returns before pioarduino-resolve starts and the include-path/library-discovery work between phases is not timed. Worth adding phases before optimizing blind: "include paths: 375 total" and "compiled 42 framework built-in libraries" both happen in there.
What good looks like
Items 1–4 are ~3.5s of the 7.1s. A ~3s fresh-project build looks reachable without touching the linker, and the no-op path is already 0.3s after #1413.
Reproduction
mkdir -p blink/src && cd blink
cat > platformio.ini <<'INI'
[env:esp32_esp32_esp32s3_opi]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
build_flags = -DCORE_DEBUG_LEVEL=0 -DBOARD_HAS_PSRAM
board_build.memory_type = dio_opi
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
INI
printf '#include <Arduino.h>\nvoid setup() { pinMode(2, OUTPUT); }\nvoid loop() { digitalWrite(2, HIGH); delay(500); digitalWrite(2, LOW); delay(500); }\n' > src/main.cpp
FBUILD_PERF_LOG=1 fbuild build # phase summary lands in ~/.fbuild/prod/daemon/daemon.logMetadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTriage
Activity
zackees commentedon Oct 31, 2026
Item 2 (per-phase accounting) landed in #1816 (merged 7683e35) and the whole Dylint lane is green again via #1819.
What the fix does:
compile-core-varianthad been timing atokio::join!of core + sketch, so with a hydrated core cache the phase log attributed the sketch's ~660 ms to the core.compile-core/compile-sketchare now recorded from each set's own start (PerfTimer::recorded/activate), withcompile_phases_tests.rscovering the empty-core-leg case.Current numbers on
main(freshesp32-s3-devkitc-1dir, this host): cold ~5.4 s / hydrated ~2.9 s — not the 7.1 s in the report. Of the warm budget,link-convert-sizeis now the largest phase (~1.3-1.5 s) andboot-artifacts~380 ms; the previously "unaccounted" 930 ms is ~165 ms today. So the remaining win is in link/convert, not in the compile region.zackees commentedon Oct 31, 2026
Boot-artifact slice shipped as #1822; link is genuine ld work
Follow-up to the 10:08Z note ("the remaining win is in link/convert"). I re-measured both claims on current
main(esp32-s3-devkitc-1, brand-new dirs, 3 runs each).link-convert-sizesplit (temporary instrumentation,soldr cargo test-built binaries):Link is ~1.0-1.1 s of real
ldwork, not overhead. Running the exact captured link argv:-Wl,--crefdropped-Wl,-Map=droppedSo the 10.7 MB map + cross-reference costs only ~30-40 ms; there is no cheap link knob left at this layer.
convert(~390 ms) is the one esptool spawn that already has per-build-dir reuse (can_reuse_bin).The unclaimed 400 ms was
boot-artifacts. Every fresh dir re-spawnedesptool elf2imageforbootloader.bin(315 ms measured directly) andgen_esp32part.pyforpartitions.bin(~60 ms), although both are pure functions of immutable framework-package inputs. #1822 caches them content-addressed under the cache root (toolFileStamp+ parameters; CSV content hash), reusing the tool's own output byte-for-byte. Fallback to the tool on any miss, so it cannot fail a build.Measured across three fresh dirs:
boot-artifacts483 ms → 0 ms → 1 ms; total 4387 → 3459 ms;bootloader.bin/partitions.binmd5-identical in all three (andpartitions.bin== a directgen_esp32part.pyrun).Remaining fresh-dir budget after #1822 is dominated by link (~1.0 s), compile-sketch (~0.7 s) and hydrate (~80 ms warm) — link has no in-tree optimization left that I could measure, so the next real lever would be an lld/xtensa-ld replacement, not a flag change.