diff --git a/rational/CLAUDE.md b/rational/CLAUDE.md index b7308da..d179ab8 100644 --- a/rational/CLAUDE.md +++ b/rational/CLAUDE.md @@ -41,7 +41,11 @@ stated design divisor, exactly sixteen exported symbols; `CApi.*` pins it bit fo `basic_chain`), the ctypes binding and `notebooks/matrix.ipynb` executed through the C ABIs, and the icount ratchet (`bench/icount/`, `bench/baselines.json`, marker `RATIONAL_ICOUNT_DONE`; a change that moves a count beyond ±3 % re-records the baselines in the same PR). The codegen -levers PLAN.md defers after M6 wait for a consumer. +levers after M6 are measured and recorded in PLAN.md section 6: the Helium Q15 dot and the Q15 +decimators' per-branch quantization shipped (a Q15 decimator's table is its band's interpolator +table, the 1 / M in `tap::dsp::finalize_divided`; `basic_stage::k_table_gain` and +`finalize_output()` state it), the sparse rows and the symmetry-halved table were declined on +their numbers. ## The charter constraints (load-bearing) diff --git a/rational/PLAN.md b/rational/PLAN.md index 0eb8057..5bc0656 100644 --- a/rational/PLAN.md +++ b/rational/PLAN.md @@ -19,6 +19,7 @@ matrix, layout, test strategy, milestones, non-goals and risks. | v0.7 | 2026-10-02 | M4 landed: `chain.h` (`basic_chain` over DspTap's `chain<>`, each stage designed at its **design divisor** — its lower rate over the chain's lowest, 3.1 — and the 20 named multi-stage chains), `tools/coverage/matrix.py` committed and re-run on the pinned lengths with the per-stage relaxation pins (`design.h`'s relaxation tables, verified by `test_design.cpp`), section 3 regenerated from measured counts (35 of 182 rows changed chain, the ledger in section 8 S1), `test_matrix.cpp` over all 182 rows (every row measured, none skipped: 2.2 had landed), DspTap's `chain<>::flush` (tap/DspTap#52) | | v0.5 | 2026-10-02 | M2 landed: the design spike (`notebooks/design_spike.ipynb`, executed) pins N for every band ∈ {2, 3, 4, 6, 8} × profile on a 16384-point grid, the mixed ratios' taps per phase, measured worst stopband and ripple; `profile` carries the pins and `test_design.cpp` enforces them; the M2 table below is filled from measurements. Finding: the designer's 1024-point default grid under-pins two rows (tap/DspTap#50 made the search grid a parameter) | | v0.9 | 2026-10-02 | M6 landed: the C ABI (`capi/`, `libtap_sr_rational_capi`: the 28 named within-family chains as stable `TAP_SR_RATIONAL_*` constants, one stage of the vocabulary at a stated design divisor for chains through `bridge`, exact accounting, latency and MACs as rationals; exactly sixteen exported symbols), the ctypes binding and `matrix.ipynb` executed through the C ABIs (all 728 row × profile pins reproduced), the icount ratchet (twelve workloads, `RATIONAL_ICOUNT_DONE`, baselines on M33 / M55 / Hexagon, gated two-sided at ±3 % in CI), family version 0.5.0 | +| v0.10 | 2026-10-02 | The codegen levers after M6, each measured (section 6): the Helium Q15 dot (tap/DspTap#53, shipped: the M55 Q15 workloads −21 … −48 %); the Q15 decimators quantized per branch with the 1 / M in the single rounding (tap/DspTap#54's `finalize_divided`, shipped: ↓6 / ↓8 attain −71.5 / −71.7 dB at `economy`, the Q15 decimator's table is its band's interpolator table); the sparse rows for the mixed ratios going down (built, measured, declined: Q15 +22 … +60 %); the symmetry-halved table (declined: memory only, 1.6 KB at most). The 2/3 workloads join the ratchet | **Status.** This draft is the reviewed-plan deliverable that the family plan's "Next — `rational` (separate plan)" entry asks for @@ -666,7 +667,7 @@ rational/ │ ├── ratio.h ratio, ratio_traits, the 2^a·3^b / gcd static_asserts (R1) │ ├── design.h profile (R5), design_stage(profile) over tap::dsp::nyquist.h │ ├── stage.h basic_stage: table (per phase or per decimating branch, trimmed to the nonzero span; -│ │ symmetry halving deferred to the codegen levers after M6, as bridge's M7 lever 3), the +│ │ symmetry halving measured out after M6: section 6, the levers), the │ │ schedule, process/pull/flush/reset, the exact-rational latency │ ├── chain.h basic_chain: tap::dsp::chain over basic_stage, each stage at its design │ │ divisor (3.1); the 20 named multi-stage chains of 3.3 / 3.4 (up_2_up_2 … down_3_down_8_down_2) @@ -858,13 +859,13 @@ MACs per superblock): | Stage | Q15 stopband eco (dB) | Q15 RMS vs double eco (dBFS) | Q15 / float MACs eco | Q15 stopband tr (dB) | Q15 RMS tr (dBFS) | Q15 / float MACs tr | |---|---|---|---|---|---|---| | ↑2 | -72.3 | -95.8 | 23 / 23 | -77.3 | -93.4 | 55 / 63 | -| ↓2 | -71.6 | -92.8 | 23 / 23 | -69.2 | -89.3 | 53 / 63 | +| ↓2 | -72.3 | -97.5 | 23 / 23 | -77.3 | -95.7 | 55 / 63 | | ↑3 | -70.2 | -93.5 | 45 / 45 | -76.1 | -91.2 | 89 / 101 | -| ↓3 | -72.4 | -90.8 | 45 / 45 | -63.8 | -86.4 | 82 / 101 | +| ↓3 | -70.2 | -96.8 | 45 / 45 | -76.1 | -95.7 | 89 / 101 | | ↑6 | -71.5 | -92.4 | 121 / 121 | -78.5 | -90.7 | 219 / 251 | -| ↓6 | -64.7 | -86.5 | 119 / 121 | -62.9 | -85.6 | 193 / 251 | +| ↓6 | -71.5 | -98.6 | 121 / 121 | -78.5 | -98.3 | 219 / 251 | | ↑8 | -71.7 | -92.3 | 169 / 169 | -78.2 | -90.2 | 303 / 351 | -| ↓8 | -62.9 | -86.8 | 165 / 169 | -61.1 | -85.6 | 259 / 351 | +| ↓8 | -71.7 | -97.9 | 169 / 169 | -78.2 | -99.5 | 303 / 351 | | 3/2 | -70.2 | -93.4 | 45 / 45 | -76.1 | -91.1 | 89 / 101 | | 2/3 | -69.7 | -91.0 | 65 / 65 | -73.9 | -90.6 | 125 / 149 | | 4/3 | -71.7 | -93.6 | 67 / 67 | -77.2 | -90.6 | 131 / 151 | @@ -874,44 +875,63 @@ MACs per superblock): Two limits, stated rather than papered over (both pinned by the battery): -- **A Q15 decimator's stopband is format-limited.** Its table is h / M, - the whole filter quantized as one row summing to 2^14 so that DC stays - exact, so each coefficient is M times smaller than an interpolator's on - the same Q1.14 LSB: the quantization noise floor of the response rises by - about 20 log10 M. ↓2 and ↓3 hold 70 dB at `economy`; ↓6 reaches −64.7 dB - and ↓8 −62.9 dB. Interpolators and mixed ratios hold within about a dB of - 70 at the 70 dB tiers. `transparent` buys nothing in Q15 (−61 … −78 dB, - and the outer taps of its long designs round to zero and are trimmed: - Q15 MACs below float's), so at Q15 `economy` is the pairing, as `bridge` - found (`../bridge/tests/test_converter_fixed_point.cpp`). The 70 / 120 dB - promises of 2.3 and section 3 are float's and Q31's. A lever exists for - the decimators — quantize each branch at unity and fold the 1 / M into a - power-of-two finalize where M is one (↓2, ↓8), or a Q1.14 multiply-back - where it is not — and is deferred with the codegen levers after M6: it - changes the bit-pinned tables and the rounding point, and no consumer has - asked. +- **A Q15 decimator's stopband was format-limited; the lever after M6 + lifted it.** At M5 its table was h / M, the whole filter quantized as one + row summing to 2^14 so that DC stayed exact, so each coefficient was M + times smaller than an interpolator's on the same Q1.14 LSB and the + quantization noise floor of the response rose by about 20 log10 M: ↓6 + reached −64.7 dB and ↓8 −62.9 dB at `economy`, ↓8 −61.1 dB at + `transparent`. Since the lever (tap/DspTap#54's + `tap::dsp::finalize_divided`) each of the M branches of the unscaled + design (each sums to 1) is quantized at its own unity, which makes the + Q15 decimator's table bit for bit the Q15 interpolator's of the same band + (`FixedPoint.Q15DecimatorTableIsTheInterpolatorsTable`), and the 1 / M + rides the single rounding: a 15- or 17-bit shift for ↓2 and ↓8, an + exact-at-DC multiply-back for ↓3 and ↓6. The table above is measured + after it: every decimator attains its band's interpolator stopband (↓6 + −71.5 dB, ↓8 −71.7 dB at `economy`; −76 … −79 dB at `transparent`), the + Q15 RMS deviation from double fell by 5–12 dB, full-scale DC is still + exact, and ↓3 at `economy` moved from −72.4 to −70.2 dB (still the 70 dB + tier). Q31 and float are unchanged. What remains format-limited in Q15 + is `transparent` (−76 … −79 dB, not 120; the outer taps of its long + designs round to zero and are trimmed: Q15 MACs below float's) and the + mixed ratios going down (−68.2 … −69.7 dB at the 70 dB tiers: their + L-phase rows are normalized at the branch-spread level, the same + coefficient-shrinking at a smaller factor), so at Q15 `economy` is the + pairing, as `bridge` found (`../bridge/tests/test_converter_fixed_point.cpp`). + The 70 / 120 dB promises of 2.3 and section 3 are float's and Q31's. - **A mixed ratio going down multiplies its band's structural zeros.** 2/3, 3/4 and 3/8 run the L-phase machine over a band-M design: the zeros stride by M, the rows by L, so every row crosses every M-th zero and the trimmed - dot still holds them — 20 of 65 MACs per superblock for 2/3 at `economy`, - 20 of 87 for 3/4, 22 of 191 for 3/8. Interpolators, decimators and the - mixed ratios going up (band L, every zero in the centre phase) multiply - none. Sparse rows are a codegen lever after M6, as the symmetry-halved - table is; the matrix's MAC figures count what runs today. + dot holds them — 20 of 65 MACs per superblock for 2/3 at `economy`, 20 of + 87 for 3/4, 22 of 191 for 3/8. Interpolators, decimators and the mixed + ratios going up (band L, every zero in the centre phase) multiply none. + The sparse-row lever was built and measured after M6, and declined: input + i on sub-line i mod M makes each row's M residue classes of lag + contiguous, so the zeros' class drops out (MACs per output fall to the + design's nonzero count, 2/3 at `economy` 32.5 → 22.5; no chain of the + matrix changes), with float, Q15 and Q31 outputs bit-identical. But the + per-output bookkeeping — M delay lines, a short sub-dot per class — costs + more than the third of the MACs it saves wherever the dot is cheap: the + 2/3 workload at `economy` ran −22 % (M33) and −25 % (Hexagon) in float + but +13 % on the M55, and +22 % / +60 % / +24 % in Q15 on M33 / M55 / + Hexagon, the flagship embedded profile. The matrix's MAC figures count + what runs: the dense rows. **The icount baselines, measured** (M6, `bench/icount/`: 2 s of stereo at 48 kHz streamed in 32-frame blocks, counted under QEMU by the family's -plugin, `bench/baselines.json`; the README carries the full table): +plugin, `bench/baselines.json`, as re-recorded after the levers; the +README carries the full table, the 2/3 workloads included): | Workload | Cortex-M33 | Cortex-M55 | Hexagon | |---|---:|---:|---:| -| `down2_float_eco` | 366,459,700 | 24,577,869 | 72,734,422 | -| `down2_q15_eco` | 47,494,712 | 19,806,582 | 18,554,089 | +| `down2_float_eco` | 366,459,676 | 24,433,741 | 72,735,414 | +| `down2_q15_eco` | 47,494,140 | 19,807,000 | 18,558,545 | | `up2_float_eco` | 732,658,864 | 43,442,565 | 137,930,738 | | `up2_q15_eco` | 67,354,778 | 34,150,590 | 28,133,275 | | `up3_float_eco` | 1,407,620,075 | 75,330,937 | 254,903,952 | | `up3_q15_eco` | 92,695,694 | 51,156,121 | 39,793,932 | -| `construct_q15_eco` | 774,741 | 33,098 | 183,854 | +| `construct_q15_eco` | 777,433 | 34,522 | 193,048 | One finding, recorded for the codegen levers, and the first lever it pulled: at M6, Q15 on the M55 was no faster than float (↓2 25.07 M against @@ -925,9 +945,20 @@ above to the counts shown — ↓2 −21 %, ↑2 −28 %, ↑3 −32 %, the by-4 Hexagon count unchanged (bridge's Q15 workloads fell 55–62 % and async's Q15 pipelines 21–42 % on the same pin). Q15 is now 19 % (↓2) to 32 % (↑3) under float on the M55; what remains per output is the stage's own -bookkeeping around the dot. The sparse rows, the symmetry-halved table and -the Q15 decimators' per-branch quantization are the levers still open, each -measured against these baselines. +bookkeeping around the dot. + +**The codegen levers after M6** (one lever per change, measured, as +`bridge`'s M7 campaign): the Helium Q15 dot above (shipped); the Q15 +decimators' per-branch quantization (shipped, a quality lever: the limit +above, lifted); the sparse rows for the mixed ratios going down (built, +measured, declined: the second limit above); and the symmetry-halved table +(declined unbuilt). Halving stores each mirrored phase once and dots it +backward (`tap::dsp::dot_row_reversed`, bit-identical), so it saves table +memory only — and this engine's tables are small (the largest stage, 3/8 at +`transparent`, holds 399 coefficients: 1.6 KB in float, 0.8 KB in Q15), +against `bridge`'s 147 / 160 phases, while on the M55 the reversed Q15 dot +reads its row through a gather, slower than the contiguous load. A +memory-bound consumer can reopen it with these numbers. **Order and gating.** M0 lands in DspTap and the family bumps its pin (`../CLAUDE.md`, "Substrate discipline"); M1–M6 are done; the 2.2 diff --git a/rational/README.md b/rational/README.md index d743ec1..c4a6ee5 100644 --- a/rational/README.md +++ b/rational/README.md @@ -33,8 +33,10 @@ sample-format traits, the dot kernels and the row-sum quantization). > PLAN.md section 6): exact-unity rows and full-scale DC in Q15 and Q31, > bit-pinned tables, saturation without wrap, Q31 within 3.4e−9 of double; > Q15 is format-limited, and its numbers are stated per stage — at Q15 use -> `economy`, and note that a Q15 decimator by 6 or 8 attains −65 / −63 dB -> of stopband, not 70. The C ABI, the executed coverage-matrix notebook and +> `economy`, where every interpolator and decimator attains 70 dB of +> stopband (a Q15 decimator quantizes each branch at unity and divides by M +> in its one rounding, so ↓6 and ↓8 attain −71.5 / −71.7 dB, as ↑6 and ↑8 +> do) and the mixed ratios going down −69.1 … −69.7 dB. The C ABI, the executed coverage-matrix notebook and > the instruction-count ratchet landed at M6 (below). The > plan is authoritative: charter, the decisions R1–R16, the generated > matrix, layout, test strategy, non-goals and risks. @@ -172,13 +174,15 @@ Executed instructions per fixed workload (`rational/bench/icount/`), measured un | Workload | Cortex-M33 | Cortex-M55 | Hexagon | |---|---:|---:|---:| -| `construct_q15_eco` | 774,741 | 33,098 | 183,854 | -| `down2_down2_q15_eco` | 62,508,360 | 28,178,778 | 22,251,668 | -| `down2_float_eco` | 366,459,700 | 24,577,869 | 72,734,422 | -| `down2_float_tr` | 955,278,690 | 47,997,517 | 174,664,319 | -| `down2_q15_eco` | 47,494,712 | 19,806,582 | 18,554,089 | -| `down3_float_eco` | 476,573,580 | 31,908,454 | 90,345,997 | -| `down3_q15_eco` | 51,977,458 | 21,227,446 | 19,350,211 | +| `construct_q15_eco` | 777,433 | 34,522 | 193,048 | +| `down2_down2_q15_eco` | 62,510,665 | 28,180,208 | 22,260,862 | +| `down2_float_eco` | 366,459,676 | 24,433,741 | 72,735,414 | +| `down2_float_tr` | 955,278,962 | 47,853,525 | 174,664,455 | +| `down2_q15_eco` | 47,494,140 | 19,807,000 | 18,558,545 | +| `down3_float_eco` | 476,578,253 | 31,908,695 | 90,345,995 | +| `down3_q15_eco` | 51,980,891 | 21,247,090 | 19,802,880 | +| `ratio23_float_eco` | 611,615,915 | 33,245,849 | 127,126,322 | +| `ratio23_q15_eco` | 52,779,137 | 18,442,245 | 20,006,956 | | `up2_float_eco` | 732,658,864 | 43,442,565 | 137,930,738 | | `up2_float_tr` | 1,961,674,119 | 89,901,988 | 340,655,706 | | `up2_q15_eco` | 67,354,778 | 34,150,590 | 28,133,275 | diff --git a/rational/bench/baselines.json b/rational/bench/baselines.json index 119093d..e9fef5c 100644 --- a/rational/bench/baselines.json +++ b/rational/bench/baselines.json @@ -1,12 +1,14 @@ { "hexagon": { - "construct_q15_eco": 183854, - "down2_down2_q15_eco": 22251668, - "down2_float_eco": 72734422, - "down2_float_tr": 174664319, - "down2_q15_eco": 18554089, - "down3_float_eco": 90345997, - "down3_q15_eco": 19350211, + "construct_q15_eco": 193048, + "down2_down2_q15_eco": 22260862, + "down2_float_eco": 72735414, + "down2_float_tr": 174664455, + "down2_q15_eco": 18558545, + "down3_float_eco": 90345995, + "down3_q15_eco": 19802880, + "ratio23_float_eco": 127126322, + "ratio23_q15_eco": 20006956, "up2_float_eco": 137930738, "up2_float_tr": 340655706, "up2_q15_eco": 28133275, @@ -14,13 +16,15 @@ "up3_q15_eco": 39793932 }, "m33": { - "construct_q15_eco": 774741, - "down2_down2_q15_eco": 62508360, - "down2_float_eco": 366459700, - "down2_float_tr": 955278690, - "down2_q15_eco": 47494712, - "down3_float_eco": 476573580, - "down3_q15_eco": 51977458, + "construct_q15_eco": 777433, + "down2_down2_q15_eco": 62510665, + "down2_float_eco": 366459676, + "down2_float_tr": 955278962, + "down2_q15_eco": 47494140, + "down3_float_eco": 476578253, + "down3_q15_eco": 51980891, + "ratio23_float_eco": 611615915, + "ratio23_q15_eco": 52779137, "up2_float_eco": 732658864, "up2_float_tr": 1961674119, "up2_q15_eco": 67354778, @@ -28,13 +32,15 @@ "up3_q15_eco": 92695694 }, "m55": { - "construct_q15_eco": 33098, - "down2_down2_q15_eco": 28178778, - "down2_float_eco": 24577869, - "down2_float_tr": 47997517, - "down2_q15_eco": 19806582, - "down3_float_eco": 31908454, - "down3_q15_eco": 21227446, + "construct_q15_eco": 34522, + "down2_down2_q15_eco": 28180208, + "down2_float_eco": 24433741, + "down2_float_tr": 47853525, + "down2_q15_eco": 19807000, + "down3_float_eco": 31908695, + "down3_q15_eco": 21247090, + "ratio23_float_eco": 33245849, + "ratio23_q15_eco": 18442245, "up2_float_eco": 43442565, "up2_float_tr": 89901988, "up2_q15_eco": 34150590, diff --git a/rational/bench/icount/CMakeLists.txt b/rational/bench/icount/CMakeLists.txt index 10c335d..13cfb0f 100644 --- a/rational/bench/icount/CMakeLists.txt +++ b/rational/bench/icount/CMakeLists.txt @@ -4,7 +4,9 @@ # 5's: the by-2 and by-3 stages both ways in float and Q15 at economy, the # by-4 chain (down 2 . down 2) in Q15, the by-2 pair in float at # transparent, and construction alone (the Q15 by-4 chain's two designs and -# tables, no streaming) as its own scenario from the first baseline. +# tables, no streaming) as its own scenario from the first baseline; the +# mixed ratio 2/3 (48 -> 32 kHz) in float and Q15 at economy joined after +# M6, when the sparse-row lever was measured on it (PLAN.md section 6). set(_rational_icount_scenarios up2_float_eco:0 down2_float_eco:1 @@ -17,7 +19,9 @@ set(_rational_icount_scenarios down2_down2_q15_eco:8 up2_float_tr:9 down2_float_tr:10 - construct_q15_eco:11) + construct_q15_eco:11 + ratio23_float_eco:12 + ratio23_q15_eco:13) foreach(_sc IN LISTS _rational_icount_scenarios) string(REPLACE ":" ";" _parts "${_sc}") diff --git a/rational/bench/icount/icount_main.cpp b/rational/bench/icount/icount_main.cpp index 981a018..b655ce7 100644 --- a/rational/bench/icount/icount_main.cpp +++ b/rational/bench/icount/icount_main.cpp @@ -11,7 +11,7 @@ // TAP_SR_RATIONAL_SC: 0 up2_float_eco, 1 down2_float_eco, 2 up3_float_eco, // 3 down3_float_eco, 4 up2_q15_eco, 5 down2_q15_eco, 6 up3_q15_eco, // 7 down3_q15_eco, 8 down2_down2_q15_eco, 9 up2_float_tr, 10 down2_float_tr, -// 11 construct_q15_eco. +// 11 construct_q15_eco, 12 ratio23_float_eco, 13 ratio23_q15_eco. #include #include #include @@ -115,6 +115,10 @@ namespace { return stage_workload(tr); #elif TAP_SR_RATIONAL_SC == 10 return stage_workload(tr); +#elif TAP_SR_RATIONAL_SC == 12 + return stage_workload(eco); +#elif TAP_SR_RATIONAL_SC == 13 + return stage_workload(eco); #else // Construction alone: the Q15 by-4 chain's two designs (pinned // lengths, no search) and quantized tables, then one output frame. diff --git a/rational/include/tap/sr/rational/stage.h b/rational/include/tap/sr/rational/stage.h index aff00aa..521d88e 100644 --- a/rational/include/tap/sr/rational/stage.h +++ b/rational/include/tap/sr/rational/stage.h @@ -39,8 +39,16 @@ // (the single phase sums to 1), for a mixed ratio each L-phase normalized at // the branch-spread level, bridge's design.h argument — and the structural // zeros stay exactly 0 in every format. Fixed-point rows are quantized with -// quantize.h's row-sum preservation per phase (per whole filter for a -// decimator), so unity DC survives the format exactly. +// quantize.h's row-sum preservation per phase (per whole filter for a Q31 +// decimator), so unity DC survives the format exactly. A Q15 decimator is the +// exception: Q1.14 cannot hold h / M at the precision h needs (each +// coefficient M times smaller on the same LSB costs about 20 log10 M dB of +// stopband), so its table is the unscaled design, each of its M branches +// quantized at its own unity sum — bit for bit the Q15 interpolator's table +// of the same band — and the 1 / M is applied in the single rounding +// (tap::dsp::finalize_divided: a shift for M = 2, 8, an exact-at-DC +// multiply-back for M = 3, 6). k_table_gain states what the rows sum to; +// finalize_output() is the stage's rounding point. // // Contract, as numbers (PLAN.md 2.5, R7, R9, R10): // - zero-primed and causal; outputs_for(n) and frames_needed(k) are exact @@ -63,10 +71,10 @@ // every row sums to exact unity in the format, so full-scale DC of // either sign comes out at exactly full scale; full-scale drive // saturates in the trait's finalize and never wraps; Q31 tracks double -// within 3.4e-9 of full scale; Q15 is format-limited — RMS -85.6 to -// -95.8 dBFS from double, and a decimator's quantized table (h / M) -// attains about 20 log10 M dB less stopband than an interpolator's -// (by 8 at economy: -62.9 dB), stated per stage in PLAN.md; +// within 3.4e-9 of full scale; Q15 is format-limited — RMS -87.1 to +// -99.8 dBFS from double, attained stopband -68.2 to -78.5 dB, a +// decimator's equal to the interpolator's of its band (by 8 at economy +// -71.7 dB), stated per stage in PLAN.md; // - MACs: no structural zero enters the dot of an interpolator, a // decimator or a mixed ratio going up; a mixed ratio going down (band // M, rows of stride L) multiplies the band's zeros its rows cross. @@ -76,6 +84,7 @@ #include #include #include +#include #include #include #include @@ -140,6 +149,26 @@ namespace tap::sr::rational { static constexpr std::size_t k_down = traits::k_down; ///< M static constexpr std::size_t k_band = traits::k_band; ///< B = max(L, M) static constexpr bool k_is_decimator = traits::k_is_decimator; + /// A Q15 decimator quantizes each of its M branches at the branch's + /// own unity sum and divides the summed branches by M in the single + /// rounding (tap::dsp::finalize_divided): Q1.14 cannot hold h / M at + /// the precision h needs (see the file header). + static constexpr bool k_branch_quantized = k_is_decimator && std::is_same_v; + /// What the table's rows sum to, in units of the format's unity: 1, + /// or M for a Q15 decimator (M branches each at unity), which the + /// single rounding divides back out. + static constexpr std::size_t k_table_gain = k_branch_quantized ? k_down : 1; + + /// The stage's single rounding point, accumulator -> sample: the + /// trait's finalize, or finalize_divided by M for a Q15 decimator. + static S finalize_output(typename tap::dsp::sample_traits::accum acc) noexcept { + if constexpr (k_branch_quantized) { + return tap::dsp::finalize_divided(k_down)>(acc); + } + else { + return tap::dsp::sample_traits::finalize(acc); + } + } /// Rows of the table: L phases, or the M branches of a decimator. static constexpr std::size_t k_rows = k_is_decimator ? k_down : k_up; @@ -366,13 +395,33 @@ namespace tap::sr::rational { // The whole filter at sum 1, quantized as ONE row so the // fixed-point sum lands on unity exactly, then scattered to // the M branches (branch j: taps j + s M, s ascending = - // older, so the row is stored reversed: oldest first). - std::vector scaled(n); - for (std::size_t i = 0; i < n; ++i) { - scaled[i] = h[i] / static_cast(k_down); - } + // older, so the row is stored reversed: oldest first). In + // Q15 each branch of the unscaled design (every branch of a + // Nyquist filter sums to 1) is quantized on its own at unity + // instead, and the 1 / M is applied in the finalize. std::vector q(n); - tap::dsp::quantize_row_preserving_sum(scaled, q); + if constexpr (k_branch_quantized) { + std::vector branch((n + k_down - 1) / k_down); + std::vector qb(branch.size()); + for (std::size_t j = 0; j < k_down; ++j) { + const std::size_t len = (n - j + k_down - 1) / k_down; // taps j, j + M, ... + for (std::size_t s = 0; s < len; ++s) { + branch[s] = h[j + s * k_down]; + } + tap::dsp::quantize_row_preserving_sum(std::span(branch.data(), len), + std::span(qb.data(), len)); + for (std::size_t s = 0; s < len; ++s) { + q[j + s * k_down] = qb[s]; + } + } + } + else { + std::vector scaled(n); + for (std::size_t i = 0; i < n; ++i) { + scaled[i] = h[i] / static_cast(k_down); + } + tap::dsp::quantize_row_preserving_sum(scaled, q); + } m_row_len = (n + k_down - 1) / k_down; // 2 m m_rows.assign(k_rows * m_row_len, static_cast(0)); for (std::size_t j = 0; j < k_rows; ++j) { @@ -501,7 +550,7 @@ namespace tap::sr::rational { acc = tap::dsp::accumulate_row(acc, m_rows.data() + j * m_row_len + m_first[j], window, m_count[j]); } - out[c] = tr::finalize(acc); + out[c] = finalize_output(acc); } } else { diff --git a/rational/tests/test_fixed_point.cpp b/rational/tests/test_fixed_point.cpp index eaa19b6..3b36fe2 100644 --- a/rational/tests/test_fixed_point.cpp +++ b/rational/tests/test_fixed_point.cpp @@ -7,9 +7,11 @@ // the vocabulary at every named profile: // // - every row of every table sums to exact unity in the format (a phase -// row of an interpolator or mixed ratio to 2^14 / 2^30; a decimator's -// whole filter, quantized as one row, to the same), so DC gain is exactly -// 1 and full-scale DC comes out at exactly full scale from every phase; +// row of an interpolator or mixed ratio to 2^14 / 2^30; a Q15 +// decimator's every branch to 2^14, its finalize dividing by M; a Q31 +// decimator's whole filter, quantized as one row, to 2^30), so DC gain is +// exactly 1 and full-scale DC comes out at exactly full scale from every +// phase; // - the tables are bit-pinned (FNV-1a-64 per ratio, profile and format); // - the structural zeros never enter the dot of an interpolator, a // decimator or a mixed ratio going up: every stored row is trimmed to its @@ -21,10 +23,12 @@ // - Q31 tracks the double golden model within a tenth of float's floor; // - Q15 is format-limited, and its limits are stated per stage: the RMS // deviation from double on the reference noise, and the stopband the -// quantized Q1.14 table attains (a decimator's table holds h / M, M times -// smaller than an interpolator's coefficients on the same LSB, so its -// attained stopband loses about 20 log10 M dB: by 8 at economy reaches -// -62.9 dB, not 70; the 70 dB / 120 dB promises are float's and Q31's); +// quantized Q1.14 table attains (a decimator's table is its band's +// interpolator table, each branch at unity with the 1 / M in the +// finalize, so it attains what the interpolator does — by 8 at economy +// -71.7 dB; quantized as h / M in one row, as before the lever, it lost +// about 20 log10 M dB: -62.9 dB; the 70 dB / 120 dB promises are float's +// and Q31's); // - full-scale drive saturates, never wraps, in both formats. // // Numbers measured 2026-10-02 (clang 18, x86-64; the formats are exact @@ -143,34 +147,34 @@ namespace { ROW(up_2, economy, 23, 23, 0x62302898b65a370dULL, 0xe57108ee5dcd11a5ULL, -72.3, -95.8) \ ROW(up_2, balanced, 27, 27, 0x597a9bff98e1d2fdULL, 0x35690983e0275251ULL, -71.1, -92.8) \ ROW(up_2, transparent, 63, 55, 0xcd3f2561c2e2d955ULL, 0x805ecb7ea3994265ULL, -77.3, -93.4) \ - ROW(down_2, super_economy, 19, 19, 0x62d9f46424b7acbdULL, 0x23868914d48461fdULL, -69.3, -91.9) \ - ROW(down_2, economy, 23, 23, 0xcd4d8ac606e35409ULL, 0x8e72f5156b104145ULL, -71.6, -92.8) \ - ROW(down_2, balanced, 27, 27, 0x29bdc8713b154ca5ULL, 0xecc3467d30d76709ULL, -70.6, -91.0) \ - ROW(down_2, transparent, 63, 53, 0xb5e7101a73dc98d9ULL, 0x6ed3d49c1c32ca15ULL, -69.2, -89.3) \ + ROW(down_2, super_economy, 19, 19, 0x3cba7ba5bf0200f1ULL, 0x23868914d48461fdULL, -71.1, -96.7) \ + ROW(down_2, economy, 23, 23, 0x62302898b65a370dULL, 0x8e72f5156b104145ULL, -72.3, -97.5) \ + ROW(down_2, balanced, 27, 27, 0x597a9bff98e1d2fdULL, 0xecc3467d30d76709ULL, -71.1, -94.9) \ + ROW(down_2, transparent, 63, 55, 0xcd3f2561c2e2d955ULL, 0x6ed3d49c1c32ca15ULL, -77.3, -95.7) \ ROW(up_3, super_economy, 33, 33, 0xfd3b44a261da711dULL, 0xdb52b0963a9827a9ULL, -71.7, -94.3) \ ROW(up_3, economy, 45, 45, 0xf843d9f1da25e251ULL, 0xffcac366d1724905ULL, -70.2, -93.5) \ ROW(up_3, balanced, 53, 53, 0x862f2f9c1f13e64dULL, 0xe9b4f733f5f69a49ULL, -71.5, -94.0) \ ROW(up_3, transparent, 101, 89, 0xd39ee5ef4c648899ULL, 0x658e039e0cf17da1ULL, -76.1, -91.2) \ - ROW(down_3, super_economy, 33, 33, 0xdf9e89c5c14bc226ULL, 0x0e531ee9e040c832ULL, -67.8, -91.5) \ - ROW(down_3, economy, 45, 45, 0xf3703d79f19957aeULL, 0x81e16a033f326f76ULL, -72.4, -90.8) \ - ROW(down_3, balanced, 53, 53, 0x9208a806f6896410ULL, 0xc11d78c7a4530ef8ULL, -71.5, -88.0) \ - ROW(down_3, transparent, 101, 82, 0x1db996525c50297eULL, 0x6506baa7c5d51262ULL, -63.8, -86.4) \ + ROW(down_3, super_economy, 33, 33, 0xfd3b44a261da711dULL, 0x0e531ee9e040c832ULL, -71.7, -97.2) \ + ROW(down_3, economy, 45, 45, 0xf843d9f1da25e251ULL, 0x81e16a033f326f76ULL, -70.2, -96.8) \ + ROW(down_3, balanced, 53, 53, 0x862f2f9c1f13e64dULL, 0xc11d78c7a4530ef8ULL, -71.5, -97.7) \ + ROW(down_3, transparent, 101, 89, 0xd39ee5ef4c648899ULL, 0x6506baa7c5d51262ULL, -76.1, -95.7) \ ROW(up_6, super_economy, 81, 81, 0x7376502a75faf5f5ULL, 0x9d6e292d48601b85ULL, -73.2, -93.1) \ ROW(up_6, economy, 121, 121, 0x36e3c046fa633d21ULL, 0x39d801984353d381ULL, -71.5, -92.4) \ ROW(up_6, balanced, 141, 141, 0x3237cb76f59e248dULL, 0x7c7d5cef3507c531ULL, -72.2, -92.3) \ ROW(up_6, transparent, 251, 219, 0xe46f033362196a39ULL, 0xb9fec6bbf1288489ULL, -78.5, -90.7) \ - ROW(down_6, super_economy, 81, 79, 0xc420c84b1741805dULL, 0x782ec334d8cf342bULL, -68.1, -87.1) \ - ROW(down_6, economy, 121, 119, 0x15b9f56b5749781dULL, 0x92a24b5cea1909cfULL, -64.7, -86.5) \ - ROW(down_6, balanced, 141, 139, 0xb585c907d418ea53ULL, 0x82885ec398c57779ULL, -65.6, -87.3) \ - ROW(down_6, transparent, 251, 193, 0xbb86f404f08bbf0dULL, 0x6a3f54f3e1587481ULL, -62.9, -85.6) \ + ROW(down_6, super_economy, 81, 81, 0x7376502a75faf5f5ULL, 0x782ec334d8cf342bULL, -73.2, -99.8) \ + ROW(down_6, economy, 121, 121, 0x36e3c046fa633d21ULL, 0x92a24b5cea1909cfULL, -71.5, -98.6) \ + ROW(down_6, balanced, 141, 141, 0x3237cb76f59e248dULL, 0x82885ec398c57779ULL, -72.2, -99.6) \ + ROW(down_6, transparent, 251, 219, 0xe46f033362196a39ULL, 0x6a3f54f3e1587481ULL, -78.5, -98.3) \ ROW(up_8, super_economy, 113, 113, 0xeb745d04141f1bb1ULL, 0x878a639d0252fde5ULL, -72.0, -92.9) \ ROW(up_8, economy, 169, 169, 0xdc280c16130175d1ULL, 0x192bcfb9abf918edULL, -71.7, -92.3) \ ROW(up_8, balanced, 197, 197, 0x8cd458eb725f53ddULL, 0xbcda6fae5723088dULL, -72.6, -91.4) \ ROW(up_8, transparent, 351, 303, 0x4b96078b2928931dULL, 0x11b5febcf65e1475ULL, -78.2, -90.2) \ - ROW(down_8, super_economy, 113, 111, 0x21171296dcd98df1ULL, 0xc6ed1a337eadf041ULL, -66.1, -87.4) \ - ROW(down_8, economy, 169, 165, 0xe15527b8a17a4c19ULL, 0x43d2f7ee82afa8c5ULL, -62.9, -86.8) \ - ROW(down_8, balanced, 197, 187, 0x97a54407216efc95ULL, 0xdc5baf7a000445a9ULL, -62.0, -86.9) \ - ROW(down_8, transparent, 351, 259, 0x4287b3116ed71645ULL, 0x5a6e47c4d822ebb1ULL, -61.1, -85.6) \ + ROW(down_8, super_economy, 113, 113, 0xeb745d04141f1bb1ULL, 0xc6ed1a337eadf041ULL, -72.0, -99.5) \ + ROW(down_8, economy, 169, 169, 0xdc280c16130175d1ULL, 0x43d2f7ee82afa8c5ULL, -71.7, -97.9) \ + ROW(down_8, balanced, 197, 197, 0x8cd458eb725f53ddULL, 0xdc5baf7a000445a9ULL, -72.6, -96.9) \ + ROW(down_8, transparent, 351, 303, 0x4b96078b2928931dULL, 0x5a6e47c4d822ebb1ULL, -78.2, -99.5) \ ROW(ratio_3_2, super_economy, 33, 33, 0xfd3b44a261da711dULL, 0xdb52b0963a9827a9ULL, -71.7, -94.4) \ ROW(ratio_3_2, economy, 45, 45, 0xf843d9f1da25e251ULL, 0xffcac366d1724905ULL, -70.2, -93.4) \ ROW(ratio_3_2, balanced, 53, 53, 0x862f2f9c1f13e64dULL, 0xe9b4f733f5f69a49ULL, -71.5, -94.0) \ @@ -218,8 +222,9 @@ namespace { } // ------------------------------------------------------------------ - // Exact unity: every phase row (interpolators, mixed ratios) and every - // decimator's whole filter sums to the format's 1.0. + // Exact unity: every phase row (interpolators, mixed ratios) sums to the + // format's 1.0, and so does every Q15 decimator branch and every Q31 + // decimator's whole filter. template void expect_rows_sum_to_unity(const pin_row& row, const profile& p) { using tr = tap::dsp::sample_traits; @@ -237,7 +242,20 @@ namespace { } } if constexpr (R::k_up == 1) { - EXPECT_EQ(whole, unity) << row.ratio << " " << row.profile << " q" << sizeof(S) * 8 << " whole filter"; + // A Q15 decimator holds each of its M branches at unity (its + // finalize divides by M); Q31 holds the whole filter at unity. + constexpr auto gain = static_cast(basic_stage::k_table_gain); + EXPECT_EQ(whole, unity * gain) + << row.ratio << " " << row.profile << " q" << sizeof(S) * 8 << " whole filter"; + if constexpr (basic_stage::k_branch_quantized) { + for (std::size_t r = 0; r < k_rows; ++r) { + std::int64_t sum = 0; + for (std::size_t t = 0; t < c.row_length(); ++t) { + sum += c.coefficient(r, t); + } + EXPECT_EQ(sum, unity) << row.ratio << " " << row.profile << " q16 branch " << r; + } + } } } @@ -268,6 +286,30 @@ namespace { for_each_row(); } + // A Q15 decimator's table IS its band's Q15 interpolator table: each of + // its M branches is the unscaled design's branch quantized at unity, as + // each interpolator phase is (the 1 / M rides the finalize), so the two + // hash alike — and attain the same stopband — at every profile. + template + void expect_shared_q15_table() { + for (const profile& p : + {profile::super_economy(), profile::economy(), profile::balanced(), profile::transparent()}) { + EXPECT_EQ(table_fnv1a64(basic_stage(1, p)), + table_fnv1a64(basic_stage(1, p))) + << ratio_traits::k_band << " " << p.stopband_atten_db; + } + } + + TEST(FixedPoint, Q15DecimatorTableIsTheInterpolatorsTable) { + expect_shared_q15_table(); + expect_shared_q15_table(); + expect_shared_q15_table(); + expect_shared_q15_table(); + static_assert(basic_stage::k_table_gain == 6); + static_assert(basic_stage::k_table_gain == 1); + static_assert(basic_stage::k_table_gain == 1); + } + // ------------------------------------------------------------------ // The structural zeros never enter the dot: each stored row's quantized // nonzero span holds no design zero, and the MACs counted are the spans' diff --git a/rational/tests/test_stage.cpp b/rational/tests/test_stage.cpp index 0545c06..a558be0 100644 --- a/rational/tests/test_stage.cpp +++ b/rational/tests/test_stage.cpp @@ -84,8 +84,8 @@ namespace { if (!(d > 0 && n == 0)) { const std::size_t s = d == 0 ? n : n - 1; if (s < t_len) { - expected = - tr::finalize(tr::mac(typename tr::accum{}, x[d], c.coefficient(j, t_len - 1 - s))); + expected = basic_stage::finalize_output( + tr::mac(typename tr::accum{}, x[d], c.coefficient(j, t_len - 1 - s))); } } } @@ -168,7 +168,19 @@ namespace { whole += down.coefficient(j, t); } } - EXPECT_EQ(whole, unity) << "decimator, all branches"; + // A Q15 decimator holds each branch at unity (its finalize + // divides by M); the other formats hold the whole filter at it. + EXPECT_EQ(whole, unity * static_cast(basic_stage::k_table_gain)) + << "decimator, all branches"; + if constexpr (basic_stage::k_branch_quantized) { + for (std::size_t j = 0; j < 3; ++j) { + std::int64_t branch = 0; + for (std::size_t t = 0; t < down.row_length(); ++t) { + branch += down.coefficient(j, t); + } + EXPECT_EQ(branch, unity) << "Q15 decimator branch " << j; + } + } } } diff --git a/rational/tools/coverage/matrix.py b/rational/tools/coverage/matrix.py index 344a475..73701f1 100644 --- a/rational/tools/coverage/matrix.py +++ b/rational/tools/coverage/matrix.py @@ -498,6 +498,8 @@ def diff(): m = re.match(r"\| (\d+)/(\d+) \| (.+?) \| (.+?) \|", line) if m: for pair in m.group(3).split(","): + if "→" not in pair: # another table keyed by ratio (section 6's) + continue a, b = pair.strip().split("→") table[(int(float(a) * 1000), int(float(b) * 1000))] = m.group(4).strip() n = 0 diff --git a/submodules/dsptap b/submodules/dsptap index d646905..54131fd 160000 --- a/submodules/dsptap +++ b/submodules/dsptap @@ -1 +1 @@ -Subproject commit d646905a47aa11ca9d1bcbcd36a28081cce8c4dc +Subproject commit 54131fde126dad30a636ccc031a8c41020ccd6db