fix(ram): nur ein grosses Modell resident (Analyse starb im OOM-Killer)
rkllm_server braucht 6,5 GB von 7,74 GB. Blieb daneben ein anderer Dauerhelfer resident, wurde rkllm beim Laden OOM-getoetet -- bei #101 in allen 5 Startversuchen, weil das Retry nichts freigab. Ausloeser war der warme CT2-Whisper-Daemon, den die 2-Sekunden-Aufnahme davor gestartet hatte. Zweiter Fall: rkllm blieb nach der Analyse fuer immer resident, und der Suchindex startete e5-small daneben. gate.js bekommt eine Freigabe-Registry (registerReleaser/freeRam/ killAndWait, verdrahtet in server.js wie setProbe). Vor jedem rkllm-Versuch und vor dem NPU-Whisper-Lauf werden die anderen Helfer beendet und ihr Prozessende abgewartet; analyzeRecording gibt rkllm vor dem Suchindex frei. Alle Helfer starten faul neu. Verifiziert mit der Abfolge, die scheiterte: 12-s-Clip (CT2-Daemon resident) -> Analyse #101 laeuft durch (221 s, done), rkllm lud in 22 s, kein OOM, danach 5,3 GB frei statt 100 MB.
This commit is contained in:
+8
-1
@@ -12,6 +12,7 @@ import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
import path from 'node:path';
|
||||
import * as gate from './gate.js';
|
||||
|
||||
const PY = process.env.AIME_PY || '/opt/aime-venv/bin/python';
|
||||
const DIR = process.env.AIME_EMB_DIR || '/opt/aime-models/e5-small';
|
||||
@@ -91,4 +92,10 @@ export async function embedMany(texts) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function stop() { if (proc) { try { proc.kill(); } catch {} proc = null; ready = null; } }
|
||||
// Beendet den Daemon und wartet auf sein Ende (siehe gate.freeRam: das e5-Modell
|
||||
// hält in der Spitze 1,15 GB und muss weg, bevor rkllm lädt). Startet faul neu.
|
||||
export function stop() {
|
||||
const p = proc;
|
||||
proc = null; ready = null; rl = null;
|
||||
return gate.killAndWait(p);
|
||||
}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// Sperre für schwere Hintergrundarbeit, solange eine Aufnahme läuft.
|
||||
//
|
||||
// Warum: whisper, die sherpa-Diarisierung und rkllm dürfen nicht neben einer
|
||||
// laufenden Aufnahme arbeiten. Auf diesem lüfterlosen 8-GB-Board hält rkllm
|
||||
// allein ~6,7 GB (RssShmem 4,79 GB + RssAnon 1,93 GB von 7,74 GB MemTotal), es
|
||||
// bleibt also ~1 GB Rest. Am 2026-08-06 lief die Analyse von #96 gleichzeitig
|
||||
// mit der Transkription von #97 an — der globale OOM-Killer erschlug
|
||||
// rkllm_server, systemd nahm den ganzen Dienst mit ('oom-kill'), und die GUI
|
||||
// zeigte "failed to fetch". Dazu treibt die Zusatzlast die A76-Kerne auf ~80 °C,
|
||||
// während das Mikrofon offen ist, und kostet Audioqualität.
|
||||
//
|
||||
// Der Recorder ist die einzige Wahrheit über den Aufnahmezustand; server.js
|
||||
// hängt die Abfrage beim Start ein. Eigenes Modul, weil recorder.js bereits
|
||||
// llm.js importiert — llm.js darf recorder.js deshalb nicht zurückimportieren
|
||||
// (Zyklus).
|
||||
let probe = () => false;
|
||||
|
||||
// Beim Start aus server.js: gate.setProbe(() => rec.isRecording()).
|
||||
export function setProbe(fn) { probe = typeof fn === 'function' ? fn : (() => false); }
|
||||
|
||||
export function busy() {
|
||||
try { return !!probe(); } catch { return false; }
|
||||
}
|
||||
|
||||
// Parken, bis der Recorder wieder still ist. Ein- und Austritt werden geloggt,
|
||||
// damit eine lange Pause im Journal als Pause erkennbar ist und nicht wie ein
|
||||
// hängender Job aussieht.
|
||||
export async function waitIdle(label) {
|
||||
if (!busy()) return 0;
|
||||
const t0 = Date.now();
|
||||
console.log(`gate: ${label} pausiert — Aufnahme läuft`);
|
||||
while (busy()) await new Promise(r => setTimeout(r, 2000));
|
||||
const s = Math.round((Date.now() - t0) / 1000);
|
||||
console.log(`gate: ${label} fortgesetzt nach ${s} s Pause`);
|
||||
return s;
|
||||
}
|
||||
|
||||
// --- Speicher freiräumen, bevor ein großes Modell lädt -----------------------
|
||||
//
|
||||
// Auf diesem 7,74-GB-Board darf nur EIN großes Modell resident sein. rkllm_server
|
||||
// braucht allein ~6,5 GB (davon 4,79 GB unevictable NPU-Shmem), und daneben halten
|
||||
// die faul gestarteten Dauerhelfer in der Spitze (gemessen, VmHWM):
|
||||
// e5-small (embed_text.py) 1,15 GB
|
||||
// CT2-Whisper (transcribe_daemon) 0,90 GB
|
||||
// TitaNet (spk_embed) 0,70 GB
|
||||
// Am 2026-08-07 kostete das die Analyse von #101: die 2-Sekunden-Aufnahme #100
|
||||
// davor hatte den warmen CT2-Daemon gestartet (Kurz-Clip-Pfad, bleibt per Design
|
||||
// resident), danach wurde rkllm in ALLEN 5 Startversuchen OOM-getötet → GUI
|
||||
// "KI-Analyse fehlgeschlagen". zram hilft hier NICHT: der komprimierte Swap liegt
|
||||
// selbst im RAM, ausgelagerte 0,9 GB kosten immer noch ~0,3-0,45 GB physisch.
|
||||
//
|
||||
// Jeder Helfer registriert deshalb, wie er sich beenden lässt; wer ein großes
|
||||
// Modell laden will, ruft vorher freeRam(<eigener Name>). Alle Helfer starten
|
||||
// beim nächsten Aufruf faul neu — es geht nur Ladezeit verloren, kein Zustand.
|
||||
const releasers = new Map();
|
||||
export function registerReleaser(name, stop) { releasers.set(name, stop); }
|
||||
|
||||
// Beendet einen Kindprozess und wartet, bis er wirklich weg ist (sonst hat der
|
||||
// Kernel die Seiten beim Laden des nächsten Modells noch nicht zurück).
|
||||
// Rückgabe: true, wenn etwas beendet wurde.
|
||||
export function killAndWait(proc, timeoutMs = 5000) {
|
||||
if (!proc || proc.exitCode !== null || proc.signalCode) return Promise.resolve(false);
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const done = () => { if (!settled) { settled = true; resolve(true); } };
|
||||
proc.once('exit', done);
|
||||
try { proc.kill('SIGTERM'); } catch { done(); return; }
|
||||
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} setTimeout(done, 500); }, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Alle registrierten Helfer außer `keep` beenden. Wird vor jedem Ladeversuch
|
||||
// eines großen Modells aufgerufen — auch vor einem Retry, denn ein Retry ohne
|
||||
// freien Speicher wird genauso OOM-getötet wie der erste Versuch.
|
||||
export async function freeRam(keep) {
|
||||
const freed = [];
|
||||
for (const [name, stop] of releasers) {
|
||||
if (name === keep) continue;
|
||||
try { if (await stop()) freed.push(name); }
|
||||
catch (e) { console.warn(`ram: ${name} nicht freigegeben — ${e.message}`); }
|
||||
}
|
||||
if (freed.length) {
|
||||
console.log(`ram: für ${keep} freigegeben — ${freed.join(', ')}`);
|
||||
await new Promise(r => setTimeout(r, 1500)); // Kernel-Reclaim abwarten
|
||||
}
|
||||
return freed;
|
||||
}
|
||||
+23
-1
@@ -4,6 +4,7 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
import * as gate from './gate.js';
|
||||
|
||||
const SERVER = '/opt/callrec/rkllm_server';
|
||||
const MODEL = process.env.RKLLM_MODEL || '/opt/rkllm-models/qwen2.5-3b.rkllm';
|
||||
@@ -30,6 +31,14 @@ function spawnServer() {
|
||||
let done = false;
|
||||
proc = spawn('taskset', ['-c', '4-7', SERVER, MODEL], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
proc.stderr.on('data', d => process.stderr.write('[rkllm] ' + d));
|
||||
// Ein write() in die stdin eines toten Kindes loest 'error' (EPIPE) auf
|
||||
// diesem Socket aus. Ohne Listener behandelt node das als unhandled
|
||||
// 'error' event und beendet den GANZEN Dienst — genau so starb callrec am
|
||||
// 2026-08-06 nach #98. Der Fehler gehoert an die offene Anfrage, nicht an
|
||||
// den Prozess.
|
||||
proc.stdin.on('error', e => {
|
||||
if (pending) { const p = pending; pending = null; p.reject(new Error('rkllm stdin: ' + e.message)); }
|
||||
});
|
||||
ready = new Promise((resolve, reject) => {
|
||||
settleReject = reject;
|
||||
// 180s: Qwen3-4B (5.3GB) needs ~96s to load on the capped clock — the old
|
||||
@@ -61,6 +70,11 @@ async function startServer() {
|
||||
// loads succeed. Up to 5 attempts, ~4s apart.
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
// Vor JEDEM Versuch die anderen großen Helfer beenden. rkllm braucht ~6,5 GB
|
||||
// von 7,74 GB; blieb z. B. der warme CT2-Daemon der vorherigen Kurz-Aufnahme
|
||||
// resident, wird rkllm OOM-getötet — und ein Retry ohne freien Speicher
|
||||
// stirbt genauso (2026-08-07: 5/5 Versuche, "KI-Analyse fehlgeschlagen").
|
||||
await gate.freeRam('rkllm');
|
||||
try { spawnServer(); await ready; return; }
|
||||
catch (e) {
|
||||
lastErr = e;
|
||||
@@ -78,10 +92,18 @@ async function startServer() {
|
||||
// next one (otherwise one bad chunk fails all following chunks instantly).
|
||||
function ask(prompt) {
|
||||
const run = chain.then(async () => {
|
||||
// Haltepunkt zwischen den Chunks: startet mittendrin eine Aufnahme,
|
||||
// pausiert die Analyse hier, statt neben dem offenen Mikrofon zu rechnen.
|
||||
await gate.waitIdle('llm');
|
||||
await startServer();
|
||||
return await new Promise((resolve, reject) => {
|
||||
// Zweites Netz: der Prozess kann zwischen startServer() und hier
|
||||
// gestorben sein (exit-Event noch nicht verarbeitet).
|
||||
if (!proc || !proc.stdin || proc.stdin.destroyed)
|
||||
return reject(new Error('rkllm nicht bereit (Prozess weg)'));
|
||||
pending = { resolve, reject };
|
||||
proc.stdin.write(b64e(prompt) + '\n');
|
||||
try { proc.stdin.write(b64e(prompt) + '\n'); }
|
||||
catch (e) { pending = null; reject(new Error('rkllm stdin: ' + e.message)); }
|
||||
});
|
||||
});
|
||||
chain = run.catch(() => {}); // next request starts from a clean, resolved chain
|
||||
|
||||
+18
-1
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import * as diar from './diar.js';
|
||||
import * as llm from './llm.js';
|
||||
import * as gate from './gate.js';
|
||||
const pexec = promisify(execFile);
|
||||
|
||||
const WHISPER = '/opt/whisper.cpp/build/bin/whisper-cli';
|
||||
@@ -145,6 +146,16 @@ function startTd() {
|
||||
tdProc.on('exit', cleanup); tdProc.on('error', cleanup);
|
||||
return tdReady;
|
||||
}
|
||||
// Beendet den warmen CT2-Daemon und wartet auf sein Ende. Er bleibt sonst per
|
||||
// Design für immer resident und hält ~0,9 GB — genug, um den rkllm-Load danach
|
||||
// OOM-tötbar zu machen (2026-08-07, #101). Nächster Kurz-Clip startet ihn neu;
|
||||
// der einzige Preis ist der erneute Modell-Load. Siehe gate.freeRam.
|
||||
export function stopTranscribeDaemon() {
|
||||
const p = tdProc;
|
||||
tdProc = null; tdReady = null; tdRl = null;
|
||||
return gate.killAndWait(p);
|
||||
}
|
||||
|
||||
// One request at a time (single warm process), chained like spk.embed.
|
||||
function runDaemonTranscribe(wavPath) {
|
||||
const run = tdChain.then(async () => {
|
||||
@@ -400,7 +411,10 @@ export async function transcribeWav(wavFile, numSpeakers, opts = {}) {
|
||||
}
|
||||
console.log(`cpu-daemon-transcribe: ${words.length} words, RTF ${r.rtf} (${r.transcribe_s}s / ${r.duration}s audio, warm, rkllm kept resident)`);
|
||||
} else if (TRANSCRIBER === 'npu') {
|
||||
await llm.stopServer();
|
||||
// Nicht nur rkllm freigeben: der warme CT2-Daemon und die Embedder halten
|
||||
// zusammen ~2,7 GB und der turbo-rknn-Encoder + CT2-Decoder brauchen den
|
||||
// Platz genauso wie rkllm (siehe gate.freeRam).
|
||||
await gate.freeRam('npu-whisper');
|
||||
await setA76Max(A76_CAP_NPU);
|
||||
// large-v3-turbo, encoder on NPU. CLI already emits word-level timestamps.
|
||||
const r = await runNpuTranscribe(wavPath);
|
||||
@@ -426,6 +440,9 @@ export async function transcribeWav(wavFile, numSpeakers, opts = {}) {
|
||||
}
|
||||
}
|
||||
// Acoustic diarization under the A76 clock cap (both paths). Non-fatal.
|
||||
// Zweiter Haltepunkt: sherpa nagelt alle 4 A76-Kerne fest, das darf nicht
|
||||
// neben einer laufenden Aufnahme passieren (gate.js).
|
||||
await gate.waitIdle('diarize');
|
||||
await setA76Max(A76_CAP);
|
||||
try {
|
||||
const segs = await diar.diarize(wavPath, numSpeakers, { markers: opts.markers || [], voiceprints: opts.voiceprints || [] });
|
||||
|
||||
+36
-4
@@ -12,8 +12,26 @@ import * as diar from './diar.js';
|
||||
import * as spk from './spk.js';
|
||||
import * as search from './search.js';
|
||||
import * as emb from './emb.js';
|
||||
import * as gate from './gate.js';
|
||||
const pexec = promisify(execFile);
|
||||
|
||||
// Der Recorder ist die einzige Wahrheit darueber, ob gerade aufgenommen
|
||||
// wird. gate.js fragt sie hier ab, statt recorder.js zu importieren (das
|
||||
// waere ein Zyklus, weil recorder.js llm.js importiert).
|
||||
gate.setProbe(() => rec.isRecording());
|
||||
|
||||
// Wer sich wie freigeben lässt, wenn ein großes Modell RAM braucht (gate.freeRam).
|
||||
// Hier verdrahtet, damit gate.js keines der Module importieren muss (Zyklen) —
|
||||
// dasselbe Muster wie setProbe oben. Jeder Helfer startet faul neu.
|
||||
gate.registerReleaser('rkllm', async () => {
|
||||
if (!llm.status().running) return false;
|
||||
await llm.stopServer();
|
||||
return true;
|
||||
});
|
||||
gate.registerReleaser('cpu-transcriber', () => rec.stopTranscribeDaemon());
|
||||
gate.registerReleaser('text-embedder', () => emb.stop());
|
||||
gate.registerReleaser('spk-embedder', () => spk.stop());
|
||||
|
||||
// Glossary → { soundalike(lowercased): correctTerm }. Only entries WITH
|
||||
// soundalikes get a deterministic swap; term-only entries are left to the LLM hint.
|
||||
function glossaryMap(glossary) {
|
||||
@@ -254,7 +272,7 @@ async function analyzeRecording(id) {
|
||||
// spk-embed helper OOM-dies next to it ("spk beendet" per cluster, #71 got
|
||||
// 48 raw speakers). llm.analyze() below respawns it (~96s load, noise
|
||||
// against a 15-min analysis).
|
||||
llm.stopServer();
|
||||
await llm.stopServer();
|
||||
await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {});
|
||||
try {
|
||||
const rec = await db.getRecording(id);
|
||||
@@ -312,6 +330,13 @@ async function analyzeRecording(id) {
|
||||
a.participants = (a.participants || []).map(p => applyGlossaryText(p, gmap));
|
||||
a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap));
|
||||
await db.updateAnalysis(id, { ...a, status: 'done' });
|
||||
// rkllm ist ab hier fertig und wird freigegeben, BEVOR der Suchindex unten
|
||||
// das e5-Modell (1,15 GB) startet: am 2026-08-07 traf genau diese Reihenfolge
|
||||
// den OOM-Killer, der den residenten rkllm erschlug (13:27:09, dmesg). Vorher
|
||||
// blieb rkllm nach einer Analyse für immer resident — das war der Zustand
|
||||
// "nur noch 100 MB frei", in dem jeder weitere Helferstart russisches
|
||||
// Roulette war. Preis: die nächste Analyse lädt das Modell neu (~96 s).
|
||||
await llm.stopServer();
|
||||
// A speaker can be named WHILE the analysis runs (it takes ~15 min, and the
|
||||
// marker/voiceprint passes themselves rename clusters). The finished text
|
||||
// still carries the label from analysis start ("Sprecher 1"), so patch it
|
||||
@@ -373,6 +398,10 @@ function withNpuLock(label, fn) {
|
||||
const waiting = npuDepth - 1;
|
||||
const run = npuChain.then(async () => {
|
||||
if (waiting > 0) console.log(`npu-queue: ${label} start (${waiting} ahead done)`);
|
||||
// Nie neben einer laufenden Aufnahme: rkllm haelt ~6,7 GB von 7,74 GB,
|
||||
// eine zweite schwere Last traf am 2026-08-06 den globalen OOM-Killer
|
||||
// und nahm den Dienst mit. Siehe gate.js.
|
||||
await gate.waitIdle(label);
|
||||
try { return await fn(); }
|
||||
finally { npuDepth--; }
|
||||
});
|
||||
@@ -411,7 +440,7 @@ async function transcribeAndAnalyze(id, wavFile, numSpeakers) {
|
||||
await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {});
|
||||
return; // no transcript -> skip LLM analysis
|
||||
}
|
||||
analyzeRecording(id);
|
||||
await analyzeRecording(id);
|
||||
}
|
||||
|
||||
app.post('/api/record/stop', wrap(async (req, res) => {
|
||||
@@ -593,7 +622,7 @@ app.post('/api/search/answer', wrap(async (req, res) => {
|
||||
try {
|
||||
res.json(await withNpuLock('search-answer', () => search.answer(q, hits)));
|
||||
} finally {
|
||||
llm.stopServer();
|
||||
await llm.stopServer();
|
||||
}
|
||||
}));
|
||||
app.get('/api/search/status', wrap(async (req, res) => {
|
||||
@@ -666,7 +695,10 @@ async function rediarize(id, numSpeakers) {
|
||||
// Free the resident rkllm first — diar's consolidation embeds every cluster
|
||||
// via TitaNet, which OOM-dies next to a resident 5.9GB LLM (see
|
||||
// analyzeRecording; on #71 that silently yielded 48 raw speakers).
|
||||
llm.stopServer();
|
||||
// Abwarten: ein nicht abgewartetes stopServer() laesst proc/ready gesetzt,
|
||||
// bis das exit-Event eintrifft — der naechste ask() schreibt dann in die
|
||||
// tote Pipe (EPIPE, riss am 2026-08-06 den Dienst mit).
|
||||
await llm.stopServer();
|
||||
// Diarization runs on the A76 cores; the board is fanless, so cap the clock
|
||||
// for the whole run (transcribeWav does this for the record path — rediarize
|
||||
// is the other entry point and must do it too, or a long re-diar overheats).
|
||||
|
||||
+10
@@ -4,6 +4,7 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
import * as gate from './gate.js';
|
||||
|
||||
const BIN = '/opt/callrec/spk_embed';
|
||||
const DIR = '/opt/sherpa/sherpa-onnx-v1.13.3-linux-aarch64-shared-cpu';
|
||||
@@ -78,3 +79,12 @@ export function bestMatch(emb, voiceprints, threshold = THRESHOLD) {
|
||||
}
|
||||
|
||||
export function status() { return { model: EMB, modelId: modelId(), present: fs.existsSync(EMB), running: !!proc, threshold: THRESHOLD }; }
|
||||
|
||||
// Beendet den TitaNet-Helfer und wartet auf sein Ende (siehe gate.freeRam: hält
|
||||
// in der Spitze 0,7 GB). Startet beim nächsten embed() faul neu. Wird NUR
|
||||
// zwischen den Phasen gerufen, nie während der Diarisierung.
|
||||
export function stop() {
|
||||
const p = proc;
|
||||
proc = null; ready = null; rl = null;
|
||||
return gate.killAndWait(p);
|
||||
}
|
||||
|
||||
@@ -248,3 +248,60 @@ swappen seriell unter dem NPU-Lock.
|
||||
**Thermik:** `analyzeRecording()` läuft seit 2026-07-16 unter dem A76-Takt-Cap (`.bak-anacap`) — rkllm
|
||||
zieht neben der NPU ~1,7 CPU-Kerne, eine lange ungedeckelte Gemma-Generierung erreichte **95–97 °C**
|
||||
(lüfterlos, Schutzabschaltungs-Risiko). Gleicher Cap wie `transcribeWav`/`rediarize` (Kap. 03).
|
||||
|
||||
## Speicherhaushalt: nur EIN großes Modell resident (seit 2026-08-07)
|
||||
|
||||
Bis dahin hat jeder Helfer für sich entschieden, wann er lädt und wer dafür weichen muss — und das ging
|
||||
schief. **Gemessene Spitzen (`VmHWM`) auf 7,74 GB Gesamtspeicher:**
|
||||
|
||||
| Prozess | Spitze | Anmerkung |
|
||||
|---|---|---|
|
||||
| `rkllm_server` (Qwen3-4B w8a8) | **6,5 GB** | davon 4,79 GB `unevictable` NPU-Shmem — nicht auslagerbar |
|
||||
| `embed_text.py` (e5-small, Suche) | 1,15 GB | faul, bleibt danach resident |
|
||||
| `transcribe_daemon_cpu.py` (CT2) | 0,90 GB | Kurz-Clip-Pfad, bleibt per Design warm |
|
||||
| `spk_embed` (TitaNet) | 0,70 GB | faul, bleibt danach resident |
|
||||
|
||||
**Zwei Ausfälle am 2026-08-07, beide aus derselben Wurzel** (Aufnahme #101, 34 s, 2 Personen):
|
||||
|
||||
1. Die 2-Sekunden-Aufnahme #100 davor hatte den warmen CT2-Daemon gestartet. Beim anschließenden
|
||||
rkllm-Load war er noch resident → `rkllm_server` wurde in **allen 5 Startversuchen** OOM-getötet,
|
||||
GUI: „KI-Analyse fehlgeschlagen". Ein Retry ohne Aufräumen stirbt zwangsläufig genauso wie der erste
|
||||
Versuch.
|
||||
2. Nach einer Analyse blieb rkllm **für immer** resident (belegt: der Prozess von 10:37 lebte noch um
|
||||
13:27). Der Suchindex am Ende von `analyzeRecording()` startet aber das e5-Modell — 1,15 GB neben
|
||||
6,5 GB → der Kernel erschlug rkllm (13:27:09, `dmesg`). Das war auch der Dauerzustand
|
||||
„nur noch ~100 MB frei", in dem jeder weitere Helferstart Glückssache war.
|
||||
|
||||
⚠️ **zram rettet das nicht:** der komprimierte Swap liegt selbst im RAM. 0,9 GB ausgelagert kosten
|
||||
weiter ~0,3–0,45 GB physisch — und rkllms 4,79 GB Shmem sind ohnehin nicht auslagerbar.
|
||||
|
||||
**Regel jetzt:** wer ein großes Modell lädt, räumt vorher auf. `gate.js` führt dazu eine Registry
|
||||
(`registerReleaser` / `freeRam(keep)`, verdrahtet in `server.js` — dasselbe Muster wie `setProbe`, damit
|
||||
`gate.js` keines der Module importieren muss). Alle Helfer starten faul neu; es geht nur Ladezeit
|
||||
verloren, kein Zustand.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph REG["gate.js — Registry"]
|
||||
R1["rkllm → llm.stopServer()"]
|
||||
R2["cpu-transcriber → rec.stopTranscribeDaemon()"]
|
||||
R3["text-embedder → emb.stop()"]
|
||||
R4["spk-embedder → spk.stop()"]
|
||||
end
|
||||
A["NPU-Whisper startet<br/>(recorder.js)"] --> FA["freeRam('npu-whisper')<br/>→ beendet ALLE vier"]
|
||||
B["rkllm lädt<br/>(llm.startServer, vor JEDEM<br/>der 5 Versuche)"] --> FB["freeRam('rkllm')<br/>→ beendet die anderen drei"]
|
||||
FA --> REG
|
||||
FB --> REG
|
||||
REG --> W["SIGTERM, auf Prozessende warten,<br/>1,5 s Kernel-Reclaim<br/>(gate.killAndWait)"]
|
||||
W --> LOAD["großes Modell lädt allein"]
|
||||
LOAD --> END["Analyse fertig →<br/>llm.stopServer() VOR dem Suchindex<br/>(analyzeRecording)"]
|
||||
```
|
||||
|
||||
**Verifiziert am 2026-08-07** mit genau der Abfolge, die vorher scheiterte: 12-s-Clip → warmer
|
||||
CT2-Daemon resident → Analyse von #101 → Journal
|
||||
`ram: für rkllm freigegeben — cpu-transcriber, text-embedder, spk-embedder`, rkllm lud in **22 s** (nicht
|
||||
96 s: die Modelldatei lag noch im Seiten-Cache), Analyse in 221 s fertig, kein OOM. Danach **5,3 GB
|
||||
frei** statt 100 MB.
|
||||
|
||||
**Preis:** die nächste Analyse lädt rkllm neu. Der Kurz-Clip-Pfad verliert damit seinen „rkllm bleibt
|
||||
warm"-Vorteil (Kap. 03) — dafür ist die Analyse verlässlich statt schnell-aber-manchmal-tot.
|
||||
|
||||
@@ -3,6 +3,44 @@
|
||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||
Was, Warum, betroffene Dateien/Backups).
|
||||
|
||||
## 2026-08-07 (2) — „KI-Analyse fehlgeschlagen" bei einer 34-Sekunden-Aufnahme: zwei Modelle gleichzeitig
|
||||
|
||||
**Symptom:** Aufnahme #101 (34 s, 2 Personen) — Transkript und Sprecherspuren da, aber
|
||||
„KI-Analyse fehlgeschlagen". Also **nicht** dieselbe Ursache wie der Vormittags-Fix: der betraf die
|
||||
Länge, dieser die *Reihenfolge*. Gemeinsam ist nur der OOM-Killer.
|
||||
|
||||
**Ursache 1:** `rkllm_server` braucht 6,5 GB von 7,74 GB. Die 2-Sekunden-Aufnahme #100 unmittelbar davor
|
||||
hatte über den Kurz-Clip-Pfad den warmen CT2-Whisper-Daemon (0,9 GB) gestartet, der per Design resident
|
||||
bleibt. Beim rkllm-Load war er noch da → OOM-getötet, und zwar in **allen 5 Startversuchen**, weil das
|
||||
Retry nichts freigab: `[rkllm] start 5/5 failed: rkllm beendet vor READY`.
|
||||
|
||||
**Ursache 2:** rkllm blieb nach einer Analyse für immer resident (der Prozess von 10:37 lebte um 13:27
|
||||
noch). Der Suchindex am Ende von `analyzeRecording()` startet aber e5-small (1,15 GB) — daneben erschlug
|
||||
der Kernel rkllm (13:27:09). Dasselbe Muster war auch der Dauerzustand „nur ~100 MB frei".
|
||||
|
||||
**Fix:** `gate.js` bekommt eine Freigabe-Registry (`registerReleaser`/`freeRam(keep)`/`killAndWait`,
|
||||
verdrahtet in `server.js` wie schon `setProbe`). Wer ein großes Modell lädt, beendet vorher die anderen
|
||||
Dauerhelfer und wartet auf ihr Prozessende — vor **jedem** der 5 rkllm-Versuche und vor dem
|
||||
NPU-Whisper-Lauf. Dazu gibt `analyzeRecording()` rkllm frei, **bevor** der Suchindex läuft. Alle Helfer
|
||||
starten faul neu; verloren geht nur Ladezeit.
|
||||
|
||||
**Verifiziert** mit genau der Abfolge, die scheiterte: 12-s-Clip (CT2-Daemon resident) → Analyse #101 →
|
||||
`ram: für rkllm freigegeben — cpu-transcriber, text-embedder, spk-embedder`, rkllm lud in 22 s, Analyse
|
||||
„Auto und Budget" in 221 s fertig (`done`), kein OOM, danach **5,3 GB frei** statt 100 MB.
|
||||
|
||||
Dateien: `app/gate.js`, `app/llm.js`, `app/server.js`, `app/recorder.js`, `app/spk.js`, `app/emb.js`
|
||||
(Board-Backups `.bak-ramfree`), Kapitel [05 – LLM-Analyse](05-llm-analyse.md) (neuer Abschnitt
|
||||
„Speicherhaushalt" mit Messtabelle und Diagramm).
|
||||
|
||||
**Offen, getrennt davon (gemessen, nicht gefixt):** dieselbe Aufnahme zeigt 4 Sprecher statt 2, und die
|
||||
Stimmabdruck-Erkennung ist auf ROHEN Kosinus nicht trennscharf — `Sascha` ↔ `Sascha Wolf` (dieselbe
|
||||
Person, zwei Einträge) liegt bei 0,928, aber `Sascha Wolf` ↔ `Johannes` (verschiedene Personen) bei
|
||||
**0,912** und `Sascha` ↔ `Fabio Haase` bei 0,876. Bei `DIAR_VP_MIN` 0,55 passt damit fast jeder auf
|
||||
jeden; eine höhere Schwelle würde nur alles ablehnen. `Jens` ↔ `Jens Kuppler` ist mit Kosinus **1,000**
|
||||
sogar eine exakte Dublette. Der Clustering-Teil hat dieses Problem 2026-07-27 durch Mittelwert-Zentrierung
|
||||
gelöst — die Abdruck-Erkennung arbeitet noch ohne. Dazu kommt die Über-Segmentierung kurzer Clips
|
||||
(diagnostiziert 2026-07-31, Kap. 04).
|
||||
|
||||
## 2026-08-07 — Lange Aufnahmen scheiterten: sherpa-Diarisierung lief in den OOM-Killer
|
||||
|
||||
**Symptom:** Aufnahme #99 (53:56) endete mit „Transkription fehlgeschlagen“ (Badge „KI-Fehler“), beim
|
||||
|
||||
Reference in New Issue
Block a user