serialize ASR across tracks onto one shared worker, cut 1.7B peak memory ~13GB->~7GB
qwen_asr has no quantized-weights option, so the only lever available to reduce the 1.7B model's memory footprint is avoiding concurrent instances. Previously each track (system, mic) ran its own transcribeTrack goroutine with independent qwen_asr subprocesses, so simultaneous speech on both tracks meant two ~6.9GiB model instances alive at once - over half the 24GB target machine's memory. Merge both tracks onto a single worker (asrSegs channel, dispatch() routes both tracks onto it) so invocations are strictly serialized; per-track order is preserved since VAD's output is already chronological. Verified via pgrep -x qwen_asr polling during a real dual-track capture: never more than 1 concurrent process. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+37
-45
@@ -4,11 +4,13 @@
|
||||
// architecture and the reasoning behind it.
|
||||
//
|
||||
// This is a capture+VAD+ASR+SSE test harness: it runs audiotee, feeds
|
||||
// detected speech segments to qwen_asr (one subprocess call per segment, one
|
||||
// worker per track), and publishes the results both to stdout and to an SSE
|
||||
// endpoint (`curl -N http://<http-addr>/events` is the terminal client for
|
||||
// now — see CLAUDE.md on why SSE, not WebSocket). Post-processing (fuzzy
|
||||
// glossary correction, LLM reread) isn't wired in yet.
|
||||
// detected speech segments (from both tracks, chronologically) to a single
|
||||
// shared qwen_asr worker (one subprocess call per segment), and publishes
|
||||
// the results both to stdout and to an SSE endpoint (`curl -N
|
||||
// http://<http-addr>/events` is the terminal client for now — see
|
||||
// CLAUDE.md on why SSE, not WebSocket, and why one shared worker rather
|
||||
// than one per track). Post-processing (fuzzy glossary correction, LLM
|
||||
// reread) isn't wired in yet.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -121,30 +123,30 @@ func run() error {
|
||||
detector := vad.New(vad.DefaultConfig(sampleRate))
|
||||
segments := detector.Run(tee)
|
||||
|
||||
// Two independent transcribers (2 tracks, 2 ASR engines — see
|
||||
// CLAUDE.md), each processing its own track's segments sequentially. A
|
||||
// fresh qwen_asr subprocess per segment is fast enough (~0.15-0.2x
|
||||
// realtime measured) that this doesn't need to be more concurrent than
|
||||
// that.
|
||||
// A single shared worker across both tracks, not one per track.
|
||||
// qwen_asr has no quantized-weights option (BF16 only — see
|
||||
// third_party/qwen-asr/README.md "Memory Requirements"); its static
|
||||
// footprint alone is ~6.9GiB for the 1.7B model. Two per-track workers
|
||||
// meant two concurrent qwen_asr subprocesses whenever both tracks had
|
||||
// speech at once, doubling peak RSS to ~13GiB — over half the 24GB
|
||||
// target machine's unified memory (see "Target hardware" in
|
||||
// CLAUDE.md). Serializing onto one worker caps peak RSS to ~1 model
|
||||
// instance, trading it for latency: if both tracks talk at the same
|
||||
// time, one track's segment now waits for the other's to finish
|
||||
// transcribing instead of running in parallel. Per-track order is
|
||||
// still preserved, since `segments` (VAD's output) is itself already
|
||||
// chronological and this worker drains it in that same order.
|
||||
transcriber := asr.New(asr.Config{BinaryPath: *asrBinary, ModelDir: *asrModelDir, Prompt: *prompt})
|
||||
// transcribeTrack uses its own background context, not the
|
||||
// transcribeWorker uses its own background context, not the
|
||||
// shutdown-signal ctx above: segments still queued when Ctrl+C arrives
|
||||
// (including the ones flushed by c.Stop() below) must still get
|
||||
// transcribed during the graceful drain, not fail with "context
|
||||
// canceled" because the shutdown signal already canceled ctx.
|
||||
asrCtx := context.Background()
|
||||
var systemSegs, micSegs chan vad.Segment
|
||||
asrSegs := make(chan vad.Segment, 16)
|
||||
var wg sync.WaitGroup
|
||||
if *captureSystem {
|
||||
systemSegs = make(chan vad.Segment, 8)
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); transcribeTrack(asrCtx, transcriber, systemSegs, broadcaster, fileWriter) }()
|
||||
}
|
||||
if *captureMic {
|
||||
micSegs = make(chan vad.Segment, 8)
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); transcribeTrack(asrCtx, transcriber, micSegs, broadcaster, fileWriter) }()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); transcribeWorker(asrCtx, transcriber, asrSegs, broadcaster, fileWriter) }()
|
||||
|
||||
loop:
|
||||
for {
|
||||
@@ -154,7 +156,7 @@ loop:
|
||||
break loop
|
||||
}
|
||||
logSegment(seg)
|
||||
dispatch(seg, systemSegs, micSegs)
|
||||
dispatch(seg, asrSegs, *captureSystem, *captureMic)
|
||||
case err := <-c.Errs():
|
||||
fmt.Fprintln(os.Stderr, "capture error:", err)
|
||||
case err := <-httpErrs:
|
||||
@@ -182,17 +184,9 @@ loop:
|
||||
// Drain any segments flushed by Stop() closing the chunk stream.
|
||||
for seg := range segments {
|
||||
logSegment(seg)
|
||||
dispatch(seg, systemSegs, micSegs)
|
||||
}
|
||||
|
||||
// Closing a nil channel panics — only close the ones actually created
|
||||
// (i.e. for tracks that were enabled).
|
||||
if systemSegs != nil {
|
||||
close(systemSegs)
|
||||
}
|
||||
if micSegs != nil {
|
||||
close(micSegs)
|
||||
dispatch(seg, asrSegs, *captureSystem, *captureMic)
|
||||
}
|
||||
close(asrSegs)
|
||||
wg.Wait() // let in-flight/queued transcriptions finish before exiting, and
|
||||
// before their results are published — otherwise a connected SSE client
|
||||
// would miss the tail end of the transcript.
|
||||
@@ -204,21 +198,19 @@ loop:
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch routes seg to the channel for its track. Sending to a nil
|
||||
// channel (a disabled track — see -capture-system/-capture-mic) blocks
|
||||
// forever in Go, so it must be checked, not just sent to unconditionally;
|
||||
// a disabled track's segments are silently dropped (VAD still runs on
|
||||
// every track's chunks regardless — negligible cost — but a disabled
|
||||
// track never reaches ASR).
|
||||
func dispatch(seg vad.Segment, systemSegs, micSegs chan<- vad.Segment) {
|
||||
// dispatch sends seg to the single shared ASR worker if its track is
|
||||
// enabled. A disabled track's segments are silently dropped (VAD still
|
||||
// runs on every track's chunks regardless — negligible cost — but a
|
||||
// disabled track never reaches ASR).
|
||||
func dispatch(seg vad.Segment, asrSegs chan<- vad.Segment, captureSystem, captureMic bool) {
|
||||
switch seg.Track {
|
||||
case capture.TrackSystem:
|
||||
if systemSegs != nil {
|
||||
systemSegs <- seg
|
||||
if captureSystem {
|
||||
asrSegs <- seg
|
||||
}
|
||||
case capture.TrackMic:
|
||||
if micSegs != nil {
|
||||
micSegs <- seg
|
||||
if captureMic {
|
||||
asrSegs <- seg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +230,7 @@ func httpAddrForDisplay(addr string) string {
|
||||
return addr
|
||||
}
|
||||
|
||||
func transcribeTrack(ctx context.Context, t *asr.Transcriber, segs <-chan vad.Segment, b *transcript.Broadcaster, fw *transcript.FileWriter) {
|
||||
func transcribeWorker(ctx context.Context, t *asr.Transcriber, segs <-chan vad.Segment, b *transcript.Broadcaster, fw *transcript.FileWriter) {
|
||||
for seg := range segs {
|
||||
text, err := t.Transcribe(ctx, seg.Data)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user