Files
TheM14 42d6da6302
ci / ubuntu-latest (push) Failing after 13s
ci / macos-latest (push) Has been cancelled
ci / windows-latest (push) Has been cancelled
Initial pure C++ implementation
2026-09-25 04:17:30 +08:00

129 lines
6.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SIMD and runtime dispatch
[中文](simd.md) · [Back to README](../README.en.md)
The heaviest loops in the binaural path (QMF analysis and synthesis, the hybrid
analysis low join, hybrid-domain path rendering, the ROOM FFT, the spherical
harmonic alignment) each have a runtime-dispatched vector implementation: one
binary carries several instruction-set variants, asks the CPU once at startup and
runs the widest one. **The output is byte-identical either way** — that is a hard
constraint, not a goal.
```text
JOC_SIMD=auto|scalar|sse2|avx2|avx512|neon pin one tier (used for verification)
JOC_SIMD_LOG=1 report the ISA each kernel actually got
```
## Why the split has to happen per translation unit
MSVC has no function-level attribute like `__attribute__((target("avx2")))`: one
`.cpp` file gets one `/arch`. So every ISA is its own translation unit with its own
`/arch:AVX2` or `/arch:AVX512` (GCC/Clang: `-mavx2` / `-mavx512f`), and
`dispatch.cpp` fills the function table at run time. The baseline units — the
dispatcher itself, the CPU probe and the scalar reference — carry **no** `/arch` at
all and stay on the SSE2 that x86-64 guarantees.
A trap from the history of this tree: `JOC_ENABLE_AVX2` used to be global, so
turning it on put AVX2 instructions into the very code paths that exist for older
CPUs. It now only selects whether the AVX2 unit is compiled in.
## Directory layout
One flat directory, **the instruction set in the file name and never in a
subdirectory** — that is how FLAC does it (`lpc.c` sits next to
`lpc_intrin_sse2.c`, `lpc_intrin_avx2.c` and `lpc_intrin_neon.c`, with the CPU
probe in its own `cpu.c`).
```text
src/simd/
simd.h the contract: Isa / Kernel / Kernels / dimensions
cpu_probe.{h,cpp} "can this machine run ISA X": CPUID+XGETBV / __builtin_cpu_supports / getauxval
dispatch.cpp policy: JOC_SIMD, the fallback ladder, the table, the log
kernels_scalar.cpp the reference (Isa::scalar; always built, always selectable)
kernels_intrin_avx2.cpp /arch:AVX2 -mavx2
kernels_intrin_avx512.cpp /arch:AVX512 -mavx512f
kernels_intrin_neon.cpp AArch64 default
```
The module lives at `src/simd/`, not `src/dsp/simd/`: `foundation/`, `hrtf/` and
`binaural/` all call into these kernels, so it is a cross-cutting layer rather than
a submodule of the DSP code (and `src/dsp/` held nothing else).
The header comment of `simd.h` carries the same module map; keep the two in sync
when the layout changes.
## Three rules
1. **Only `kernels_intrin_*.cpp` gets a wider flag.** `CMakeLists.txt` names those
files explicitly with `set_source_files_properties`; every other target stays on
the architecture's guaranteed ISA. A unit that goes wide without matching that
name fails `devtools/vec/isa_audit.ps1`.
2. **No dynamic initialisation inside an ISA unit.** Those objects are linked into
the same image as the baseline, so a global constructor would execute a wide
instruction before the dispatcher has looked at the CPU. Constant tables are
fine — they land in `.rdata`.
3. **Bit-exactness comes from the lane assignment, not from the ISA.** A lane may
only carry mutually independent outputs; the rounding sequence of a single
output, the separation of multiply and add (never an FMA) and the summation
order all stay exactly as `kernels_scalar.cpp` wrote them. Layout changes that
only reorder stored doubles (rank-minor basis tables, term-ordered tap tables,
stage-contiguous twiddle tables, band-major ROOM planes) are allowed.
## How the choice is made
`dispatch.cpp` parses `JOC_SIMD` first (forcing a tier this build or this machine
does not have prints a diagnostic and falls back, rather than pretending and
crashing), then walks `avx512 → avx2 → sse2 → neon` and picks, per kernel, the
widest implementation that is both compiled into this binary **and** runnable
here, falling back to the scalar reference. An AVX-512 unit therefore costs
nothing on a CPU without AVX-512; it is simply never selected.
On x86 the probe requires CPUID *and* XGETBV to agree: CPUID says the silicon can
do it, XCR0 says the OS saves the registers it needs. Either one alone is not
enough — using AVX without OS state support corrupts other threads across a
context switch. AArch64 needs no probe; ASIMD is the architectural baseline.
`sse2` is a selectable tier with **no unit of its own**, on purpose: a 128-bit SSE2
register is the register a scalar double already occupies, SSE2 cannot widen
double-precision arithmetic, and hand-written SSE2 would only add moves. The tier
resolves to the baseline unit.
## Effect
30-second reference cases, one binary with only `JOC_SIMD` switched (DSP stage,
`t_render_dsp`):
| Case | `scalar` | `auto` | DSP speed-up | End-to-end wall clock |
|---|---|---|---|---|
| Binaural Rosella | 1.256 s | **0.640 s** | **1.96×** | 1.690 → **0.941 s** |
| Binaural SOFA | 1.560 s | **0.654 s** | **2.39×** | 1.859 → **0.849 s** |
| Speaker 5.1 / 9.1.6 / ADM | — | — | 1.00× | no regression (these kernels are not on those paths) |
The vectorised loops themselves gain more: synthesis basis 5.89×, 13-tap low join
4.50×, SOFA QMF synthesis 4.27×, 128-point FFT 2.24×. The whole pipeline stops
short of 8× because a good part of the time goes to parameter setup, straight
copies and file writing — none of which has independent work items — and because
SOFA's 33 M sin/cos calls per sample cannot be vectorised under a byte-exactness
contract.
## Verifying a change
```powershell
$env:JOC_SIMD_LOG='1' # per-kernel ISA on this machine
$env:JOC_SIMD='scalar' # force the reference: hashes must not move
pwsh -NoProfile -File devtools\vec\isa_audit.ps1 # disassemble every .obj: 0 unguarded wide instructions
pwsh -NoProfile -File devtools\vec\sha_matrix.ps1 # 5 tiers x 2 renders against the reference digests
cmd /c devtools\vec\build_kernel_probe.bat # per-kernel byte digests (8 kernels)
```
## Adding an ISA
1. Write `kernels_intrin_<isa>.cpp`, implementing the slots you have and leaving the
rest `nullptr` — the dispatcher falls back per kernel (that is how
`qmf_synthesis_basis` is handled in the AVX-512 unit).
2. Add it to `JOC_SIMD_SOURCES` in `CMakeLists.txt` with `JOC_SIMD_HAVE_<ISA>=1` and
its flag, and extend `Isa`, `isa_rank`, `isa_compiled`, `isa_supported` and the
`JOC_SIMD` name table in `simd.h` / `dispatch.cpp`.
3. Verify: the kernel digests must match the scalar unit byte for byte, and the
reference renders must keep their SHA-256 on every tier.