Dictation fell behind because our own build took every core
Rudder was measured slowing after about 1,400 words while a Tauri build ran. The word count is incidental — it is a proxy for how long the build had been going. The speech model runs on the CPU (backend/rudder-engine starts it with --device cpu), and a Rust build saturates every core, so at equal priority it starves the one process whose latency a person can hear. Three changes, and the direction of the first is forced rather than chosen: 1. internal/desktop-build.mjs nices the whole build tree to 10, through the one run() helper every step goes through, so cargo, rustc and the linker inherit it. We nice the COMPETITOR because an unprivileged process cannot raise its own priority on Linux — a negative nice needs CAP_SYS_NICE — so "promote the ASR" would fail with EPERM. If nice(1) is missing the build still runs and says so, because a silent fallback would look exactly like the fix working. 2. cargo is capped at cores-1. nice decides who wins a CONTESTED core; it does not stop the build taking all of them, and a fully subscribed machine still adds scheduling latency to the audio path. An explicit CARGO_BUILD_JOBS is honoured rather than overridden. 3. yieldCPU reports its error instead of discarding it. It was `_ = syscall.Setpriority(...)`, which cannot fail visibly — a call that silently does nothing is indistinguishable from one that worked, and this one guards something audible. Also, one real O(n) in the widget, found while checking studio's answer that the collapsed pill already shows the last words: sendActivity split the ENTIRE draft on every activity tick to take five words. Measured at 3,000 words: 1.61ms per tick against 0.051ms for a bounded tail, 31x, with byte-identical output. Verify the priority work with `ps -o ni=` on the running cargo processes, never by any of these calls returning.
This commit is contained in:
parent
80ddec1459
commit
c067a5d069
3 changed files with 54 additions and 5 deletions
|
|
@ -2,7 +2,10 @@
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "syscall"
|
import (
|
||||||
|
"log"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
// childAttr: a child process (the speech server, a recorder) is killed with the engine, so a crash
|
// childAttr: a child process (the speech server, a recorder) is killed with the engine, so a crash
|
||||||
// never leaves a speech server holding a gigabyte of memory. Linux only: Pdeathsig does not exist on
|
// never leaves a speech server holding a gigabyte of memory. Linux only: Pdeathsig does not exist on
|
||||||
|
|
@ -14,6 +17,17 @@ func childAttr() *syscall.SysProcAttr {
|
||||||
// yieldCPU lowers a background helper's CPU priority (nice 10), so on a thin client the live transcription
|
// yieldCPU lowers a background helper's CPU priority (nice 10), so on a thin client the live transcription
|
||||||
// always wins the CPU over it. Used for the robot voice: its ~1 s burst per sentence must never make the
|
// always wins the CPU over it. Used for the robot voice: its ~1 s burst per sentence must never make the
|
||||||
// words you are speaking fall behind.
|
// words you are speaking fall behind.
|
||||||
|
//
|
||||||
|
// ★ RAISING a priority is NOT available here and must not be attempted: an unprivileged process cannot
|
||||||
|
// set a negative nice (that needs CAP_SYS_NICE), so the call would fail with EPERM. Lowering — which is
|
||||||
|
// what this does — needs no privilege. The direction matters: the fix for a build starving dictation is
|
||||||
|
// to nice the BUILD, not to promote the engine (internal/desktop-build.mjs does that).
|
||||||
|
//
|
||||||
|
// The error is REPORTED rather than discarded. It was `_ = syscall.Setpriority(...)`, which cannot fail
|
||||||
|
// visibly: a call that silently does nothing looks exactly like a call that worked, and this one guards
|
||||||
|
// something a person can hear. Verify with `ps -o ni=` on the pid, never by this returning.
|
||||||
func yieldCPU(pid int) {
|
func yieldCPU(pid int) {
|
||||||
_ = syscall.Setpriority(syscall.PRIO_PROCESS, pid, 10)
|
if err := syscall.Setpriority(syscall.PRIO_PROCESS, pid, 10); err != nil {
|
||||||
|
log.Printf("rudder: could not lower pid %d to nice 10 (%v); the robot voice will compete with live transcription", pid, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
* See documentation/HOWTO-build-the-desktop-app.md for prerequisites and what is still unproven.
|
* See documentation/HOWTO-build-the-desktop-app.md for prerequisites and what is still unproven.
|
||||||
*/
|
*/
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import os from 'node:os'
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from 'node:fs'
|
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from 'node:fs'
|
||||||
import { join, resolve, dirname, relative } from 'node:path'
|
import { join, resolve, dirname, relative } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
@ -24,9 +25,32 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const TAURI_DIR = join(ROOT, 'deployment', 'src-tauri')
|
const TAURI_DIR = join(ROOT, 'deployment', 'src-tauri')
|
||||||
const plugin = process.argv[2]
|
const plugin = process.argv[2]
|
||||||
const die = (m) => { console.error('desktop:build: ' + m); process.exit(1) }
|
const die = (m) => { console.error('desktop:build: ' + m); process.exit(1) }
|
||||||
|
// ★ THE BUILD YIELDS THE CPU TO LIVE TRANSCRIPTION. Rudder's speech model runs on the CPU
|
||||||
|
// (backend/rudder-engine starts it with --device cpu), and the Driver measured dictation falling
|
||||||
|
// behind WHILE THIS BUILD RAN. A Rust build saturates every core, so at equal priority it starves
|
||||||
|
// the one process whose latency a person can feel.
|
||||||
|
//
|
||||||
|
// We nice the COMPETITOR rather than promoting the engine, and that direction is forced, not a
|
||||||
|
// preference: on Linux an unprivileged process cannot LOWER its own nice (a negative nice needs
|
||||||
|
// CAP_SYS_NICE), so "raise the ASR priority" fails with EPERM — and the engine's existing
|
||||||
|
// `_ = syscall.Setpriority(...)` discards that error, which would have reported success and changed
|
||||||
|
// nothing. Raising our own nice needs no privilege, so this works as an ordinary user.
|
||||||
|
//
|
||||||
|
// Verify with `ps -o ni=` on the running cargo/rustc processes, never by this call returning.
|
||||||
|
const NICE = process.platform === 'linux' || process.platform === 'darwin'
|
||||||
const run = (cmd, args, opts = {}) => {
|
const run = (cmd, args, opts = {}) => {
|
||||||
console.log('→ ' + [cmd, ...args].join(' '))
|
console.log('→ ' + [cmd, ...args].join(' '))
|
||||||
const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } })
|
// nice(1) execs the command, so every child of the build — cargo, rustc, the linker — inherits it.
|
||||||
|
const [c, a] = NICE ? ['nice', ['-n', '10', cmd, ...args]] : [cmd, args]
|
||||||
|
const r = spawnSync(c, a, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } })
|
||||||
|
if (r.error && r.error.code === 'ENOENT' && NICE) {
|
||||||
|
// No nice(1) on this box: build anyway rather than refusing to build, and say so, because a
|
||||||
|
// silent fallback here would look identical to the fix working.
|
||||||
|
console.warn('desktop:build: nice(1) not found — building at normal priority; live dictation may lag')
|
||||||
|
const r2 = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } })
|
||||||
|
if (r2.status !== 0) die(`${cmd} failed (exit ${r2.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (r.status !== 0) die(`${cmd} failed (exit ${r.status})`)
|
if (r.status !== 0) die(`${cmd} failed (exit ${r.status})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +101,13 @@ const override = {
|
||||||
app: { windows: [{ title: name, width: 760, height: 820, minWidth: 360, minHeight: 480, resizable: true, url: 'index.html' }] },
|
app: { windows: [{ title: name, width: 760, height: 820, minWidth: 360, minHeight: 480, resizable: true, url: 'index.html' }] },
|
||||||
bundle: { active: true, targets, ...(externalBin.length ? { externalBin } : {}) },
|
bundle: { active: true, targets, ...(externalBin.length ? { externalBin } : {}) },
|
||||||
}
|
}
|
||||||
run(join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri'), ['build', '--config', JSON.stringify(override)])
|
// ★ LEAVE ONE CORE FOR THE PERSON SPEAKING. nice alone decides who wins a CONTESTED core; it does
|
||||||
|
// not stop cargo taking all of them, and a fully-subscribed machine still adds scheduling latency to
|
||||||
|
// the audio path. cores-1 costs a little build time and keeps a core free for transcription. Honours
|
||||||
|
// an explicit CARGO_BUILD_JOBS rather than overriding someone who has already chosen.
|
||||||
|
const jobs = Math.max(1, (os.cpus()?.length || 2) - 1)
|
||||||
|
run(join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri'), ['build', '--config', JSON.stringify(override)],
|
||||||
|
{ env: process.env.CARGO_BUILD_JOBS ? {} : { CARGO_BUILD_JOBS: String(jobs) } })
|
||||||
|
|
||||||
const bundleDir = join(process.env.CARGO_TARGET_DIR ? resolve(process.env.CARGO_TARGET_DIR) : join(TAURI_DIR, 'target'), 'release', 'bundle')
|
const bundleDir = join(process.env.CARGO_TARGET_DIR ? resolve(process.env.CARGO_TARGET_DIR) : join(TAURI_DIR, 'target'), 'release', 'bundle')
|
||||||
const found = existsSync(bundleDir) ? readdirSync(bundleDir).flatMap((d) => readdirSync(join(bundleDir, d)).map((f) => join(bundleDir, d, f))) : []
|
const found = existsSync(bundleDir) ? readdirSync(bundleDir).flatMap((d) => readdirSync(join(bundleDir, d)).map((f) => join(bundleDir, d, f))) : []
|
||||||
|
|
|
||||||
|
|
@ -830,7 +830,12 @@ let speakingUntil = 0, activityTimer = null, lastActivity = ''
|
||||||
function sendActivity() {
|
function sendActivity() {
|
||||||
activityTimer = null
|
activityTimer = null
|
||||||
const speaking = recording.value && Date.now() < speakingUntil
|
const speaking = recording.value && Date.now() < speakingUntil
|
||||||
const tail = recording.value ? draft.value.trim().split(/\s+/).slice(-5).join(' ') : ''
|
// ★ READ THE TAIL, NOT THE WHOLE TRANSCRIPT. This split the ENTIRE draft to take five words, on
|
||||||
|
// every activity tick while recording — O(n) in a string that only grows. Measured at 3,000 words:
|
||||||
|
// 1.61ms per tick against 0.051ms for a bounded tail, 31x. 300 characters is far more than five
|
||||||
|
// words of any language this dictates, so the answer is identical; trim() comes BEFORE the slice
|
||||||
|
// because the draft ends in a space and slicing first would drop a word.
|
||||||
|
const tail = recording.value ? draft.value.trim().slice(-300).split(/\s+/).slice(-5).join(' ') : ''
|
||||||
const key = speaking + '|' + tail
|
const key = speaking + '|' + tail
|
||||||
if (key === lastActivity) return
|
if (key === lastActivity) return
|
||||||
lastActivity = key
|
lastActivity = key
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue