In the split build, rten-ops-misc2 (control_flow, fft, non_max_suppression, random — about 1,400 non-test lines) was the last crate to finish and defined the critical path. That was out of proportion to its size, so I bisected it by feature:
build of rten-ops-misc2 |
wall | CPU |
|---|---|---|
--features fft,random |
4.20s | 8.93s |
--features random |
0.50s | 1.83s |
| no features | 0.49s | 1.72s |
So fft.rs — 436 non-test lines — accounts for 3.7s wall / 7.1s CPU on its own.
A standalone crate whose entire source is this:
pub fn plan(len: usize) -> Arc<dyn Fft<f32>> {
FftPlanner::<f32>::new().plan_fft_forward(len)
}
pub fn run(f: &dyn Fft<f32>, buf: &mut [Complex32]) { f.process(buf); }takes 3.61s wall / 7.20s CPU to build in release, and emits 288,842 lines of LLVM IR across 3,386 functions. rten-ops-fft emits 311,246 — so 93% of the codegen in rten's FFT crate comes from that one planner call, not from rten's code.
cargo llvm-lines attribution for rten-ops-fft:
| origin | LLVM IR lines | share |
|---|---|---|
rustfft::* |
274,508 | 88.2% |
rten's own fft.rs |
17,785 | 5.7% |
| everything else | 18,953 | 6.1% |
-Ztime-passes on the probe crate: 0.4s frontend, 0.2s codegen_to_LLVM_IR, 3.28s in the LLVM backend. It's not type-checking or trait resolution — it's LLVM optimizing a quarter-million lines of IR.
FftPlanner<T: FftNum> is generic over the scalar type, and its planning logic statically names every algorithm rustfft implements — radix-N butterflies, Bluestein, Rader, mixed-radix, and the SIMD variants. Monomorphization walks all of those reachable paths, so calling FftPlanner::<f32>::new() instantiates rustfft's entire algorithm zoo into your crate. rustfft's own prebuilt rlib (1.4s) contains almost none of this — it's all generic, so it lands downstream.
Two details make it worse than it needs to be:
- 62% of the whole crate's IR is
rustfft::neon::*(192,271 lines). - 64,166 of those lines are
NeonF64Butterfly*<f32>— the f64-lane NEON butterflies instantiated withT = f32. The planner selects between f32 and f64 paths with a runtime type check, which mono collection can't fold away, so both branches get instantiated. The f64-with-f32 half can never execute; LLVM optimizes and emits it anyway.
Same probe crate:
| variant | LLVM IR lines | wall | CPU |
|---|---|---|---|
FftPlanner (default) |
288,842 | 3.61s | 7.20s |
FftPlannerScalar |
93,274 | 1.25s | 3.21s |
rustfft with default-features = false |
93,352 | 1.30s | 3.36s |
And end-to-end on unsplit main + lto = "off", A/B interleaved, min of 3:
| config | time |
|---|---|
main + lto="off" |
14.71s |
main + lto="off" + rustfft default-features = false |
12.89s (−12.4%) |
That's a one-line change to the workspace dependency worth more than my entire crate split.
Isolating fft.rs into rten-ops-fft (depending only on rten-ops-base, not on the elem→math chain) doesn't reduce the work — it moves it. The crate now starts at t≈5.3 and runs to t≈12.0 in parallel with everything else, instead of sitting in the middle of a serial chain. That's the general shape of what crate splitting buys you here: better packing, not less work.
Three caveats:
- Both mitigations cost FFT throughput at runtime — the NEON butterflies are the fast path. For rten that's probably an easy trade (STFT/DFT are rarely a model's bottleneck), but it is a real one, and I haven't benchmarked it.
fftisn't in rten's default features, so library users who don't opt in never pay this. It hitsrten-cliandrten-examplesbecause they enableall-ops.- The cleanest fix is upstream: if rustfft exposed a non-generic
FftPlanner32, the instantiation would happen once inside rustfft's own rlib and every downstream crate would get it for free.