Conversation
The scalar planner spells out two pieces of arithmetic around its own base-choice rules, and the SIMD planners are about to need the same two. Both come out into building blocks that carry no policy: PrimeFactors::get_power_of(value) replaces the hand-written find_map over get_other_factors(). get_power_of_two and get_power_of_three already existed, this covers 5, 7 and anything later. RadixFactor::split_cross_len(len) is the 7/6/5/3-then-4s-last split of a cross-FFT length. It returns None where the scalar planner asserted, and plan.rs keeps the panic by calling .expect() on it. What is left in the planner is only its own decisions: which base, and whether Radix4 takes the length. Nothing about the algorithm set is shared, so a planner can use these without being tied to any other planner's choices. Recipes are unchanged: FftPlannerScalar dumped for every length from 1 to 20000, f32 and f64, all 40000 identical before and after.
The RadixN drivers about to be added reuse the size-7 butterfly for their radix-7 cross-FFT layer, so the generated structs and their constructors have to be visible outside their own module. Changes the shared template and regenerates all three backends, so the autogeneration check keeps passing. The neon, sse and wasm_simd modules are themselves private, so this exports nothing new from the crate.
Mirrors src/algorithm/radixn.rs: one flat transpose down to a base FFT, then in-place cross-FFT layers over a single packed twiddle array. The difference is that the layers use column butterflies, so a whole vector of columns goes through each butterfly call. The algorithm lives in src/simd_radixn.rs as SimdRadixN<V, T>, generic over a RadixNVector trait, so the other two SIMD backends can reuse it by supplying two trait impls. NEON is the first, at 224 lines of impl plus a type alias. - generic over f32 and f64. Twiddles are stored as vectors, and the trait pairs each vector type with its radix 3, 5, 6 and 7 butterflies. Radix 2 and 4 were already vector-generic. - the base may be a composite recipe with its own scratch, which the existing boilerplate macros hardcode to zero, so Fft is implemented on SimdRadixN directly instead. - an f32 vector holds two complex numbers, so every cross-FFT layer needs an even column count. An odd base folds in a spare factor of two, and odd lengths, which have none to spare, keep the mixed radix path. src/simd_planner.rs holds the planning arithmetic, again so the other two backends get it unchanged: design_radixn, design_butterfly_product, and complex_per_vector. Each planner owns a private Recipe enum, so these hand back plain numbers and the caller builds its own recipe. The planner's dispatch chain ends up in the same order src/plan.rs uses, with the butterfly pair search ahead of RadixN. That moves the pair search out of the final else, so it now sees lengths with trailing_zeros() >= 6 that it never used to. Two of them change plan, and both get faster. NEON, forward, 10*len buffer, M1, ns/iter, mean of two runs: len dtype old new speedup 320 f32 8730 7444 1.17x 320 f64 12235 10806 1.13x 576 f32 15690 13124 1.20x 576 f64 22265 19226 1.16x Recipes audited over every length from 1 to 20000 for f32 and f64: 26323 change, 26290 of them by gaining a RadixN, and all 33 of the rest are those same two plans propagating through nested designs. Nothing else moves. Measured on an M1, f64, at 1008, 1050, 1080, 1296, 10368 and 100800: 1.46x to 2.03x faster than the previous planner, and 1.13x to 1.53x faster than the best MixedRadix tree this backend could build before.
The shared layer in simd_radixn.rs and simd_planner.rs landed in a shape SSE can use unchanged, so this is additive apart from the two cfg gates in lib.rs, which grow an x86_64 arm. SSE is the most mechanical of the ports. SseVector already mirrors NeonVector method for method, the SseArray and SseArrayMut load and store traits match, and the butterfly structs for radix 3, 5, 6 and 7 have the same perform_fft_direct and perform_parallel_fft_direct shapes, so the two RadixNVector impls are the NEON ones with the names changed. sse_radixn.rs comes out at the same 224 lines as neon_radixn.rs. The planner gets the same treatment: a Recipe::RadixN variant, thin wrappers over simd_planner::design_radixn and design_butterfly_product, and the same dispatch chain order. Recipes audited over every length from 1 to 20000 for f32 and f64, and the change is exactly the one NEON saw: 26290 lengths gain a RadixN, and all 33 of the remaining differences are the butterfly-pair reorder at 320 and 576 propagating through nested designs. The resulting designs are identical to NEON's at all 40000, which is the check that the shared layer really is shared. Both reorder lengths measured faster on a Ryzen 7 250, forward, 10*len buffer, ns/iter, median of five runs: len dtype old new speedup 320 f32 9729 5642 1.72x 320 f64 11225 8123 1.38x 576 f32 18582 10532 1.76x 576 f64 31156 15097 2.06x That is a good deal more than the 1.13x to 1.20x the same plan change gave on an M1, so the size of the win is microarchitecture specific. Length 512, whose plan does not change, measures the same in both trees to within 0.5%, which rules out a build difference behind these numbers. A later run on the same machine put 576 f64 at 0.78x rather than 2.06x, so that one number is not settled and is being re-measured; the other three have been stable across runs.
The third and last SIMD backend. Same algorithm and the same planner branch as the other two, which the backends being structurally identical makes largely mechanical. Two things differ. The butterflies for radix 3, 5 and 6 are written against raw v128, while WasmVector32 and WasmVector64 are newtypes over it, so the column butterflies unwrap and rewrap around them; butterfly 7 already speaks the wrapper types. And wasm_simd_planner globs its own module, so mod.rs re-exports the new one. The planner tests copied from sse also get their names fixed to test_plan_wasm_simd_* and test_wasm_simd_*, matching what the other two call the same test bodies. All five RadixN tests are #[wasm_bindgen_test], not #[test]. The wasm-bindgen harness only collects the former, so a plain #[test] here is silently dropped on the one backend where these are slowest. wasm-pack test --node lists 73 passing and 1 ignored, the ignored one being the six-layer case. Recipes audited over every length from 1 to 20000 for f32 and f64, under node via wasm32-wasip1: the same 26290 lengths gain a RadixN and the same 33 are the butterfly-pair reorder at 320 and 576, with nothing else moving. The designs are identical to NEON's and SSE's at all 40000, so all three backends now plan these lengths the same way.
Move the factor dispatch outside the chunk loop, the way algorithm/radixn.rs already does it, so each layer runs one monomorphized loop over its chunks. Measured perf-neutral on NEON, this is for consistency.
factor_transpose recomputes every column's reversed index on each call, with an out-of-line reverse_remainders call per column and two hardware divides, and chunks_exact_mut adds one more divide per cross layer. None of that scales with the length, so it is a large share of a short FFT, and more so on x86 where a 64-bit divide takes tens of cycles. Compute the reversed columns once in new() and walk the layer chunks with split_at_mut. The per-element work is unchanged. factor_transpose itself is left alone, since the scalar RadixN still uses it.
…imdVector The trait gets its own simd_vector.rs so other algorithms can be written against it. Each backend's impls move next to its own vector trait impls in *_vector.rs, together with the fft_helper forwarding macro, leaving *_radixn.rs with just the type alias and tests.
It is factoring arithmetic, so it belongs with PrimeFactors rather than on RadixFactor in common.rs.
… up front The perform methods were one line forwards from the Fft closures. The column loop now computes how many pairs and whether a column is left over before it starts, instead of testing vcol + 2 <= num_vector_columns.
The comment said from_fn was newer than the MSRV, but it has been stable since 1.63 and the MSRV is 1.77. Perf-neutral on NEON: 0.995x to 1.002x over 10 lengths from 120 to 100800, f32 and f64.
The harness, the tuning feature and the op count derivation. The lab notes, the weight grid scripts and the one-off analysis scripts stay on counted_cost_spike.
Each planner now enumerates the recipes that could compute a length, prices them with a cost model read off the source, and keeps the cheapest. Inner FFTs go through the recipe cache, and the best cost per length is cached next to it, so the search recurses over divisors. The fixed planner stays reachable behind the tuning feature for comparison while this is a draft. wasm_simd borrows NEON's instruction counts as a placeholder.
The harness now asks the fixed and the estimating planner for their picks instead of enumerating and pricing on its own, and prices offline dumps with the library's cost model, so there is one copy of it. Adds a survey command that sweeps random lengths and reports percentiles of the runtime change. Drops the measured-table model the counted one replaced.
A survey of 300 random lengths up to 1M on the M1 moves the estimating planner's runtime over the fixed planner's from geomean 0.950, p90 1.163 to 0.883, p90 1.015 in f64, and from 1.016, p90 1.260 to 0.924, p90 1.062 in f32.
The cost model priced a MixedRadix transpose of a large buffer the same as a RadixN cross layer, which gathers its rows from inside the chunk it is already working on and so keeps its locality at any size. Only the transposes are charged, and only above a working set of 256 KiB, which is the smallest last-level cache worth planning for: no pick below length 16385 moves, so the weights fitted by sweeping 1 to 1000 are untouched. On 300 random lengths up to 1M on an M1, against the fixed planner: f32 worst goes from 1.485 to 1.178 and f64 p90 from 1.015 to 1.000, at unchanged geometric means of 0.93 and 0.88.
Two terms, fitted on a Raspberry Pi 5 and checked on an M1, since they are the first weights in this model whose optimum differs by machine. Above a working set of 256 KiB an access costs dram_pass, and a transpose's costs dram. They move together: penalising ordinary passes alone makes a MixedRadix wrapped around a smaller radix recipe look good, and those measure worse on both machines. On the Pi this removes losses of 4.19x in f64 and 3.81x in f32, where the model had been computing a whole transform as one Bluestein's with an inner FFT far larger than cache. radix_call charges what a RadixN or Radix4 execution costs regardless of length: the call, the scratch split, the layer setup and the virtual call into the base FFT. Nothing charged it on NEON, where radixn_extra is zero, so lengths 14 and 21 took a RadixN measuring 1.32x slower.
Halves the M1's f32 losses beyond 5% over the validation set, 27 to 15, for an unchanged loss count on the Pi 5. Rewrites COST-MODEL.md for the model as it now lives in the library, and records what it scores on both NEON machines.
…e cannot fix them
NEON gathers a complex number with one lane load where SSE assembles it from scalar halves, so the same Rader's permutation costs far more per element there. Over the SSE validation set this takes f64 losses beyond 5% from 35 to 20 and f32 from 13 to 7, with wins up from 94 to 110.
Measured on the ThinkCentre, the first SSE numbers since the memory terms landed. rader_index was refitted to 2 on NEON evidence alone and is wrong there by a factor of ten. strided, radixn_extra, dram and dram_pass all survive the recheck, the last two on a machine whose cache sits between the other two.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft, and stacked on #179, which is also still a draft. A PR here cannot be based on a branch in my
fork, so the diff below contains #179's commits as well. The estimating planner starts at ba8d179,
"Bring over the planner tuning harness", and is about 4700 of the 7000 lines.
On this description: I asked Claude for a detailed description of how the planner works, and it
went all in. So this is a lot more text than I would have written myself. The numbers are real and
reproducible with the commands under Results. If the length puts you off, read the TL;DR below, and
then "Where it loses" and "Known gaps" if you want the caveats. If there is a number you want that
is not here, just ask and I will measure it.
TL;DR
and wasm_simd only; the scalar and AVX planners are untouched.
more than 20%, for a geomean of 0.891. 33 lengths lose by more than 5%. The same range in f64
wins at 343 and loses at 2.
0.88 to 0.98 of the fixed planner's runtime. Sampling 334 lengths up to a million instead, it
wins by more than 5% at 64 to 161 of them and loses by more than 5% at 3 to 20.
which is the one loss mode that needs the cache size to fix properly.
What it replaces is a fixed decision tree. That fixed planner stays in the tree behind the
non-default
tuningfeature so the two can be measured against each other, which is what everynumber here is. The losses fall into three modes, all described below, and none is fixed in this PR.
Results
ratiothroughout is the estimating planner's runtime over the fixed planner's, so below 1 isfaster. A win or loss means more than 5% in that direction. Machines: Apple M1 (12 MB L2),
Raspberry Pi 5 (2 MB L3), i3-8100T (6 MB L3). Repeat runs hold the geomeans to about 0.001 but move
the win and loss counts by one or two, so read any count near the 5% threshold as approximate.
Survey: 334 lengths drawn from 1 to 1,000,000
survey --count 350 --seed 1 1..1000000, the same lengths on every machine.SSE f64 is the weakest cell and the only one whose p90 is above 1. It agrees with the fixed planner
at 175 of 334 lengths, wins at 64 and loses at 20.
Dense sweep: every length from 1 to 1000, on the M1
The survey samples, so it can miss a whole neighbourhood. This trades range for completeness: a much
narrower band, but every length in it. Note that no length here comes near
cache_elems, so lossmode 1 below cannot appear at all, and these rows are not comparable in difficulty with the survey.
Where the wins come from
Three recipe changes account for most of them.
(Precompute the Rader's permutation #178), Rader's is the better answer far more often than the fixed planner assumes. Length 523
goes from
bs(523,r4(3,b24))torad(rn(6.3,b29)), 2.03x faster on the M1 in f64. It compounds:933 and 622 win 1.72x and 1.71x purely because their inner 311 changed.
rn(7,mrs(b3,b9))becomesrn(7.3,b9)at 189, 1.80x. In f32 thisclass is large: 448 goes from
rn(2.4.4,gts(b2,b7))torn(7.2,b32), 2.07x.mrs(b6,b8)tor4(1,b12), 1.66x in f64and 2.05x in f32, and 96 from
mrs(b8,b12)tor4(1,b24), 1.97x in f32.The largest single win is 135721 on the Pi 5 in f64, 5.46x, where
bs(135721,r4(7,b24))becomesrad(rn(6.6.5.2,gts(b13,b29))). The same length wins 2.32x on the M1 and 1.78x on the i3, which isthe shape of the whole Bluestein's-to-Rader's class: biggest where cache is smallest.
Where it loses
Broken out by the largest prime factor of the length, which is what separates the loss classes:
Each cell is geomean / losses beyond 5%. There are three distinct loss modes.
1. One large Bluestein's whose inner FFT no longer fits in cache. This is the worst class and
every worst case in the table above is in it. The model takes a single Bluestein's over the whole
length where the fixed planner splits first and keeps its inner FFTs cache resident:
mr(rad(rn(5.3,gts(b2,b11))),bs(2339,r4(4,b24)))bs(774209,r4(8,b24))mr(bs(25819,r4(6,b16)),b9)bs(232371,r4(7,b32))rn(3.3,bs(25819,r4(6,b16)))bs(232371,r4(7,b32))mr(b5,bs(193873,r4(7,b24)))bs(969365,r4(8,b32))mr(bs(205399,r4(7,b32)),b3)bs(616197,r4(8,b24))At 774209 the model's inner FFT is 1572864 points, 12.6 MB in f32. Timing the pieces on the Pi puts
out-of-cache work at 0.79 to 0.98 ns per unit of cost against 0.19 to 0.21 for cache-resident work,
so it is still under-priced about four times over after
dram_pass. The same pick is correct onthe M1, where 12.6 MB fits in a 12 MB L2. The multiplier is not the machine-specific part, the
threshold is, and
cache_elemsis a compile-time constant here. See "Known gaps".2. The general-versus-small transpose crossover, in f32. 29 of the 33 losses in the f32 dense
sweep are one substitution, the model taking the general
MixedRadixorGoodThomaswhere thefixed planner takes the
Smallvariant, at lengths 195 to 992:general_rowwas fitted against twelve measured general-over-small ratios in f64 and put thecrossover near length 200. In f64 this mode produces no losses at all over the same 1000 lengths;
in f32 the crossover clearly belongs higher. It is one weight, and making it per element type is
the obvious fix, but it wants its own measurement pass and is not in this PR.
3. Very short lengths where RadixN replaces a butterfly pair. The only two f64 sweep losses:
21 at 1.09x (
gts(b3,b7)torn(3,b7)) and 20 at 1.06x (gts(b4,b5)torn(2,b10)).radix_callprices the generic driver's fixed overhead but evidently not quite high enough at the bottom end.
Both stay under 10%, and they are the entire f64 loss list for lengths 1 to 1000.
Plan time
Enumerate-and-price costs far more than the fixed planner's plan. That is the wrong denominator: a
caller pays plan plus build, and building dominates. On the M1 in f64:
Planning alone is 113x dearer, and that is 4.3% of plan-plus-build over this set. The worst relative
case is a short length with many divisors (96, 1000), where the absolute cost is single-digit
microseconds. Butterfly lengths and powers of two short-circuit enumeration entirely, which is why
32, 64, 256, 4096 and 65536 stay flat. These are cold-start figures with a fresh planner per
length; a planner reused across lengths shares inner lengths through its caches.
Reproducing all of the above
The tool crate selects the SIMD feature for the host, neon on aarch64 and sse on x86-64. Do not
build the library with default features for this, or the planner would pick AVX and measure the
wrong backend.
How it works
Three steps, and only the middle one is new. All three are in
design_fft_for_lenin each planner.Shapevalues: a top-levelalgorithm plus the lengths of its inner FFTs, but not the inner recipes.
CostModel::cost.The model lives at
src/simd/simd_estimate.rsand is shared by all three SIMD planners. The scalarand AVX planners do not use it and are untouched.
What a cost is
One unit is one issued arithmetic instruction. A cost is not nanoseconds and nothing converts it
to time, because only the ranking within one length is ever used. That is why one weight set travels
across machines of different clock speeds.
A pure operation count, the
FFTW_ESTIMATEanalogue, scores worse than the fixed planner. Thememory term is what makes the model work; it is not a refinement.
The memory term
Each pass is charged per element it touches, times a multiplier for how it walks memory:
stridedpermutedSequential and strided accesses are divided by the number of complex numbers in a vector; permuted
ones are not, because a gather computes an address per element and cannot fill a vector. Above
cache_elemscomplex numbers an access costsdram_passextra, and a transpose's accesses costdram, because a cross layer keeps its locality inside the chunk it is already working on while atranspose walks the whole rectangle.
What each node costs
Butterfly(len)2*lensequentialRadix4 { k, base_len }repsx base, plus per layerlen/4butterfly4 and 3 twiddles2*lenpermuted digit reversal,2*lenstrided per layer, plusradix_callRadixN { factors, base_len }repsx base, plus per layerlen/rx (butterflyr+r-1twiddles)radixn_extraper element per layerMixedRadixhx left +wx right, pluslentwiddle multipliesGoodThomashx left +wx right, no twiddlesRaders { len }lentwiddles,len * rader_indexBluesteins { len, inner_len }inner_lenpointwise multipliesThree terms exist only to encode a decision the model would otherwise be unable to make:
smallversus general. TheSmallvariants calltranspose_small, a naive strided doubleloop, so their transposes are priced
Permuted. The general variants hand the job to thetransposecrate, which tiles the rectangle and paysgeneral_rowper row for it. That makesthe general form cheaper per element and dearer per row, which is a crossover. Without the
per-row term the model has only per-element costs and would take the general form at every
length.
small_row * max(width - height, 0)is the only thing separatingmrs(A,B)frommrs(B,A).transpose_small's outer loop runswidthtimes, so bothSmallvariants change by exactly
width - heightwhen the pair is reversed. Charging the differencekeeps a square pair free and leaves the better ordering at the cost it had before the term.
radix_callis what a RadixN or Radix4execution costs regardless of length, and
radixn_extrais what its cross layers cost perelement over
Radix4doing the same work. Without the first, short lengths take a RadixN thatmeasures 1.32x slower than a table-driven
GoodThomasAlgorithmSmall.Assumptions
These are the places where the planner asserts something rather than measuring it. Each was checked
against measurement, and the check is the thing to redo if a kernel changes.
answers immediately at both. Over lengths 8 to 128 on NEON the bare butterfly is fastest at every
one, and at every power of two from 64 up, across four datasets, the fixed planner's Radix4 is
exactly the fastest measured candidate. This shortcut is also what keeps plan time flat at
exactly the lengths where plan time is the largest fraction of plan-plus-build.
the candidate count at a highly composite length for almost no information: the smaller-width
ordering is the better one in 90 to 97% of measured pairs for the
Smallvariants, and thegeneral variants are usually indistinguishable.
factor has one, the whole length decomposes into butterflies, and across four sweeps of 1 to 1000
not one such length was won by Bluestein's. It is offered at composite lengths: 671 = 11 x 61
measured 1.46x faster as Bluestein's than as a split around a Rader's for 61.
walks itself, never at the size of the transform it is nested inside. This is what makes the
search affordable: each planner memoises the best cost per length beside its recipe cache and
recurses over the divisors of a length rather than over whole trees. If a future term ever
makes a node's cost depend on its parent, that memoisation becomes wrong, and the failure would
be a subtly bad inner recipe rather than anything that trips a test.
not move a cost cannot move a plan.
on the grounds that a split with a tiny side is mostly its large side plus a transpose.
Everything structural is kept.
constants. Where a weight's optimum differs between machines it was swept on all of them and set
to the value whose worst machine looks best.
closely, but that is the same machine, and nothing has been counted or fitted for wasm itself.
This is the weakest assumption in the PR and it is marked as a placeholder in the source.
What is tuned from measurements
The distinction the whole approach rests on. Most of the model is read off the source and must be
maintained when the source changes. A small set of weights is fitted against measurements and must
be rechecked when the machines change.
Read off the source, never fitted
src/neon/*.rs,src/sse/*.rs, by hand; derived inOP-COUNTS.mdmul_complex,column_butterfly4Fitted, and where each came from
stridedpermutedgeneral_rowsmall_rowtranspose_small, 1.48 ns on an i3, 0.7 to 1.0 on an M1. A tie-break with a derivation; anything from 2 to 24 scores the samerader_indexradixn_extraradix_callcache_elemsdram,dram_passdramanddram_passare the first weights whose optimum depends on the machine rather than thebackend, because they price the memory system rather than the instruction set. They have to move
together: charging ordinary passes without charging transposes just as hard makes a MixedRadix
wrapped around a smaller radix recipe look good, and those recipes measure worse on both machines.
What to redo when the kernels change
In this order, from free to expensive. The first four need no machine.
verifyat a spread of lengths. It checks every enumerated candidate against an f64 referenceDFT, which catches an illegal spec such as a Bluestein's inner shorter than
2n - 1. Never trusta timing taken before
verifyis clean.explainon a recipe whose cost you can predict.picksbefore and after, over tens of thousands of lengths in seconds. It says how far thechange reaches before anything has been measured.
scoreagainst an existing dump, to see whether the ranking moved.sweep 1..1000and asurveyup to a million if it did. Both cost-model defects found duringdevelopment were invisible to the 33- and 44-length tuning sets and showed up only in a sweep.
A dump goes stale when the algorithm it measured changes. Every dump taken before #178 holds the
old, slow Rader's, so it cannot judge any Rader's decision.
Known gaps
above. The fix, when it is wanted, is to read the last-level cache size at construction and set
cache_elemsfrom it:/sys/devices/system/cpu/cpu0/cache/on Linux, andhw.perflevel0.l2cachesizeon macOS, whose flathw.*cachesizekeys report the E-core sizes.At a 12 MiB threshold,
dram_pass3 costs the M1 one loss instead of seven while still fixingthe Pi, so giving each machine its own cache size makes the conflict disappear.
the 33 f32 sweep losses.
mr(A,B)andmr(B,A)get the same cost apart from thesmall_rowtie-break, yet many reversed pairs measure more than 2% apart. This is the clearestunexploited improvement and it is derivable from the code.
cost difference is a constant multiple of
len, so its sign cannot vary with length. Astride-aware rewrite aimed at this regressed both backends and was reverted.
for SSE, so an SSE weight and an i3-8100T weight are still the same column.
rader_indexshowswhy that matters: 2 on NEON against 20 on SSE is a gap explained by how each backend gathers a
complex number, but only a second x86 machine can confirm that reading.
What is in the diff
src/simd/simd_estimate.rssrc/neon,src/sse,src/wasm_simdplannersset_estimatingfor comparisonsrc/tuning/tuningfeature,#[doc(hidden)]tools/planner_tuning/COST-MODEL.md,OP-COUNTS.mdandREADME.mdThe harness drives the planners through the
tuningfeature, so recipes are built by the planners'own code and priced by the library's own cost model: what gets timed is exactly what a planner would
construct.
dumptimes every candidate at a length and writes a TSV;score,costsandexplainthen replay that file offline, so iterating on the cost model needs no machine after the first run.
The exhaustive enumeration in
src/tuning/is deliberately separate from the planner's, becausescoring a pick needs alternatives no planner would propose.
Open question
The fixed planner is still in the tree, reachable only from the
tuningfeature. If this lands itshould probably come out, but keeping it is what makes every number above reproducible, so I have
left that decision for review.