Skip to content

One chain form: every smooth length runs on RChain - #30

Merged
tknopp merged 1 commit into
mainfrom
os/rchain-only
Nov 13, 2026
Merged

tknopp merged 1 commit into
mainfrom
os/rchain-only

Conversation

@oscardssmith

@oscardssmith oscardssmith commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

slop:

What

Every smooth length now runs on RChain (radix sequence and stage records as data, codelets from the fixed set). The chain type with the radix sequence in its type (Chain, used for smooth lengths up to 128) is removed, together with

  • its @generated run! / run_lanes! and the body builders behind them,
  • the 583 compiled chain methods in kernel_methods.jl,
  • the six dispatch trees (small_*_run!) from an RChain of a short length into the compiled chains,
  • the compilation at the first plan of a short dimension with a large stride (its radix sequence was outside the compiled set).

Nothing is compiled per length any more.

Why this is possible now

The run-time chain used to cost 9–20 ns more per call, which is why the compiled chain was kept (decision of 2026-11-09). That cost was not the choice of the codelet at run time (about 1 ns per stage) but the code around it:

  • GC frames for references loaded from the chain inside the stage loop. The stage records are now one flat vector that carries the twiddle addresses and is read through a raw pointer.
  • The copy of an immutable chain to the stack of each caller. RChain is a mutable struct with const fields, passed as one pointer.
  • One more call level per stage. The codelet switch is inlined into the loop.
  • A line of one stage is one codelet call without the loop; the rows of a plane run inside one call.

Measurements

AMD EPYC 9354 (AVX-512), Julia 1.13.3, one thread, plan_fft and mul!, 92 cases (Float64 and Float32; lines of 2 to 4096, planes of 8 × 8 to 128 × 128, cubes of 8³ to 64³, in-place, backward and real transforms), 8 rounds in alternating processes, median time over the median time on main:

this branch / main
geometric mean 0.973
cases below 0.95 27
cases above 1.05 8
lines of one stage (2, 4, 16) 0.80–0.95
8 × 8, 8 × 8 × 8, 7 × 12, 5 × 64 0.85–0.91
worst case (37 Float32 rows of 64) 1.13

The cases above 1.05 are short lines of two or three stages: Float32 20, 32, 96, 100 (1.09–1.10), 37 rows of 64 (1.13), rfft of 64 (1.07 and 1.09), Float64 12 × 7 (1.05). The stage loop runs about 50 instructions per stage around the codelet, without mispredicted branches: 2.4 ns per stage against direct calls of the two kernels of Float32 32.

The machine was shared during the measurement. Over the 8 rounds, the slowest time of main over its fastest is 1.04 in the median over the cases, 1.19 at the 90th percentile and 1.55 at most.

Size:

main this branch
methods of the package 2385 1796
package image (AVX-512 host) 72 MB 61 MB
libfftl.so (x86-64-v3, --trim=safe) 55.6 MB 49.5 MB

Things to look at

  • Addresses in the stage records. A package image keeps Ptr values as they were, so a plan stored in a user's precompiled image would come back with stale twiddle addresses. A chain remembers its own address when its records are linked and links again when it finds itself elsewhere (a copy, a plan loaded from an image). Covered by a test; deepcopy of a plan works after the original is freed.
  • TypedKernels. Chain was also the tag of the default plans in Bluestein, RealLine and R2RLine; the tag is now this abstract type.
  • Tests. The three tests that compared Chain with RChain check against the direct DFT instead. Quick selection passes with 2 threads (10950 pass, 10 broken as before). libfftl builds and fftw3_ctest.sh passes (3940 comparisons, 0 failed).

Not done

  • Not verified on aarch64.
  • The full weekly test lists and the documentation build were not run.
  • The M1 numbers on the package-size page were not measured again.

The decision and its evidence are in docs/src/explanation/design-decisions.md ("One chain form"), the stage loop in implementation.md.

🤖 Generated with Claude Code

The radix sequence and the stage records of a Stockham chain are data, and
the codelets come from the fixed set. The chain type with the radix sequence
in its type (Chain, used for smooth lengths up to 128) is removed, with its
generated run!/run_lanes!, the 583 compiled chain methods and the dispatch
trees from an RChain of a short length into them. Nothing is compiled per
length any more.

The run-time chain used to cost 9-20 ns more per call. That was not the
choice of the codelet at run time (about 1 ns per stage) but the code around
it, which the stage loop now avoids:
- a line of one stage is one codelet call;
- the stage records are one flat vector that carries the twiddle addresses
  and is read through a raw pointer, so the loop needs no GC frame;
- RChain is a mutable struct with const fields, passed as one pointer
  instead of being copied to the stack of each caller;
- the codelet switch is inlined into the loop;
- the rows of a plane run inside one call.

The addresses in the stage records belong to one chain object. A chain
remembers its own address when it is linked and links again when it finds
itself elsewhere (a copy, or a plan loaded from a package image, which keeps
Ptr values as they were).

The tag of the default plans in Bluestein, RealLine and R2RLine is the
abstract type TypedKernels.

AMD EPYC 9354 (AVX-512), one thread, plan_fft and mul!, 92 cases, 8 rounds
in alternating processes, median time over that of the previous code:
geometric mean 0.973, 27 cases below 0.95, 8 above 1.05 (short lines of two
or three stages, at most 1.13). 1796 methods instead of 2385, package image
61 MB instead of 72 MB, libfftl.so 49.5 MB instead of 55.6 MB. Not verified
on aarch64.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@tknopp

tknopp commented Nov 13, 2026

Copy link
Copy Markdown
Member

Checked on aarch64 (Apple M1 Max, Julia 1.13, one thread, plan_fft + mul!, fresh process per branch, best of 2 rounds, time over main). The quick selection passes on top of #31 with 2 threads (10956 pass, 10 broken as before).

case #30 / main
Float64 16 0.96
Float32 32 / 64 / 96 1.10 / 1.02 / 1.06
Float64 64 / 128 1.06 / 1.03
Float32 8 × 8, Float64 12 × 7 0.91 / 0.94
Float64 1024, 64 × 64; Float32 4096 0.98 / 1.03 / 0.98

Same picture as on the EPYC: one-stage lines and small planes gain, a few short Float32 lines of two or three stages lose 6–10 %. Fine with me for dropping per-length compilation.

One point on the addresses in the stage records: when a plan comes back from an image and several threads call it at once, each of them sees linked(P) == false and runs link_calls! on the same vectors. They write the same values, so in practice nothing goes wrong, but it is a data race. It would be cleaner to link once when the plan is first used through a lock, or at least to state in the code that concurrent relinking writes identical records. This can be a follow-up.

@tknopp
tknopp merged commit a7c165b into main Nov 13, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants