Skip to content

perf(esp32): fresh-project build is 7.1s for one file; only 25% is linking #1414

Open

Description

@zackees

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.log

Found while verifying #1411 / #1347.

Activity

  1. zackees commented on Oct 31, 2026

    @zackees
    MemberAuthor

    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-variant had been timing a tokio::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-sketch are now recorded from each set's own start (PerfTimer::recorded / activate), with compile_phases_tests.rs covering the empty-core-leg case.

    Current numbers on main (fresh esp32-s3-devkitc-1 dir, this host): cold ~5.4 s / hydrated ~2.9 s — not the 7.1 s in the report. Of the warm budget, link-convert-size is now the largest phase (~1.3-1.5 s) and boot-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.

  2. zackees commented on Oct 31, 2026

    @zackees
    MemberAuthor

    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-size split (temporary instrumentation, soldr cargo test-built binaries):

    TIMING link=1008.0 convert=374.5 size=24.5
    TIMING link=1091.7 convert=390.1 size=12.8
    

    Link is ~1.0-1.1 s of real ld work, not overhead. Running the exact captured link argv:

    variant ms (3 runs) ELF
    baseline 1007 / 980 / 986 e1a18f50…
    -Wl,--cref dropped 986 / 1019 / 1022 e1a18f50… (identical)
    -Wl,-Map= dropped 945 / 976 / 984 e1a18f50… (identical)

    So 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-spawned esptool elf2image for bootloader.bin (315 ms measured directly) and gen_esp32part.py for partitions.bin (~60 ms), although both are pure functions of immutable framework-package inputs. #1822 caches them content-addressed under the cache root (tool FileStamp + 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-artifacts 483 ms → 0 ms → 1 ms; total 4387 → 3459 ms; bootloader.bin/partitions.bin md5-identical in all three (and partitions.bin == a direct gen_esp32part.py run).

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions