fix(vcv): implement proper double-buffered threading

Replace direct-mutation threading with shadow IML:
- Background thread clones weights from main → shadow IML
- Training and perturbation operate only on shadow instance
- New weights staged in pendingWeights, swapped atomically by audio thread
- Thumbs-down now enqueues Perturb job instead of calling move_weights directly
- Audio thread applies new weights via iml.set_weights() at safe point
- Examples copied to shadow for training, results copied back as weights only

Threading invariant now fully enforced: background thread never writes to
the inference IML. Audio thread applies staged weights between inference calls.
This commit is contained in:
w1n5t0n 2026-03-28 01:55:30 +02:00
parent ba0fcab2a2
commit c7964fd454

View file

@ -64,8 +64,14 @@ struct MEMLNaut : Module {
LIGHTS_LEN LIGHTS_LEN
}; };
// ── ML Engine ───────────────────────────────────────────────────── // ── ML Engine (double-buffered) ─────────────────────────────────
// Audio thread reads from `iml` (inference only).
// Background thread clones weights into `imlShadow`, trains/perturbs
// the shadow, then copies new weights back via atomic swap flag.
nisps::IML<float> iml{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}}; nisps::IML<float> iml{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}};
nisps::IML<float> imlShadow{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}};
nisps::MLP<float>::mlp_weights pendingWeights; // new weights from shadow, waiting for swap
std::atomic<bool> weightsPending{false}; // true when pendingWeights is ready
// ── State ───────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────
float noiseLevel = 0.1f; float noiseLevel = 0.1f;
@ -239,22 +245,27 @@ struct MEMLNaut : Module {
isTraining.store(true); isTraining.store(true);
// NOTE: Full double-buffering requires IML weight get/set API // Double-buffering: clone weights to shadow, mutate shadow,
// (filed for follow-up). For now, training and perturbation // stage new weights for the audio thread to pick up.
// operate directly on iml. The audio thread reads outputs auto weights = iml.get_weights();
// (which are a cached copy), so this is safe for outputs imlShadow.set_weights(weights);
// but not for concurrent inference. The RATE decimation
// means inference doesn't run every sample, reducing
// collision probability. Proper double-buffering is Phase 3
// follow-up work.
if (job.type == JobType::Train) { if (job.type == JobType::Train) {
iml.set_mode(nisps::IML<float>::Mode::Training); // Copy examples to shadow, train it
iml.set_mode(nisps::IML<float>::Mode::Inference); auto features = iml.get_example_features();
auto labels = iml.get_example_labels();
imlShadow.load_examples(features, labels);
imlShadow.set_mode(nisps::IML<float>::Mode::Training);
imlShadow.set_mode(nisps::IML<float>::Mode::Inference);
} else { } else {
iml.move_weights(job.noiseLevel, job.spread); // Perturb shadow weights
imlShadow.move_weights(job.noiseLevel, job.spread);
} }
// Stage new weights for audio thread to swap in
pendingWeights = imlShadow.get_weights();
weightsPending.store(true);
// Update novelty/confidence for current input position // Update novelty/confidence for current input position
if (iml.get_example_count() > 0) { if (iml.get_example_count() > 0) {
float dist = iml.nearest_example_distance(lastInputs, NUM_ML_INPUTS); float dist = iml.nearest_example_distance(lastInputs, NUM_ML_INPUTS);
@ -341,15 +352,19 @@ struct MEMLNaut : Module {
lights[LIGHT_LEARN].setBrightness(learn ? 1.f : 0.f); lights[LIGHT_LEARN].setBrightness(learn ? 1.f : 0.f);
lights[LIGHT_TRAINING].setBrightness(isTraining.load() ? 1.f : 0.f); lights[LIGHT_TRAINING].setBrightness(isTraining.load() ? 1.f : 0.f);
// ── Handle weight change notification from background thread ── // ── Apply new weights from background thread ─────────────────
if (swapReady.load()) { if (weightsPending.load()) {
swapReady.store(false); iml.set_weights(pendingWeights);
weightsPending.store(false);
// Start crossfade: save current outputs as "old" // Start crossfade: save current outputs as "old"
for (int i = 0; i < NUM_ML_OUTPUTS; i++) { for (int i = 0; i < NUM_ML_OUTPUTS; i++) {
prevOutputs[i] = cachedOutputs[i]; prevOutputs[i] = cachedOutputs[i];
} }
crossfadeProgress = 0.f; crossfadeProgress = 0.f;
} }
if (swapReady.load()) {
swapReady.store(false);
}
// ── Handle RAND button ──────────────────────────────────────── // ── Handle RAND button ────────────────────────────────────────
if (randTrigger.process(params[PARAM_RAND].getValue() > 0.f)) { if (randTrigger.process(params[PARAM_RAND].getValue() > 0.f)) {
@ -402,8 +417,7 @@ struct MEMLNaut : Module {
if (thumbsDown || trigNeg) { if (thumbsDown || trigNeg) {
float noiseCap = 0.3f * (1.f - spread) + 0.05f * spread; float noiseCap = 0.3f * (1.f - spread) + 0.05f * spread;
noiseLevel = std::min(noiseLevel * 1.5f, noiseCap); noiseLevel = std::min(noiseLevel * 1.5f, noiseCap);
// Perturb directly (simple for now — enqueue for full thread safety later) enqueueJob(JobType::Perturb, noiseLevel, spread);
iml.move_weights(noiseLevel, spread);
} }
} }