acquire() judges at the slot: household signals first (admission), then a held slot (slot); nothing queues, so a request can no longer wait on the worker lock past a changed gate. While Vili's job holds the slot, and until free VRAM recovers to its pre-job level (capped), low free VRAM is not the household's; full-screen and 3D always count. A sample with no resolved 3D counters is a probe fault and the gate fails closed. The placement 503 carries no gate status. A malformed VILI_GPU_* override refuses to start, naming the variable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
5.4 KiB
JavaScript
152 lines
5.4 KiB
JavaScript
// GPU tenancy gate: the one owner of "may heavy GPU work start now". It reads numbers only
|
|
// (full-screen state, 3D-engine use, host free VRAM), keeps a yes/no and a timestamp, and never
|
|
// learns which program is running. It refuses new work; it never stops work.
|
|
//
|
|
// The daemon holds one GPU slot. A job takes the slot through acquire() and the gate judges at that
|
|
// moment: the household signals say no, or another job holds the slot, and the answer is a typed
|
|
// refusal. Nothing waits. While Vili's own job holds the slot, and until its VRAM has drained after
|
|
// the slot frees, the free-VRAM signal is not the household's: the encoder child sizes itself to the
|
|
// VRAM that was free, so no VRAM sample separates Vili from a game. Full-screen and 3D-engine use
|
|
// always count.
|
|
|
|
export const RETRY_AFTER_SECONDS = 60;
|
|
const QUNS_BUSY = 2;
|
|
const QUNS_RUNNING_D3D_FULL_SCREEN = 3;
|
|
|
|
export function createGpuTenancy({
|
|
now,
|
|
sampleIntervalMs = 15000,
|
|
clearHoldMs = 180000,
|
|
minFreeMiB = 10240,
|
|
util3dBusyPct = 20,
|
|
staleAfterMs = 3 * sampleIntervalMs,
|
|
drainToleranceMiB = 1024,
|
|
drainCapMs = 4 * sampleIntervalMs,
|
|
}) {
|
|
const startedAt = now();
|
|
let inUse = true;
|
|
let since = startedAt;
|
|
let checkedAt = null;
|
|
let lastSeenAt = startedAt;
|
|
let clearSince = null;
|
|
let previousUtil3d = null;
|
|
let lastFreeMiB = null;
|
|
let held = false;
|
|
let baselineFreeMiB = null;
|
|
let drainingSince = null;
|
|
|
|
const staleSince = () => (now() - lastSeenAt > staleAfterMs ? lastSeenAt + staleAfterMs : null);
|
|
|
|
function observe(sample) {
|
|
// A probe that found no 3D engine counters is blind to the signal: a probe fault, not a clear
|
|
// sample. The sample is dropped, the gate goes stale and fails closed.
|
|
if (!(sample.engines >= 1)) return;
|
|
const t = now();
|
|
const staleAt = staleSince();
|
|
if (staleAt !== null) {
|
|
if (!inUse) {
|
|
inUse = true;
|
|
since = staleAt;
|
|
}
|
|
clearSince = null;
|
|
previousUtil3d = null;
|
|
}
|
|
if (drainingSince !== null && (sample.freeMiB >= baselineFreeMiB - drainToleranceMiB || t - drainingSince >= drainCapMs)) {
|
|
drainingSince = null;
|
|
}
|
|
const ownVram = held || drainingSince !== null;
|
|
const sustained3d = sample.util3d > util3dBusyPct && previousUtil3d !== null && previousUtil3d > util3dBusyPct;
|
|
const busy = sample.quns === QUNS_BUSY
|
|
|| sample.quns === QUNS_RUNNING_D3D_FULL_SCREEN
|
|
|| (!ownVram && sample.freeMiB < minFreeMiB)
|
|
|| sustained3d;
|
|
previousUtil3d = sample.util3d;
|
|
lastFreeMiB = sample.freeMiB;
|
|
lastSeenAt = t;
|
|
checkedAt = t;
|
|
if (busy) {
|
|
clearSince = null;
|
|
if (!inUse) {
|
|
inUse = true;
|
|
since = t;
|
|
}
|
|
return;
|
|
}
|
|
if (clearSince === null) clearSince = t;
|
|
if (inUse && t - clearSince >= clearHoldMs) {
|
|
inUse = false;
|
|
since = t;
|
|
}
|
|
}
|
|
|
|
function status() {
|
|
const staleAt = staleSince();
|
|
if (staleAt !== null) {
|
|
return { inUse: true, since: inUse ? since : staleAt, checkedAt, probeOk: false };
|
|
}
|
|
return { inUse, since, checkedAt, probeOk: true };
|
|
}
|
|
|
|
// Judges once and either takes the slot or answers the typed refusal. The household's answer comes
|
|
// first: it is the one a consumer can act on.
|
|
function acquire() {
|
|
const gpu = status();
|
|
if (gpu.inUse) return { refusal: { ok: false, code: "gpu-busy", source: "admission", gpu } };
|
|
if (held) return { refusal: { ok: false, code: "gpu-busy", source: "slot" } };
|
|
held = true;
|
|
if (drainingSince === null) baselineFreeMiB = lastFreeMiB;
|
|
return {
|
|
release() {
|
|
if (!held) return;
|
|
held = false;
|
|
drainingSince = now();
|
|
},
|
|
};
|
|
}
|
|
|
|
return { observe, status, acquire };
|
|
}
|
|
|
|
// Takes the slot, runs the job to its end and returns its result, or returns the typed refusal
|
|
// without calling the job. The gate never holds, pauses or cancels a job.
|
|
export async function admitThen(gate, job) {
|
|
const slot = gate.acquire();
|
|
if (slot.refusal) return slot.refusal;
|
|
try {
|
|
return await job();
|
|
} finally {
|
|
slot.release();
|
|
}
|
|
}
|
|
|
|
// The encoder child's placement refusal reaches the daemon as a typed code on the worker's error body;
|
|
// it is never read out of error text. Placement is the child's own view of free VRAM, not the
|
|
// household gate, so the body carries no gate status. Returns the typed 503 body, or null for any
|
|
// other outcome.
|
|
export function placementRefusal(workerBody, jobId) {
|
|
if (workerBody?.code !== "gpu-busy") return null;
|
|
return { ok: false, code: "gpu-busy", source: "placement", jobId };
|
|
}
|
|
|
|
const ENV_OPTIONS = [
|
|
["VILI_GPU_SAMPLE_MS", "sampleIntervalMs", true],
|
|
["VILI_GPU_CLEAR_HOLD_MS", "clearHoldMs", false],
|
|
["VILI_GPU_MIN_FREE_MIB", "minFreeMiB", false],
|
|
["VILI_GPU_UTIL3D_BUSY_PCT", "util3dBusyPct", false],
|
|
];
|
|
|
|
// Operator overrides. A malformed value would silently defeat a comparison (NaN is never below
|
|
// anything), so it refuses to start instead, naming the variable and never its value.
|
|
export function gpuTenancyOptionsFromEnv(env) {
|
|
const options = {};
|
|
for (const [name, key, positive] of ENV_OPTIONS) {
|
|
const raw = env[name];
|
|
if (raw === undefined) continue;
|
|
const value = raw.trim() === "" ? Number.NaN : Number(raw);
|
|
if (!Number.isFinite(value) || value < 0 || (positive && value === 0)) {
|
|
throw new Error(`${name} must be a finite ${positive ? "positive" : "non-negative"} number`);
|
|
}
|
|
options[key] = value;
|
|
}
|
|
return options;
|
|
}
|