Skip to content

Commit 4d54b77

Browse files
author
crispasr integration
committed
fix(sidon,#431): memory-derived cap, windowing removed — a 60 s file now works
Reporter Disonantemus: a 60 s clip was refused with "input too long ... Split the audio or raise CRISPASR_SIDON_MAX_FRAMES". Fixed: the 62 s repro now restores in one pass, 62.00 s in and 62.00 s out, on the default budget. I got here after two wrong turns and the evidence is worth recording because it contradicts both of them. WHAT I BUILT FIRST AND HAVE NOW DELETED: windowing the predictor. Measured against the upstream TorchScript reference on Kaggle (tools/kaggle/sidon-length-parity, chr1s4/... v5), our predictor handoff vs the reference's: length whole-utterance windowed 11 s 0.994072 (no split: T < window) 30 s 0.997032 0.991427 50 s 0.997106 0.986650 62 s 0.997073 0.973793 Windowing is a DEVIATION that worsens with every added window. It is removed rather than gated: a path known to diverge from the reference is not a fallback. The attempt is in history (cf3e6fe, dbffc3a, 112c52a). The reason it cannot work is structural — the DAC chunking below is exact because the decoder is fully convolutional and its cores come from dac_receptive_frames(); attention has no receptive field, so no context size makes a windowed core exact. I copied that chunking's FORM without its justification. WHAT THE CAP ACTUALLY GUARDS: memory, not quality. The whole-utterance path sits at ~0.997 against upstream from 30 s to 62 s — flat. My "quality falls off before the cap" theory, inferred from ASR transcripts, was wrong; transcript quality under a tiny ASR is not fidelity, and here the two pointed opposite ways. So the cap is now derived from a memory budget instead of being a magic 3000, inverting the docs' measured anchor (2042 MiB at T=2825, growth O(T^2)). Default 4096 MiB -> ~4000 frames (~78 s), which covers the reported case. CRISPASR_SIDON_MEM_BUDGET_MB sets the budget; CRISPASR_SIDON_MAX_FRAMES still overrides the frame count directly. The refusal message now states the real reason, the actual MiB estimate, and the exact knob to raise — and is honest that at the reporter's REAL file (54 min, T=162000) the relative index alone would be ~1.5 TiB, so splitting there is inherent rather than us being unhelpful. ONE THING THE FIX DOES NOT DO, stated plainly: sidon's own restoration quality degrades on long input. The 62 s output transcribes poorly on BOTH the faithful path and the windowed one, while our predictor matches upstream at 0.997 — so that degradation is the MODEL's behaviour and we reproduce it correctly. It is not something to "fix" in the port, and windowing only appeared to help by computing something upstream does not. Recorded so the next person does not chase it as a port bug. Faster too, incidentally: 294 s for the 62 s clip vs 686 s windowed, since the whole-utterance path does not rebuild a graph per window.
1 parent d68eeed commit 4d54b77

1 file changed

Lines changed: 78 additions & 178 deletions

File tree

src/sidon.cpp

Lines changed: 78 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -939,7 +939,12 @@ std::vector<float> sidon_extract_hidden(sidon_context* ctx, const float* pcm_16k
939939
std::vector<float> feats = make_features(ctx->model, pcm_16k, n_samples, T);
940940
if (T <= 0)
941941
return {};
942-
int max_frames = 3000; // same O(T^2) attention guard as sidon_restore
942+
// Same O(T^2) hazard as sidon_restore, but a fixed cap here on purpose:
943+
// this is the encoder-only feature path (#431 changed sidon_restore's cap to
944+
// be derived from a memory budget). Callers of extract_hidden are tooling
945+
// and diff harnesses on short clips, not user audio, so the simple bound
946+
// stays until something actually needs the budget form.
947+
int max_frames = 3000;
943948
if (const char* e = getenv("CRISPASR_SIDON_MAX_FRAMES"); e && e[0]) {
944949
const int v = atoi(e);
945950
if (v > 0)
@@ -1059,99 +1064,68 @@ std::vector<float> sidon_restore(sidon_context* ctx, const float* samples, int n
10591064
if (T <= 0)
10601065
return {};
10611066

1062-
// Guard against O(T^2) attention blowup. The predictor materializes
1063-
// (heads, T, T) relative indices and attention scores, so cost grows
1064-
// quadratically in the feature-frame count T (~50 frames/sec of input).
1065-
// Restoration is utterance-scale; cap T and fail cleanly rather than let a
1066-
// multi-minute clip exhaust memory. After the required 1.5 s lookahead,
1067-
// the default ~3000-frame cap permits ~58.5 s of user audio; override it
1068-
// only when the selected backend has sufficient memory.
1069-
int max_frames = 3000;
1070-
if (const char* e = getenv("CRISPASR_SIDON_MAX_FRAMES"); e && e[0]) {
1071-
const int v = atoi(e);
1072-
if (v > 0)
1073-
max_frames = v;
1074-
}
1075-
// #431: T > max_frames used to be a hard refusal — "split the audio or raise
1076-
// CRISPASR_SIDON_MAX_FRAMES" — which rejected any clip over ~58.5 s. The
1077-
// reporter's real input was 54 MINUTES, so neither suggestion is a fix:
1078-
// raising the cap re-introduces the O(T^2) blowup the cap exists to prevent,
1079-
// and asking a user to pre-split with ffmpeg is asking them to implement the
1080-
// feature by hand.
1081-
//
1082-
// The cap is right about the ATTENTION and wrong about the UTTERANCE. This
1083-
// function already chunks the DAC decoder below — core window, context on
1084-
// both sides, crop to the core — precisely so a long clip decodes in bounded
1085-
// memory. The predictor simply never got the same treatment. So the cap now
1086-
// bounds ONE PREDICTOR WINDOW instead of the whole file, and anything longer
1087-
// is processed as overlapping windows using that existing idiom.
1088-
//
1089-
// Short inputs are untouched: at T <= max_frames the whole-utterance path
1090-
// below runs exactly as before, so nothing that works today changes.
1067+
// #431 — WHY THIS IS A MEMORY BOUND AND NOT A QUALITY ONE.
10911068
//
1092-
// WINDOW SIZE IS THE THRESHOLD (2026-09-12, measured — see the A/B below).
1069+
// Measured against the upstream TorchScript reference on Kaggle
1070+
// (tools/kaggle/sidon-length-parity, chr1s4/crispasr-sidon-length-parity v5),
1071+
// comparing our predictor handoff to the reference's at four lengths:
10931072
//
1094-
// First attempt sized the window to the MEMORY cap (2400-frame cores) and
1095-
// the 62 s output came back degraded, which I wrongly attributed to
1096-
// windowing and gated off. A length sweep on the UNCHANGED whole-utterance
1097-
// path showed the real effect — quality falls off well before the cap:
1073+
// length whole-utterance vs REF windowed vs REF
1074+
// 11 s 0.994072 (no split: T < window)
1075+
// 30 s 0.997032 0.991427
1076+
// 50 s 0.997106 0.986650
1077+
// 62 s 0.997073 0.973793
10981078
//
1099-
// 11 s T= 625 whole-utterance "And so my fellow-americans ask not what
1100-
// your country can do for you..." CLEAN
1101-
// 30 s T=1575 whole-utterance "...ask not what your country can do
1102-
// for you..." CLEAN
1103-
// 50 s T=2575 whole-utterance "...it's not what you can DRINK AND do
1104-
// for you." DEGRADED
1079+
// Two conclusions, both of which contradict what I believed this morning:
11051080
//
1106-
// That 50 s arm is the shipping code with no windowing involved, so the
1107-
// cap was never protecting quality — it was hiding the falloff. A/B on that
1108-
// same 50 s clip, 900-frame cores vs whole-utterance:
1081+
// 1. THE WHOLE-UTTERANCE PATH DOES NOT DEGRADE WITH LENGTH. It sits at
1082+
// ~0.997 from 30 s to 62 s. An ASR roundtrip had suggested otherwise and
1083+
// was measuring something else — transcript quality under a tiny ASR is
1084+
// not fidelity to the reference, and here the two pointed opposite ways.
11091085
//
1110-
// raw input "ask not what your country can do for you ask what you can
1111-
// do for your country" (ceiling)
1112-
// whole-utt "it's not what you can drink and do for you."
1113-
// WINDOWED "is not what your country can do for you, ask what you can
1114-
// do for you."
1086+
// 2. WINDOWING THE PREDICTOR IS A DEVIATION, and a worsening one: 0.991 ->
1087+
// 0.987 -> 0.974 as windows multiply. It was removed rather than gated,
1088+
// because a path known to diverge is not a fallback. The attempt is in
1089+
// git history (cf3e6fe2, dbffc3a7, 112c52a1) with its evidence.
1090+
// The reason it cannot work is structural: the DAC chunking a few hundred
1091+
// lines below IS exact, because the decoder is fully convolutional and
1092+
// its cores are sized from dac_receptive_frames(); attention has no
1093+
// receptive field, so no context size makes a windowed core exact.
11151094
//
1116-
// So small windows BEAT the status quo on audio that already "works".
1117-
// Windowing is therefore the default, and the window is sized for quality
1118-
// rather than for how much attention memory happens to fit.
1095+
// So the cap guards MEMORY, and the only honest fix for "a 60 s file should
1096+
// work" is to let it work where the memory exists. The predictor's
1097+
// relative-position attention grows as O(T^2): the docs' measured anchor is
1098+
// 2042 MiB at T=2825, so the budget below is inverted through that.
11191099
//
1120-
// The threshold IS the window size: at T <= window there is exactly one
1121-
// window and the split is a no-op, so short clips keep the whole-utterance
1122-
// path unchanged. Honest about the gap: clean is measured at T=1575 and
1123-
// degraded at T=2575, so where inside that band the falloff begins is
1124-
// interpolated, not known.
1125-
//
1126-
// CRISPASR_SIDON_WINDOW_FRAMES=0 restores the old whole-utterance-or-refuse
1127-
// behaviour for A/B; the window is also clamped to the memory cap so
1128-
// raising one never silently violates the other.
1129-
// DEFAULT 0 = OFF, pending the parity result. The A/B that motivated
1130-
// windowing measured ASR transcripts, which says "sounds better to
1131-
// moonshine-tiny" and NOT "faithful to upstream" — and if the reference
1132-
// model degrades with length too, then the whole-utterance path is correct
1133-
// and windowing is a deviation however it sounds. Until
1134-
// tools/kaggle/sidon-length-parity answers that, the shipped behaviour is
1135-
// the old clean refusal and windowing is opt-in.
1136-
//
1137-
// Set CRISPASR_SIDON_WINDOW_FRAMES=1500 to enable (that is the size whose
1138-
// A/B beat whole-utterance at 50 s); 0 keeps it off.
1139-
int pred_window_frames = 0;
1140-
if (const char* e = getenv("CRISPASR_SIDON_WINDOW_FRAMES"); e && e[0]) {
1100+
// For very long audio this is not squeamishness — at the reporter's actual
1101+
// 54-minute file, T = 162000 and the relative index ALONE is ~1.5 TiB.
1102+
// Splitting is inherent there, and the message says so instead of implying
1103+
// we simply have not tried hard enough.
1104+
int budget_mb = 4096;
1105+
if (const char* e = getenv("CRISPASR_SIDON_MEM_BUDGET_MB"); e && e[0]) {
11411106
const int v = atoi(e);
1142-
if (v >= 0)
1143-
pred_window_frames = v;
1107+
if (v > 0)
1108+
budget_mb = v;
11441109
}
1145-
if (pred_window_frames > max_frames)
1146-
pred_window_frames = max_frames;
1147-
1148-
bool predictor_chunked = (pred_window_frames > 0 && T > pred_window_frames);
1149-
if (!predictor_chunked && T > max_frames) {
1110+
// T_max = 2825 * sqrt(budget / 2042), the docs' measured point inverted.
1111+
int max_frames = (int)(2825.0 * std::sqrt((double)budget_mb / 2042.0));
1112+
if (max_frames < 256)
1113+
max_frames = 256;
1114+
if (const char* e = getenv("CRISPASR_SIDON_MAX_FRAMES"); e && e[0]) {
1115+
const int v = atoi(e);
1116+
if (v > 0)
1117+
max_frames = v; // explicit override wins over the budget
1118+
}
1119+
if (T > max_frames) {
1120+
const double est_mb = 2042.0 * ((double)T / 2825.0) * ((double)T / 2825.0);
11501121
fprintf(stderr,
1151-
"sidon: input too long — %d feature frames (~%.1f s) exceeds the %d-frame cap and "
1152-
"windowing is disabled (CRISPASR_SIDON_WINDOW_FRAMES=0).\n"
1153-
" Re-enable windowing, split the audio, or raise CRISPASR_SIDON_MAX_FRAMES.\n",
1154-
T, (double)T / 50.0, max_frames);
1122+
"sidon: input is %d feature frames (~%.1f s); the predictor's O(T^2) attention would need "
1123+
"roughly %.0f MiB, over the %d MiB budget (cap %d frames, ~%.1f s).\n"
1124+
" If this machine has the memory: CRISPASR_SIDON_MEM_BUDGET_MB=%.0f (or set "
1125+
"CRISPASR_SIDON_MAX_FRAMES=%d directly).\n"
1126+
" Otherwise split the audio — attention cost grows with the SQUARE of duration, so very long "
1127+
"recordings cannot be restored in one pass at any budget.\n",
1128+
T, (double)T / 50.0, est_mb, budget_mb, max_frames, (double)max_frames / 50.0, est_mb * 1.1, T);
11551129
return {};
11561130
}
11571131

@@ -1161,102 +1135,28 @@ std::vector<float> sidon_restore(sidon_context* ctx, const float* samples, int n
11611135
std::vector<float> predictor_features;
11621136
std::chrono::steady_clock::time_point graph_done, predictor_start, predictor_done;
11631137

1164-
if (!predictor_chunked) {
1165-
// ── Whole-utterance path. Unchanged, and reached for every input that
1166-
// worked before this change, so short clips stay bit-identical.
1167-
if (!prepare_predictor_graph(ctx, T)) {
1168-
release_predictor_workspace(ctx);
1169-
release_decoder_workspace(ctx);
1170-
return {};
1171-
}
1172-
graph_done = clock::now();
1173-
1174-
set_predictor_inputs(ctx, feats, T);
1175-
predictor_start = clock::now();
1176-
core_quant_bcast::audit(ctx->predictor_graph, "sidon");
1177-
if (ggml_backend_sched_graph_compute(ctx->predictor_sched, ctx->predictor_graph) != GGML_STATUS_SUCCESS) {
1178-
std::fprintf(stderr, "sidon: predictor graph compute failed\n");
1179-
release_predictor_workspace(ctx);
1180-
release_decoder_workspace(ctx);
1181-
return {};
1182-
}
1183-
ggml_backend_sched_synchronize(ctx->predictor_sched);
1184-
predictor_done = clock::now();
1185-
1186-
predictor_features.resize((size_t)ggml_nelements(ctx->predictor_output));
1187-
ggml_backend_tensor_get(ctx->predictor_output, predictor_features.data(), 0,
1188-
predictor_features.size() * sizeof(float));
1189-
} else {
1190-
// ── Windowed path (#431). Same core/context/crop shape the DAC decoder
1191-
// uses below: run a window, keep only the frames its CORE owns, slide.
1192-
//
1193-
// Context frames exist because attention at a core edge would
1194-
// otherwise see a truncated sequence. They are cropped away, so they
1195-
// cost compute and never reach the output. This is NOT bit-identical
1196-
// to a whole-utterance run — attention over a window is a different
1197-
// computation — but the alternative for these inputs was no output at
1198-
// all, and every frame is still produced with a full context span on
1199-
// both sides except at the true file boundaries.
1200-
//
1201-
// The window is sized to the SAME cap the whole-utterance path
1202-
// obeys, so peak attention memory is unchanged: whatever T the user's
1203-
// machine could handle before, it still handles, just repeatedly.
1204-
int pred_context_frames = 300;
1205-
if (const char* e = getenv("CRISPASR_SIDON_PREDICTOR_CONTEXT_FRAMES"); e && e[0]) {
1206-
const int v = atoi(e);
1207-
if (v >= 0)
1208-
pred_context_frames = v;
1209-
}
1210-
if (pred_context_frames * 2 >= pred_window_frames)
1211-
pred_context_frames = std::max(0, (pred_window_frames / 4));
1212-
const int pred_core_frames = std::max(1, pred_window_frames - 2 * pred_context_frames);
1138+
if (!prepare_predictor_graph(ctx, T)) {
1139+
release_predictor_workspace(ctx);
1140+
release_decoder_workspace(ctx);
1141+
return {};
1142+
}
1143+
graph_done = clock::now();
12131144

1214-
std::fprintf(stderr,
1215-
"sidon: %d feature frames (~%.1f s) — processing as %d-frame windows with %d "
1216-
"frames of context each side (window %d, memory cap %d)\n",
1217-
T, (double)T / 50.0, pred_core_frames, pred_context_frames, pred_window_frames, max_frames);
1218-
1219-
predictor_features.assign((size_t)T * pred_hidden, 0.0f);
1220-
graph_done = clock::now();
1221-
predictor_start = clock::now();
1222-
1223-
for (int core_start = 0; core_start < T; core_start += pred_core_frames) {
1224-
const int core_end = std::min(T, core_start + pred_core_frames);
1225-
int win_start = std::max(0, core_start - pred_context_frames);
1226-
int win_end = std::min(T, core_end + pred_context_frames);
1227-
const int win_frames = win_end - win_start;
1228-
1229-
if (!prepare_predictor_graph(ctx, win_frames)) {
1230-
release_predictor_workspace(ctx);
1231-
release_decoder_workspace(ctx);
1232-
return {};
1233-
}
1234-
// feats is [T, feat_dim] frame-major; hand the window its own slice.
1235-
set_predictor_inputs(ctx, feats, win_frames, win_start);
1236-
core_quant_bcast::audit(ctx->predictor_graph, "sidon");
1237-
if (ggml_backend_sched_graph_compute(ctx->predictor_sched, ctx->predictor_graph) != GGML_STATUS_SUCCESS) {
1238-
std::fprintf(stderr, "sidon: predictor graph compute failed on window [%d,%d)\n", win_start, win_end);
1239-
release_predictor_workspace(ctx);
1240-
release_decoder_workspace(ctx);
1241-
return {};
1242-
}
1243-
ggml_backend_sched_synchronize(ctx->predictor_sched);
1244-
1245-
std::vector<float> win_out((size_t)ggml_nelements(ctx->predictor_output));
1246-
ggml_backend_tensor_get(ctx->predictor_output, win_out.data(), 0, win_out.size() * sizeof(float));
1247-
if (win_out.size() < (size_t)win_frames * pred_hidden) {
1248-
std::fprintf(stderr, "sidon: predictor window produced %zu floats, expected %d\n", win_out.size(),
1249-
win_frames * pred_hidden);
1250-
release_predictor_workspace(ctx);
1251-
release_decoder_workspace(ctx);
1252-
return {};
1253-
}
1254-
std::copy(win_out.begin() + (size_t)(core_start - win_start) * pred_hidden,
1255-
win_out.begin() + (size_t)(core_end - win_start) * pred_hidden,
1256-
predictor_features.begin() + (size_t)core_start * pred_hidden);
1257-
}
1258-
predictor_done = clock::now();
1145+
set_predictor_inputs(ctx, feats, T);
1146+
predictor_start = clock::now();
1147+
core_quant_bcast::audit(ctx->predictor_graph, "sidon");
1148+
if (ggml_backend_sched_graph_compute(ctx->predictor_sched, ctx->predictor_graph) != GGML_STATUS_SUCCESS) {
1149+
std::fprintf(stderr, "sidon: predictor graph compute failed\n");
1150+
release_predictor_workspace(ctx);
1151+
release_decoder_workspace(ctx);
1152+
return {};
12591153
}
1154+
ggml_backend_sched_synchronize(ctx->predictor_sched);
1155+
predictor_done = clock::now();
1156+
1157+
predictor_features.resize((size_t)ggml_nelements(ctx->predictor_output));
1158+
ggml_backend_tensor_get(ctx->predictor_output, predictor_features.data(), 0,
1159+
predictor_features.size() * sizeof(float));
12601160

12611161
// Judge the predictor BEFORE the DAC consumes it, so a bad handoff is
12621162
// attributed to the predictor rather than to the decoder it poisons.

0 commit comments

Comments
 (0)