diff --git a/PLAN.md b/PLAN.md index 9bf7d35..aee7555 100644 --- a/PLAN.md +++ b/PLAN.md @@ -106,8 +106,11 @@ Two quality tiers behind one design path, named in the family vocabulary: | Profile | Stopband | Passband edge | Taps/phase (=MACs/out) down / up | Storage f32 down / up | Role | |---|---|---|---|---|---| -| `economy()` — **default** | 70 dB | 19 kHz | **78 / 44** | 44.8 / 27.5 KiB | The speed-first default. All alias products land above 20 kHz at ≤ −71 dBFS — arithmetically confined to the ultrasonic band (see HANDOFF §4) | -| `transparent()` | 120 dB | 20 kHz | **184 / 96** | 105.7 / 60.0 KiB | Pristine/offline tier | +| `economy()` — **default** | 70 dB | 19 kHz | **78 / 44** | 22.5 / 13.8 KiB | The speed-first default. All alias products land above 20 kHz at ≤ −71 dBFS — arithmetically confined to the ultrasonic band (see HANDOFF §4) | +| `transparent()` | 120 dB | 20 kHz | **184 / 96** | 53.2 / 30.0 KiB | Pristine/offline tier | + +Storage figures are with the M7d symmetry halving (ceil(L/2) stored rows; +pinned by `PhaseTable.StorageBudgetsArePinned`); Q15 halves them again. `economy` as default is a deliberate positioning choice consistent with the speed-first charter; the README must state the reasoning (the §4 argument: @@ -250,11 +253,30 @@ executed (it measures the shipping C++, not a Python re-implementation). remains un-pulled: construction is <0.3% of every workload, so its value is boot time and RAM on MCUs, not instruction counts — deferred until a consumer needs it. + - **M7d — polyphase symmetry storage halving (landed).** Lever 3, in two + PRs per the substrate discipline: `tap::dsp::dot_row_reversed` landed + in DspTap first (hist forward × row backward, SMLALDX swapped-lane + dual-MAC on DSP-extension Arm — bit-identical to dotting the + materialized mirror), then the table dropped to ceil(L/2) stored rows + (74 of 147 down, 80 of 160 up; economy f32 44.8→22.5 / 27.5→13.8 KiB, + transparent 105.7→53.2 / 60.0→30.0 KiB, Q15 halves again) with + mirrored phases dotting their partner's stored row reversed. + Quantization is canonical over the stored half, so the mirror is exact + by construction and every row-sum guarantee carries over. Compute cost + measured at **zero baseline changes on all three targets** — with one + codegen subtlety the ratchet caught: inlining both dot arms in the + walk broke Arm's unrolled forward codegen (M55 up_q15 +3.3%), while + outlining the mirrored arm broke Hexagon's (down_q31 +3.3% called, + +2.7% inlined) — so the mirrored-arm out-lining is gated per target + (TAP_RATIO_MIRRORED_DOT_ATTR), the same measured-per-target pattern as + the tap::dsp kernel gates. Worst residual rides inside the ±3% gate + (Hexagon down_q31 +2.7%); Arm came out slightly ahead (M33 Q31 −2.5%). v0.1 ships at M6. Nothing in M7+ blocks it. **Status: M0–M6 complete — v0.1 shipped (2026-07-23). M7a measurement harness + M7b superblock -codegen + M7c committed trip counts landed (2026-07-23/24); next lever: -polyphase symmetry storage halving, or the M33 float story if a consumer +codegen + M7c committed trip counts + M7d symmetry halving landed +(2026-07-23/24); next lever candidates: multistage decomposition, +minimum-phase economy variant, or the M33 float story if a consumer needs it.** ## 8. Acceptance criteria (v0.1) diff --git a/include/tap/ratio/converter.h b/include/tap/ratio/converter.h index 2331199..7fd08b7 100644 --- a/include/tap/ratio/converter.h +++ b/include/tap/ratio/converter.h @@ -16,6 +16,21 @@ #include "tap/ratio/phase_table.h" #include "tap/ratio/schedule.h" +// Out-of-lining attribute for the mirrored-phase dot (see dot_mirrored): +// measured per target, gated per target, the same pattern as the tap::dsp +// kernel gates. On Arm, two inlined dot expansions in the walk's loop body +// break the forward arm's unrolled codegen (M55 up_q15 +3.3%), so the +// mirrored arm is a call; hexagon-clang keeps both expansions tight inline +// and pays for the call instead (down_q31: +3.3% outlined, +2.7% inlined), +// so there the attribute is empty and the helper inlines away. +#if defined(__hexagon__) +#define TAP_RATIO_MIRRORED_DOT_ATTR +#elif defined(_MSC_VER) +#define TAP_RATIO_MIRRORED_DOT_ATTR __declspec(noinline) +#else +#define TAP_RATIO_MIRRORED_DOT_ATTR __attribute__((noinline)) +#endif + namespace tap::ratio { // ANCHOR: rt_converter_doc @@ -282,18 +297,34 @@ namespace tap::ratio { append(); --pending; } + // Symmetry-halved table (M7 lever 3): a mirrored phase dots + // its partner's stored row tap-reversed — same products, + // same accumulation order, bit-identical to a full table. + // The mirrored arm is a CALL (dot_mirrored), not an inline + // expansion: two inlined dot bodies in this loop measurably + // break the forward arm's unrolled codegen (M55 up_q15, + // +3.3%), while a call against a ~300-insn dot is noise. const schedule_entry step = k_schedule[pos]; - const coeff* row = m_table.row(step.phase); + const coeff* row = m_table.stored_row(step.phase); + const bool mir = basic_phase_table::is_mirrored(step.phase); if constexpr (CH == 1) { - out[0] = tap::dsp::dot_row(row, h0 + end - taps, taps); + out[0] = + mir ? dot_mirrored(row, h0 + end - taps) : tap::dsp::dot_row(row, h0 + end - taps, taps); } else if constexpr (CH == 2) { - out[0] = tap::dsp::dot_row(row, h0 + end - taps, taps); - out[1] = tap::dsp::dot_row(row, h1 + end - taps, taps); + if (mir) { + out[0] = dot_mirrored(row, h0 + end - taps); + out[1] = dot_mirrored(row, h1 + end - taps); + } + else { + out[0] = tap::dsp::dot_row(row, h0 + end - taps, taps); + out[1] = tap::dsp::dot_row(row, h1 + end - taps, taps); + } } else { for (std::size_t c = 0; c < ch; ++c) { - out[c] = tap::dsp::dot_row(row, m_hist[c].data() + end - taps, taps); + out[c] = mir ? dot_mirrored(row, m_hist[c].data() + end - taps) + : tap::dsp::dot_row(row, m_hist[c].data() + end - taps, taps); } } out += ch; @@ -312,13 +343,27 @@ namespace tap::ratio { } // ANCHOR_END: rt_superblock_walk + /// The mirrored-phase dot, forced out of line (T = compile-time trip + /// count, 0 = runtime): the walk's loop body keeps the forward + /// tap::dsp::dot_row as its only inlined dot expansion — inlining + /// both arms measurably regressed the forward arm's unrolled codegen + /// — while this instantiation gets the same compile-time trip count + /// on its own. Bit-exactness is the reversed kernel's contract: + /// identical bits to dotting the materialized mirrored row. + template + TAP_RATIO_MIRRORED_DOT_ATTR S dot_mirrored(const coeff* row, const S* hist) const noexcept { + return tap::dsp::dot_row_reversed(row, hist, T != 0 ? T : m_table.taps()); + } + /// One output frame at the current schedule position; advances state. void emit(S* out) noexcept { const schedule_entry step = k_schedule[m_pos]; - const coeff* row = m_table.row(step.phase); + const coeff* row = m_table.stored_row(step.phase); + const bool mir = basic_phase_table::is_mirrored(step.phase); const std::size_t taps = m_table.taps(); for (std::size_t c = 0; c < m_channels; ++c) { - out[c] = tap::dsp::dot_row(row, window(c), taps); + out[c] = mir ? tap::dsp::dot_row_reversed(row, window(c), taps) + : tap::dsp::dot_row(row, window(c), taps); } m_pending = step.advance; m_pos = m_pos + 1 == k_phases ? 0 : m_pos + 1; diff --git a/include/tap/ratio/phase_table.h b/include/tap/ratio/phase_table.h index a3798dd..36a430d 100644 --- a/include/tap/ratio/phase_table.h +++ b/include/tap/ratio/phase_table.h @@ -17,30 +17,47 @@ namespace tap::ratio { // ANCHOR: rt_phase_table /// Immutable polyphase coefficient table, designed at construction. /// - /// Phase-major layout: exactly L rows (no interpolation between phases, - /// so no extra wrap row and no power-of-two rounding — the schedule - /// indexes branches exactly), each row taps() contiguous coefficients, - /// stored tap-reversed so the dot product runs forward over an - /// oldest-first history window (the tap::dsp::dot_row convention). - /// Branch p holds prototype taps h[p + t*L], quantized per row with the - /// shared row-sum-preserving utility, so every branch's DC gain survives - /// fixed point within one coefficient LSB. + /// Phase-major layout, symmetry-halved (M7 lever 3): the linear-phase + /// prototype satisfies h[n] = h[LT-1-n], which per branch reads + /// b_p[t] = b_{L-1-p}[T-1-t] — branch p is branch L-1-p tap-reversed. So + /// only the low half of the branches is stored (k_stored = ceil(L/2) + /// rows, each taps() contiguous coefficients, tap-reversed for the + /// forward tap::dsp::dot_row convention), and a mirrored phase dots its + /// partner's stored row backward via tap::dsp::dot_row_reversed — same + /// products, same accumulation order, bit-identical outputs to a full + /// table, at half the bytes. L odd (down: 147) has a self-symmetric + /// middle branch, stored normally. + /// + /// Quantization is canonical over the stored half only: each stored row + /// is quantized with the shared row-sum-preserving utility, and a + /// mirrored row IS its partner's reversal by construction — reversal + /// preserves the coefficient multiset, so every branch's DC gain (and + /// the fixed-point exact-unity row sum) carries over unchanged. template class basic_phase_table { public: using coeff = typename tap::dsp::sample_traits::coeff; static constexpr std::size_t k_phases = ratio_traits::k_phases; + static constexpr std::size_t k_stored = (k_phases + 1) / 2; ///< rows actually held + + /// True when phase ph reads its partner's stored row tap-reversed. + static constexpr bool is_mirrored(std::size_t ph) noexcept { return ph >= k_stored; } + + /// The stored row index serving phase ph (identity for the low half). + static constexpr std::size_t stored_index(std::size_t ph) noexcept { + return ph < k_stored ? ph : k_phases - 1 - ph; + } /// Designs the prototype (double precision, via the shared tap::dsp - /// designer) and quantizes the table. Allocates; may throw. Setup - /// time only, off the audio path. + /// designer) and quantizes the stored half. Allocates; may throw. + /// Setup time only, off the audio path. explicit basic_phase_table(const profile& p = profile::economy()) : m_taps(p.taps()) - , m_table(k_phases * m_taps) { + , m_table(k_stored * m_taps) { const std::vector proto = design_prototype(p); std::vector row_d(m_taps); - for (std::size_t ph = 0; ph < k_phases; ++ph) { + for (std::size_t ph = 0; ph < k_stored; ++ph) { for (std::size_t t = 0; t < m_taps; ++t) { row_d[m_taps - 1 - t] = proto[t * k_phases + ph]; } @@ -48,9 +65,17 @@ namespace tap::ratio { } } - /// Row pointer for branch ph in [0, k_phases); taps() contiguous - /// coefficients, ready for tap::dsp::dot_row. - const coeff* row(std::size_t ph) const noexcept { return m_table.data() + ph * m_taps; } + /// Stored-row pointer serving phase ph in [0, k_phases): taps() + /// contiguous coefficients for tap::dsp::dot_row when + /// !is_mirrored(ph), or for tap::dsp::dot_row_reversed when it is. + const coeff* stored_row(std::size_t ph) const noexcept { return m_table.data() + stored_index(ph) * m_taps; } + + /// Logical coefficient (ph, t) — the cold-path accessor for tests and + /// tools; the hot path pairs stored_row() with is_mirrored(). + coeff at(std::size_t ph, std::size_t t) const noexcept { + const coeff* r = stored_row(ph); + return is_mirrored(ph) ? r[m_taps - 1 - t] : r[t]; + } std::size_t taps() const noexcept { return m_taps; } ///< T: MACs per output sample @@ -63,7 +88,7 @@ namespace tap::ratio { private: std::size_t m_taps; - std::vector m_table; // L x T, rows tap-reversed + std::vector m_table; // ceil(L/2) x T, rows tap-reversed }; // ANCHOR_END: rt_phase_table diff --git a/submodules/dsptap b/submodules/dsptap index 98abb04..dfbe18d 160000 --- a/submodules/dsptap +++ b/submodules/dsptap @@ -1 +1 @@ -Subproject commit 98abb04733c3f171276d07059bb3fe25a1fe7474 +Subproject commit dfbe18d79565b8ce11c2232789c1ccd79871b27b diff --git a/tests/test_converter.cpp b/tests/test_converter.cpp index b0289f8..665f941 100644 --- a/tests/test_converter.cpp +++ b/tests/test_converter.cpp @@ -53,7 +53,7 @@ namespace { for (std::size_t n = 0; n < made; ++n) { const std::size_t k = n * m / l; // newest-input index for output n const std::size_t phase = (n * m) % l; - const float expected = k < taps ? c.table().row(phase)[taps - 1 - k] : 0.0f; + const float expected = k < taps ? c.table().at(phase, taps - 1 - k) : 0.0f; ASSERT_EQ(y[n], expected) << "n=" << n; // bit-exact, transient included } } diff --git a/tests/test_phase_table.cpp b/tests/test_phase_table.cpp index b2170fc..1df9c39 100644 --- a/tests/test_phase_table.cpp +++ b/tests/test_phase_table.cpp @@ -29,23 +29,28 @@ namespace { using tr = tap::dsp::sample_traits; const basic_phase_table table(p); EXPECT_EQ(table.taps(), p.template taps()); - EXPECT_EQ(table.storage_bytes(), table.k_phases * table.taps() * sizeof(typename tr::coeff)); + // Symmetry-halved storage (M7 lever 3): only ceil(L/2) rows held. + EXPECT_EQ(table.storage_bytes(), table.k_stored * table.taps() * sizeof(typename tr::coeff)); EXPECT_NEAR(table.group_delay_input_samples(), static_cast(table.taps()) / 2.0, 0.51); - // Every phase, exhaustively: fixed-point rows sum to the format's - // unity exactly (the row-sum guarantee), and a full-scale DC window - // through the shared kernel lands within one output LSB of full - // scale for every branch — DC gain is phase-independent. + // Every phase, exhaustively — through the same stored_row/is_mirrored + // pairing the converter's hot path uses, so the reversed-kernel leg + // is exercised for every mirrored branch: fixed-point rows sum to + // the format's unity exactly (the row-sum guarantee; reversal + // preserves the multiset), and a full-scale DC window lands within + // one output LSB of full scale — DC gain is phase-independent. std::vector dc(table.taps(), std::is_floating_point_v ? S(1) : std::numeric_limits::max()); for (std::size_t ph = 0; ph < table.k_phases; ++ph) { if constexpr (!std::is_floating_point_v) { std::int64_t sum = 0; for (std::size_t t = 0; t < table.taps(); ++t) { - sum += table.row(ph)[t]; + sum += table.at(ph, t); } ASSERT_EQ(sum, static_cast(tr::k_coeff_scale)) << "phase " << ph; } - const S y = tap::dsp::dot_row(table.row(ph), dc.data(), table.taps()); + const S y = table.is_mirrored(ph) + ? tap::dsp::dot_row_reversed(table.stored_row(ph), dc.data(), table.taps()) + : tap::dsp::dot_row(table.stored_row(ph), dc.data(), table.taps()); if constexpr (std::is_floating_point_v) { ASSERT_NEAR(y, 1.0f, 1e-3f) << "phase " << ph; } @@ -53,6 +58,15 @@ namespace { ASSERT_NEAR(y, std::numeric_limits::max(), 2) << "phase " << ph; } } + + // The mirror identity itself, exhaustively: phase ph IS phase + // L-1-ph tap-reversed, coefficient for coefficient. + for (std::size_t ph = 0; ph < table.k_phases; ++ph) { + for (std::size_t t = 0; t < table.taps(); ++t) { + ASSERT_EQ(table.at(ph, t), table.at(table.k_phases - 1 - ph, table.taps() - 1 - t)) + << "phase " << ph << " tap " << t; + } + } } TYPED_TEST(phase_table_test, DownEconomyEveryPhase) { @@ -69,14 +83,16 @@ namespace { } // The storage numbers the plan quotes, pinned: economy is the compact - // profile the speed-first charter defaults to. + // profile the speed-first charter defaults to, and the symmetry halving + // stores ceil(L/2) rows — 74 of 147 down, 80 of 160 up (the odd L keeps + // its self-symmetric middle branch as a stored row). TEST(PhaseTable, StorageBudgetsArePinned) { const basic_phase_table de(profile::economy()); - EXPECT_EQ(de.storage_bytes(), 147u * 78u * 4u); // 44.8 KiB + EXPECT_EQ(de.storage_bytes(), 74u * 78u * 4u); // 22.5 KiB (was 44.8 full) const basic_phase_table dt(profile::transparent()); - EXPECT_EQ(dt.storage_bytes(), 147u * 184u * 4u); // 105.7 KiB + EXPECT_EQ(dt.storage_bytes(), 74u * 184u * 4u); // 53.2 KiB (was 105.7 full) const basic_phase_table ue(profile::economy()); - EXPECT_EQ(ue.storage_bytes(), 160u * 44u * 2u); // 13.8 KiB — Q15 halves it + EXPECT_EQ(ue.storage_bytes(), 80u * 44u * 2u); // 6.9 KiB — Q15 halves it again } } // namespace