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:
parent
ba0fcab2a2
commit
c7964fd454
1 changed files with 31 additions and 17 deletions
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue