Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
59 changes: 52 additions & 7 deletions include/tap/ratio/converter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<D>[pos];
const coeff* row = m_table.row(step.phase);
const coeff* row = m_table.stored_row(step.phase);
const bool mir = basic_phase_table<S, D>::is_mirrored(step.phase);
if constexpr (CH == 1) {
out[0] = tap::dsp::dot_row<S>(row, h0 + end - taps, taps);
out[0] =
mir ? dot_mirrored<T>(row, h0 + end - taps) : tap::dsp::dot_row<S>(row, h0 + end - taps, taps);
}
else if constexpr (CH == 2) {
out[0] = tap::dsp::dot_row<S>(row, h0 + end - taps, taps);
out[1] = tap::dsp::dot_row<S>(row, h1 + end - taps, taps);
if (mir) {
out[0] = dot_mirrored<T>(row, h0 + end - taps);
out[1] = dot_mirrored<T>(row, h1 + end - taps);
}
else {
out[0] = tap::dsp::dot_row<S>(row, h0 + end - taps, taps);
out[1] = tap::dsp::dot_row<S>(row, h1 + end - taps, taps);
}
}
else {
for (std::size_t c = 0; c < ch; ++c) {
out[c] = tap::dsp::dot_row<S>(row, m_hist[c].data() + end - taps, taps);
out[c] = mir ? dot_mirrored<T>(row, m_hist[c].data() + end - taps)
: tap::dsp::dot_row<S>(row, m_hist[c].data() + end - taps, taps);
}
}
out += ch;
Expand All @@ -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 <std::size_t T>
TAP_RATIO_MIRRORED_DOT_ATTR S dot_mirrored(const coeff* row, const S* hist) const noexcept {
return tap::dsp::dot_row_reversed<S>(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<D>[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<S, D>::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<S>(row, window(c), taps);
out[c] = mir ? tap::dsp::dot_row_reversed<S>(row, window(c), taps)
: tap::dsp::dot_row<S>(row, window(c), taps);
}
m_pending = step.advance;
m_pos = m_pos + 1 == k_phases ? 0 : m_pos + 1;
Expand Down
57 changes: 41 additions & 16 deletions include/tap/ratio/phase_table.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,40 +17,65 @@ 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 <tap::dsp::sample_type S, direction D>
class basic_phase_table {
public:
using coeff = typename tap::dsp::sample_traits<S>::coeff;

static constexpr std::size_t k_phases = ratio_traits<D>::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<D>())
, m_table(k_phases * m_taps) {
, m_table(k_stored * m_taps) {
const std::vector<double> proto = design_prototype<D>(p);
std::vector<double> 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];
}
tap::dsp::quantize_row_preserving_sum<S>(row_d, std::span<coeff>(m_table.data() + ph * m_taps, m_taps));
}
}

/// 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

Expand All @@ -63,7 +88,7 @@ namespace tap::ratio {

private:
std::size_t m_taps;
std::vector<coeff> m_table; // L x T, rows tap-reversed
std::vector<coeff> m_table; // ceil(L/2) x T, rows tap-reversed
};
// ANCHOR_END: rt_phase_table

Expand Down
2 changes: 1 addition & 1 deletion submodules/dsptap
2 changes: 1 addition & 1 deletion tests/test_converter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
38 changes: 27 additions & 11 deletions tests/test_phase_table.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,44 @@ namespace {
using tr = tap::dsp::sample_traits<S>;
const basic_phase_table<S, D> table(p);
EXPECT_EQ(table.taps(), p.template taps<D>());
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<double>(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<S> dc(table.taps(), std::is_floating_point_v<S> ? S(1) : std::numeric_limits<S>::max());
for (std::size_t ph = 0; ph < table.k_phases; ++ph) {
if constexpr (!std::is_floating_point_v<S>) {
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<std::int64_t>(tr::k_coeff_scale)) << "phase " << ph;
}
const S y = tap::dsp::dot_row<S>(table.row(ph), dc.data(), table.taps());
const S y = table.is_mirrored(ph)
? tap::dsp::dot_row_reversed<S>(table.stored_row(ph), dc.data(), table.taps())
: tap::dsp::dot_row<S>(table.stored_row(ph), dc.data(), table.taps());
if constexpr (std::is_floating_point_v<S>) {
ASSERT_NEAR(y, 1.0f, 1e-3f) << "phase " << ph;
}
else {
ASSERT_NEAR(y, std::numeric_limits<S>::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) {
Expand All @@ -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<float, direction::down_to_44k1> 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<float, direction::down_to_44k1> 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<std::int16_t, direction::up_to_48k> 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
Loading