Skip to content

Compile-time: deepen the crate split — break up fbuild-build (37.7K) and fbuild-packages (21.7K), the serial mid-chain giants #1008

Description

@zackees

Compile-time: deepen the crate split — break up fbuild-build (37.7K LOC) and fbuild-packages (21.7K LOC), the serial mid-chain giants

Sibling of soldr#1490 (soldr's monocrate un-collapse) — same zccache-inspired strategy, applied to fbuild's actual shape.

First, the publishing question (answered)

fbuild is NOT published to crates.io. Verified live: fbuild, fbuild-core, fbuild-cli all return "crate does not exist" from the crates.io API. Distribution is PyPI (fbuild 2.4.0 via release-auto.yml) + GitHub Releases; no workflow references cargo publish or CARGO_REGISTRY_TOKEN.

Consequences:

  1. No synthetic/amalgamated publish crate is needed. zccache's ci/publish_amalgamate.py exists solely to serve crates.io consumers a single self-contained zccache crate while keeping the 18-crate internal split for parallel compiles. With no crates.io surface, fbuild needs only the internal-split half of that strategy — which it already partially has.
  2. Hygiene now: no fbuild crate sets publish = false today, so every workspace member is accidentally publishable. Add publish = false to all crates/*/Cargo.toml (zccache marks every internal crate this way). One trivial PR.
  3. If fbuild ever wants a crates.io presence (cargo install fbuild — the name is currently free): adopt zccache's ci/publish_amalgamate.py pattern wholesale — a release-time script that copies each internal crate's src/ into a facade crate as modules, rewrites fbuild_core::crate::core:: paths, strips internal path-deps from the manifest, and asserts self-containment before cargo publish of exactly one crate. That is a documented, proven recipe; do not hand-maintain a parallel monocrate.

Why compiles are still slow despite the 13-crate split

The workspace is already split, but the dependency graph is a serial spine with two giants in the middle:

fbuild-core (11.4K) → fbuild-paths (1.0K) → fbuild-config (6.1K)
    → fbuild-packages (21.7K)  ←── giant #2
        → fbuild-build (37.7K)  ←── giant #1
            → fbuild-cli (13.9K) ∥ fbuild-daemon (16.2K)

(src LOC; side branches: fbuild-serial 7.7K hangs off core; fbuild-deploy 8.1K off packages+serial; fbuild-library-select 1.1K + fbuild-header-scan 1.0K off packages/paths; fbuild-python 3.3K; fbuild-test-support is dev-dep-only everywhere — verified.)

~59K LOC of the critical path is just two crates that compile strictly one-after-the-other, and every edit inside either one recompiles the whole crate plus cli + daemon. rustc's frontend is serial per crate — a one-line change in the teensy orchestrator recompiles all 37.7K LOC of fbuild-build.

Phase A — split fbuild-build into engine + parallel platform crates + facade

Measured internal structure (this is why the split is cheap)

A scan of crate::<module> references inside fbuild-build/src shows:

  • ENGINE → PLATFORM edges: zero. The shared engine (pipeline, compiler, compile_many, source_scanner, linker, build_fingerprint, compile_database, symbol_analyzer, shrink, framework_libs, framework_core_cache, script_runtime, flag_overlay, build_info, build_output, eh_frame_policy, zccache_embedded, arduino_props, compile_backend, parallel, perf_log, package_override, … ≈ 19K LOC) never reaches into a platform module. The only place that names platforms is lib.rs: the PlatformSupport trait (lib.rs:62) and the get_platform_support(Platform) factory (lib.rs:76, callers: fbuild-daemon/src/handlers/emulator/select.rs, fbuild-daemon/src/handlers/operations/deploy.rs). Platform itself lives in fbuild-core.
  • PLATFORM → ENGINE: pervasive and expected (18 distinct engine modules referenced) — platforms sit cleanly above the engine.
  • PLATFORM → PLATFORM: three patterns, all handleable:
    1. crate::esp32::mcu_config::DefineEntry — referenced by 8 other platforms. A shared type living in the wrong place. Move to the engine layer (e.g. mcu_config module), re-export from esp32::mcu_config for zero caller churn.
    2. esp8266 → esp32 (10 refs) — genuine reuse of esp32 tooling. Keep esp8266 and esp32 in the same crate.
    3. {stm32, rp2040, apollo3, nxplpc} → generic_arm (2–5 refs each) — ARM-family common code. Keep all ARM platforms in one crate with generic_arm.

Target shape

fbuild-build-engine (~19K)         all shared engine modules + PlatformSupport trait
                                   + mcu_config (DefineEntry moved from esp32)
   ├── fbuild-build-esp   (~5.7K)  esp32 (4,450) + esp8266 (1,242)
   ├── fbuild-build-arm   (~10.6K) generic_arm, stm32, rp2040, apollo3, nxplpc,
   │                               sam, nrf52, silabs, teensy, renesas
   └── fbuild-build-mcu   (~2.5K)  avr (1,258) + ch32v (1,264)
        ↓ (all three compile IN PARALLEL after engine)
fbuild-build (facade, ~200 LOC)    keeps the crate name; owns get_platform_support();
                                   re-exports everything at today's paths

The three platform crates depend only on fbuild-build-engine (plus the existing lower crates); they compile concurrently. fbuild-cli / fbuild-daemon manifests are unchanged — they keep depending on fbuild-build, and every existing path (fbuild_build::pipeline::…, fbuild_build::esp32::…, fbuild_build::PlatformSupport, fbuild_build::get_platform_support) keeps resolving through the facade's re-exports.

Mechanics (same invariants as soldr#1490 — read before editing)

  • M1 — moved trees stay nested under their module name. fbuild-build-esp/src/lib.rs = pub mod esp32; pub mod esp8266; — not flattened. All crate::esp32::… paths inside the moved files keep resolving.
  • M2 — crate-root re-exports satisfy cross-tree paths. Platform modules reference 18 engine modules as crate::<engine_mod>::…. Each platform crate's lib.rs adds pub use fbuild_build_engine::{pipeline, compiler, source_scanner, linker, /* …all 18 */}; so those paths compile unchanged. Same trick for crate::mcu_config after the DefineEntry move.
  • M3 — the facade preserves the public surface. fbuild-build/src/lib.rs becomes: pub use fbuild_build_engine::*; + pub use fbuild_build_esp::{esp32, esp8266}; + pub use fbuild_build_arm::{generic_arm, stm32, …}; + pub use fbuild_build_mcu::{avr, ch32v}; + the get_platform_support() factory (the ONE piece of code that must see both the trait and all implementations, so it lives at the top). If you find yourself rewriting use statements in cli/daemon, you've violated an invariant — stop and re-read.
  • Trait home: move PlatformSupport (and BuildOrchestrator if it also lives in lib.rs) into an engine module (e.g. engine::platform_support), re-export from the facade root so fbuild_build::PlatformSupport is unchanged. Platform crates implement the trait from fbuild-build-engine.
  • Dep manifests: compiler-error-driven from the workspace dep list; publish = false; [lints] workspace = true; prune with machete/udeps after green.

Steps (one PR each)

  • A0 — prep, in-crate, no new crates: (1) move esp32::mcu_config::DefineEntry (plus whatever the 8 references drag with it) to a shared mcu_config engine module; leave pub use at the old path. (2) Move PlatformSupport/BuildOrchestrator trait definitions from lib.rs into an engine module with root re-exports. (3) Re-run the adjacency scan; require ENGINE→PLATFORM = 0 and PLATFORM→PLATFORM only within the planned crate groupings.
  • A1 — extract fbuild-build-engine (git mv the ~22 engine modules; facade pub use fbuild_build_engine::*). Update ci/check_workspace_crates.py APPROVED_MEMBERS + root Cargo.toml members + CLAUDE.md in the same PR (see "crate-gate" below).
  • A2 — extract the three platform crates (can be one PR; they're mechanical after A1).
  • A3 — move fbuild-build's integration tests: keep them in the facade crate (they see everything via re-exports; zero import churn expected).

Phase B — split fbuild-packages into fetch-primitives + parallel library/toolchain + facade

Measured internal adjacency of fbuild-packages/src:

module LOC refs
library/ 10,398 → extractor(5), downloader(5), http(4), cache(1)
toolchain/ 4,391 → http(2), cache(1), library(1), downloader(1), extractor(1)
disk_cache/ 2,906 → cache(1)
lnk/ 1,399 → disk_cache(3), extractor(1), downloader(1), library(1)
downloader, extractor, http, cache, install_lock (top-level files) ~1,770 leaf primitives

Target shape:

fbuild-packages-fetch (~4.7K)  http, downloader, extractor, cache, install_lock, disk_cache
   ├── fbuild-library   (~10.4K)  library/
   └── fbuild-toolchain (~5.8K)   toolchain/ + lnk/
        ↓ (parallel)
fbuild-packages (facade)        keeps the name; re-exports everything at today's paths
  • B0 — prep: identify and break the two single-reference back-edges toolchain → library and lnk → library (grep -rn "crate::library::" crates/fbuild-packages/src/toolchain crates/fbuild-packages/src/lnk). Each is one item — move it down to the fetch layer or duplicate the tiny type, whichever is honest.
  • B1 — extract the three crates + facade, same M1–M3 mechanics. Consumers (fbuild-build, fbuild-deploy, fbuild-cli, fbuild-daemon, fbuild-library-select, fbuild-test-support) keep their fbuild-packages dep and paths unchanged.

Resulting critical path

Before:  core → paths → config → packages(21.7K) → build(37.7K) → daemon(16.2K)   (~94K serial)
After:   core → paths → config → pkgs-fetch(4.7K) → [library ∥ toolchain]
             → build-engine(19K) → [esp ∥ arm ∥ mcu] → facades → [cli ∥ daemon]   (~79K, wide middle)

Bigger than the clean-build math is the incremental scope: an edit to the teensy orchestrator today recompiles 37.7K + cli + daemon; after, it recompiles ~10.6K (fbuild-build-arm) + a ~200-LOC facade + cli/daemon. An edit to library/ stops recompiling toolchain-side consumers and vice versa. Record cargo build --timings (clean + one-file-touch in esp32/, teensy/, library/) before Phase A and after each phase; post numbers here.

crate-gate / monocrate policy

ci/check_workspace_crates.py (enforced by crate-gate.yml, policy from #560) forbids new workspace crates without maintainer sign-off: "If a new crate is genuinely unavoidable … add it to APPROVED_MEMBERS in the same PR, with a one-line rationale."

This issue is that sign-off for the seven new members (fbuild-build-engine, -esp, -arm, -mcu, fbuild-packages-fetch, fbuild-library, fbuild-toolchain). Each extraction PR updates APPROVED_MEMBERS + root members + the CLAUDE.md policy text together. Update the policy wording to reflect the refined rule: modules-first for new functionality, but compile-parallelism splits are sanctioned when backed by --timings data — the original policy goal (no drive-by crates) survives; the gate stays.

Gates for every PR

Repo-standard: ./test, clippy -D warnings, fmt, dylint, uv run --script ci/check_workspace_crates.py locally, and the --timings measurement posted in the PR. Route all Rust commands through the repo's enforced wrapper per CLAUDE.md. One phase per PR; don't start N+1 before N merges. Re-run the adjacency scan after each phase and paste it in the PR description.

Out of scope / follow-ups

  • Splitting fbuild-daemon (16.2K) or fbuild-cli (13.9K): they're at the top of the DAG (nothing waits on them except the bins), so splitting buys much less. Revisit with post-Phase-B timings.
  • Splitting fbuild-build-engine further (pipeline vs analyzers like symbol_analyzer/shrink/compile_database): possible second wave if engine dominates timings.
  • Reserving the fbuild crates.io name (requires publishing a minimal placeholder): maintainer decision, independent of this work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions