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

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions