@gjsify/machine-fit 0.0.0-stage → 0.64.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -2
- package/lib/esm/_virtual/_rolldown/runtime.js +1 -0
- package/lib/esm/fake-machine.js +1 -0
- package/lib/esm/gpu-guard.js +1 -0
- package/lib/esm/index.js +1 -0
- package/lib/esm/machine-probe.js +1 -0
- package/lib/esm/memory.js +2 -0
- package/lib/esm/power.js +1 -0
- package/lib/esm/probe-backends.js +1 -0
- package/lib/esm/vulkan.js +1 -0
- package/lib/types/fake-machine.d.ts +16 -0
- package/lib/types/gpu-guard.d.ts +59 -0
- package/lib/types/gpu-guard.spec.d.ts +2 -0
- package/lib/types/hints.spec.d.ts +2 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/machine-probe.d.ts +26 -0
- package/lib/types/memory.d.ts +84 -0
- package/lib/types/memory.spec.d.ts +2 -0
- package/lib/types/power.d.ts +18 -0
- package/lib/types/probe-backends.d.ts +61 -0
- package/lib/types/probe-backends.spec.d.ts +2 -0
- package/lib/types/vulkan.d.ts +17 -0
- package/package.json +82 -5
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gjsify/machine-fit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Will a model run on this machine, and on which backend? A static memory check that can rule a model out before it is downloaded, and the probe that decides speed once the work starts. Pure TypeScript on `node:*` APIs: it runs under plain Node as well as under GJS.
|
|
4
|
+
|
|
5
|
+
Part of the [gjsify](https://github.com/gjsify/gjsify) project. **Tier 3 (experimental, ADR 0003)**: tested against made-up machines (a fake `/sys`, a fake `nvidia-smi`); no test reads real hardware. Design: ADR 0127 decisions 11–12, ADR 0136.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gjsify install @gjsify/machine-fit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { checkFit, estimateFootprint, probeBackends, readMachineMemory } from '@gjsify/machine-fit';
|
|
17
|
+
|
|
18
|
+
// Before download: rule out what cannot fit. Only `none` is a verdict.
|
|
19
|
+
const required = estimateFootprint({
|
|
20
|
+
weightsBytes: 4.9e9, // the model file
|
|
21
|
+
contextTokens: 8192,
|
|
22
|
+
kvBytesPerToken: 2 * 32 * 8 * 128 * 2, // 2 × layers × kvHeads × headDim × bytes
|
|
23
|
+
overheadBytes: 512 * 1024 ** 2,
|
|
24
|
+
});
|
|
25
|
+
const fit = checkFit(required, readMachineMemory());
|
|
26
|
+
if (fit.placement === 'none') offerSmallerModel();
|
|
27
|
+
|
|
28
|
+
// When the work starts: measure. The candidates and the budget are yours.
|
|
29
|
+
const result = await probeBackends(
|
|
30
|
+
[
|
|
31
|
+
{ id: 'vulkan', run: (signal) => engine.tryOnce('vulkan', signal) },
|
|
32
|
+
{ id: 'cpu', run: (signal) => engine.tryOnce('cpu', signal) },
|
|
33
|
+
],
|
|
34
|
+
3000,
|
|
35
|
+
{ setting: process.env.MYAPP_BACKEND || undefined },
|
|
36
|
+
);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## What the memory check reads
|
|
40
|
+
|
|
41
|
+
| | RAM | GPU memory |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Linux | `os.totalmem()` / `os.freemem()` (`MemAvailable`) | AMD: sysfs `mem_info_vram_*`; NVIDIA: `nvidia-smi`; integrated Intel: bounded by RAM |
|
|
44
|
+
| macOS | same | Apple silicon: `iogpu.wired_limit_mb`, else 75 % of RAM (`APPLE_DEFAULT_GPU_SHARE`); Intel Macs: unknown |
|
|
45
|
+
| Windows | same | NVIDIA via `nvidia-smi`; others unknown |
|
|
46
|
+
|
|
47
|
+
The check uses FREE GPU memory: a model another server already holds (Ollama, LM Studio) counts as used. A GPU whose memory cannot be read is "cannot rule out", never zero.
|
|
48
|
+
|
|
49
|
+
`checkFit` answers `gpu`, `gpu+cpu` (split onto RAM, dedicated GPUs only), `cpu`, `unknown` or `none`. Speed is not its business: that is what `probeBackends` measures.
|
|
50
|
+
|
|
51
|
+
## The probe and its guards
|
|
52
|
+
|
|
53
|
+
- `probeBackends(candidates, budgetMs, options)` runs the candidates in order and returns the first that finishes within the budget. An explicit `setting` wins without measuring; on battery it returns `on-battery` without running anything.
|
|
54
|
+
- `createGpuGuard({ dir: defaultGuardDir('myapp') })` writes a marker around each GPU call. A driver that `abort()`s the process leaves the marker behind, and the next start reads it as a pin to the CPU. `clear()` lifts the pin.
|
|
55
|
+
- `hasVulkan()`, `onBattery()` and `parseGpuSetting()` are hints for choosing the candidates.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=Object.defineProperty,__name=(t,n)=>e(t,`name`,{value:n,configurable:!0});export{__name};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";function fakeMachine(e={}){let t=e.files??{},n=[];return{ran:n,env:e.env??{},platform:e.platform??`linux`,arch:e.arch??`x64`,exists:e=>e in t||Object.keys(t).some(t=>t.startsWith(`${e}/`)),list:e=>[...new Set(Object.keys(t).filter(t=>t.startsWith(`${e}/`)).map(t=>t.slice(e.length+1).split(`/`)[0]))],read:e=>t[e]?.trim(),run:(t,r)=>{let i=[t,...r].join(` `);return n.push(i),e.commands?.[i]},totalmem:()=>e.totalmem??16*1024**3,freemem:()=>e.freemem??8*1024**3}}export{fakeMachine};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{mkdirSync as e,readFileSync as t,readdirSync as n,rmSync as r,writeFileSync as i}from"node:fs";import{join as a}from"node:path";import o from"node:process";const s=`gpu-pin.json`;function processAlive(e){try{return o.kill(e,0),!0}catch(e){return e.code===`EPERM`}}function defaultGuardDir(e,t=o.env,n=o.platform){return t.XDG_STATE_HOME!==void 0&&t.XDG_STATE_HOME!==``?a(t.XDG_STATE_HOME,e):n===`win32`&&t.LOCALAPPDATA?a(t.LOCALAPPDATA,e):a(t.HOME??t.USERPROFILE??`.`,`.local`,`state`,e)}function createGpuGuard(c){let l=c.pid??o.pid,u=c.alive??processAlive,d=c.now??(()=>new Date),f=a(c.dir,`gpu-sessions`),p=a(f,`${l}.json`),m=0,attempt=e=>{try{e()}catch{}},readPin=()=>{try{return JSON.parse(t(a(c.dir,s),`utf8`))}catch{return}},sweep=()=>{let t;try{t=n(f)}catch{return}for(let n of t){let t=Number.parseInt(n,10);!Number.isInteger(t)||t===l||u(t)||attempt(()=>{e(c.dir,{recursive:!0});let o={at:d().toISOString(),pid:t};i(a(c.dir,s),JSON.stringify(o,null,2)),r(a(f,n),{force:!0})})}},enter=()=>{m++>0||attempt(()=>{e(f,{recursive:!0}),i(p,JSON.stringify({pid:l,at:d().toISOString()}))})},leave=()=>{--m>0||(m=0,attempt(()=>r(p,{force:!0})))};return{pin(){return sweep(),readPin()},async during(e){enter();try{return await e()}finally{leave()}},clear(){attempt(()=>r(a(c.dir,s),{force:!0}))},release(){m=0,attempt(()=>r(p,{force:!0}))}}}export{createGpuGuard,defaultGuardDir,processAlive};
|
package/lib/esm/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createGpuGuard as e,defaultGuardDir as t,processAlive as n}from"./gpu-guard.js";import{systemProbe as r}from"./machine-probe.js";import{hasVulkan as i,parseGpuSetting as a}from"./vulkan.js";import{onBattery as o,onBatteryMac as s}from"./power.js";import{probeBackends as c}from"./probe-backends.js";import{APPLE_DEFAULT_GPU_SHARE as l,checkFit as u,estimateFootprint as d,parseNvidiaSmi as f,readMachineMemory as p}from"./memory.js";export{l as APPLE_DEFAULT_GPU_SHARE,u as checkFit,e as createGpuGuard,t as defaultGuardDir,d as estimateFootprint,i as hasVulkan,o as onBattery,s as onBatteryMac,a as parseGpuSetting,f as parseNvidiaSmi,c as probeBackends,n as processAlive,p as readMachineMemory,r as systemProbe};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{existsSync as e,readFileSync as t,readdirSync as n}from"node:fs";import r from"node:process";import{execFileSync as i}from"node:child_process";import{arch as a,freemem as o,platform as s,totalmem as c}from"node:os";function systemProbe(){return{env:r.env,platform:s(),arch:a(),exists:t=>e(t),list:e=>{try{return n(e)}catch{return[]}},read:e=>{try{return t(e,`utf8`).trim()}catch{return}},run:(e,t,n)=>{try{return i(e,[...t],{encoding:`utf8`,timeout:n,stdio:[`ignore`,`pipe`,`ignore`]})}catch{return}},totalmem:()=>c(),freemem:()=>o()}}export{systemProbe};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{systemProbe as e}from"./machine-probe.js";import{join as t}from"node:path";const n=`/sys/class/drm`,r=1024*1024,i={"0x10de":`nvidia`,"0x1002":`amd`,"0x8086":`intel`};function bytes(e){if(!(e===void 0||!/^\d+$/.test(e)))return Number(e)}function parseNvidiaSmi(e){if(e===void 0)return;let t=[];for(let n of e.split(`
|
|
2
|
+
`)){let e=n.split(`,`).map(e=>e.trim());if(e.length!==3)continue;let[i,a,o]=e,s=bytes(a),c=bytes(o);s!==void 0&&t.push({vendor:`nvidia`,name:i,totalBytes:s*r,usedBytes:c===void 0?void 0:c*r,unified:!1,source:`nvidia-smi`})}return t.length>0?t:void 0}function nvidiaSmi(e){return parseNvidiaSmi(e.run(`nvidia-smi`,[`--query-gpu=name,memory.total,memory.used`,`--format=csv,noheader,nounits`],3e3))}function linuxGpus(e){let r=[],a=!1;for(let o of e.list(n).filter(e=>/^card\d+$/.test(e)).sort()){let s=t(n,o,`device`),c=i[e.read(t(s,`vendor`))??``]??`unknown`;if(c===`nvidia`){a=!0;continue}let l=bytes(e.read(t(s,`mem_info_vram_total`))),u=bytes(e.read(t(s,`mem_info_vram_used`)));r.push({vendor:c,name:e.read(t(s,`product_name`))||void 0,totalBytes:l,usedBytes:u,unified:c===`intel`&&l===void 0,source:l===void 0?`none`:`sysfs`})}return a&&r.push(...nvidiaSmi(e)??[{...o,vendor:`nvidia`}]),r}const a=.75;function appleGpu(e){let t=bytes(e.run(`/usr/sbin/sysctl`,[`-n`,`iogpu.wired_limit_mb`],2e3)?.trim());return{vendor:`apple`,totalBytes:t!==void 0&&t>0?t*r:Math.floor(e.totalmem()*a),unified:!0,source:`sysctl`}}const o={vendor:`unknown`,unified:!1,source:`none`};function readMachineMemory(t=e()){let n=[];return n=t.platform===`linux`?linuxGpus(t):t.platform===`darwin`?t.arch===`arm64`?[appleGpu(t)]:[o]:t.platform===`win32`&&nvidiaSmi(t)||[o],{ramTotalBytes:t.totalmem(),ramAvailableBytes:t.freemem(),gpus:n}}function estimateFootprint(e){let t=e.weightsBytes??(e.parameters!==void 0&&e.bitsPerWeight!==void 0?e.parameters*e.bitsPerWeight/8:void 0);if(t===void 0)throw TypeError(`estimateFootprint needs weightsBytes, or parameters and bitsPerWeight`);let n=(e.contextTokens??0)*(e.kvBytesPerToken??0);return Math.ceil(t+n+(e.overheadBytes??0))}function freeGpuBytes(e,t){if(e.totalBytes===void 0)return e.unified?t:void 0;let n=Math.max(0,e.totalBytes-(e.usedBytes??0));return e.unified?Math.min(n,t):n}function checkFit(e,t){let n=t.ramAvailableBytes,r=t.gpus.map(e=>freeGpuBytes(e,n)),best=e=>r.findIndex((t,n)=>t!==void 0&&e(t,n)),i=best(t=>t>=e);if(i>=0)return{placement:`gpu`,requiredBytes:e,gpu:i};let a=best((r,i)=>!t.gpus[i].unified&&r>0&&r+n>=e);return a>=0?{placement:`gpu+cpu`,requiredBytes:e,gpu:a}:n>=e?{placement:`cpu`,requiredBytes:e}:r.some(e=>e===void 0)?{placement:`unknown`,requiredBytes:e}:{placement:`none`,requiredBytes:e}}export{a as APPLE_DEFAULT_GPU_SHARE,checkFit,estimateFootprint,parseNvidiaSmi,readMachineMemory};
|
package/lib/esm/power.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{systemProbe as e}from"./machine-probe.js";import{join as t}from"node:path";function onBattery(n=e(),r=`/sys/class/power_supply`){if(n.platform===`darwin`)return onBatteryMac(n.run(`/usr/bin/pmset`,[`-g`,`batt`],2e3));if(n.platform!==`linux`)return!1;let read=(e,i)=>n.read(t(r,e,i))??``,i=!1;for(let e of n.list(r)){let t=read(e,`type`);if(t===`Mains`&&read(e,`online`)===`1`)return!1;t===`Battery`&&read(e,`scope`)===`Device`||t===`Battery`&&read(e,`status`)===`Discharging`&&(i=!0)}return i}function onBatteryMac(e){return e!==void 0&&/drawing from 'Battery Power'/.test(e)}export{onBattery,onBatteryMac};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{onBattery as e}from"./power.js";const t=Symbol(`too-slow`);async function probeBackends(n,r,i={}){if(i.setting!==void 0){if(!n.some(e=>e.id===i.setting)){let e=n.map(e=>e.id).join(`, `);throw RangeError(`unknown backend "${i.setting}"; expected one of: ${e}`)}return{chosen:i.setting,reason:`setting`,attempts:[]}}if((i.onBattery??(()=>e()))())return{chosen:void 0,reason:`on-battery`,attempts:[]};let a=i.now??(()=>Date.now()),o=i.setTimer??((e,t)=>setTimeout(e,t)),s=i.clearTimer??(e=>clearTimeout(e)),c=[];for(let e of n){let n=new AbortController,i,l=new Promise(e=>{i=o(()=>e(t),r)}),u=a();try{let i=e.run(n.signal);i.catch(()=>void 0);let o=await Promise.race([i,l]),s=a()-u;if(o===t||s>r){n.abort(),c.push({id:e.id,outcome:`too-slow`,ms:Math.max(s,r)});continue}return c.push({id:e.id,outcome:`fits`,ms:s}),{chosen:e.id,reason:`fits`,attempts:c}}catch(t){c.push({id:e.id,outcome:`failed`,ms:a()-u,error:t})}finally{s(i)}}return{chosen:void 0,reason:`none-fit`,attempts:c}}export{probeBackends};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{systemProbe as e}from"./machine-probe.js";const t=[`/usr/lib`,`/usr/lib64`,`/lib`,`/lib64`,`/usr/lib/x86_64-linux-gnu`,`/lib/x86_64-linux-gnu`,`/usr/lib/aarch64-linux-gnu`,`/lib/aarch64-linux-gnu`];function pathList(e){return(e??``).split(`:`).filter(e=>e!==``)}function hasVulkan(n=e()){if(n.platform!==`linux`)return!1;let{env:r}=n;return[...pathList(r.LD_LIBRARY_PATH),...t].some(e=>n.exists(`${e}/libvulkan.so.1`))?pathList(r.VK_DRIVER_FILES).length>0||pathList(r.VK_ICD_FILENAMES).length>0?!0:[`/etc/vulkan/icd.d`,...[r.XDG_DATA_HOME??(r.HOME?`${r.HOME}/.local/share`:``),...pathList(r.XDG_DATA_DIRS),`/usr/local/share`,`/usr/share`].filter(e=>e!==``).map(e=>`${e}/vulkan/icd.d`),`/usr/lib/x86_64-linux-gnu/GL/vulkan/icd.d`,`/usr/lib/aarch64-linux-gnu/GL/vulkan/icd.d`].some(e=>n.list(e).some(e=>e.endsWith(`.json`))):!1}const n=new Set([`off`,`0`,`false`,`no`,`cpu`]),r=new Set([`on`,`1`,`true`,`yes`,`gpu`]);function parseGpuSetting(e){let t=(e??``).trim().toLowerCase();return n.has(t)?`off`:r.has(t)?`on`:`auto`}export{hasVulkan,parseGpuSetting};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { MachineProbe } from './machine-probe.js';
|
|
2
|
+
export interface FakeMachine {
|
|
3
|
+
platform?: string;
|
|
4
|
+
arch?: string;
|
|
5
|
+
env?: Record<string, string | undefined>;
|
|
6
|
+
/** Path → file content; a directory exists when some path lies below it. */
|
|
7
|
+
files?: Record<string, string>;
|
|
8
|
+
/** `"file arg arg"` → stdout. A command not listed is not installed. */
|
|
9
|
+
commands?: Record<string, string>;
|
|
10
|
+
totalmem?: number;
|
|
11
|
+
freemem?: number;
|
|
12
|
+
}
|
|
13
|
+
/** A made-up machine for tests: no test in this package reads the real hardware. */
|
|
14
|
+
export declare function fakeMachine(machine?: FakeMachine): MachineProbe & {
|
|
15
|
+
readonly ran: string[];
|
|
16
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A GPU that hangs takes the whole process with it, and this is how the next start knows.
|
|
3
|
+
*
|
|
4
|
+
* WHY A FILE. A driver that loses the device (`vk::DeviceLostError`, a Metal command-buffer error)
|
|
5
|
+
* makes llama.cpp, whisper.cpp and their kin call `abort()`: no `try/catch`, no `exit` handler,
|
|
6
|
+
* nothing runs afterwards. The only thing a dead process can leave behind is what it wrote BEFORE.
|
|
7
|
+
* So a process writes a marker for as long as it is using the GPU and removes it again; a marker
|
|
8
|
+
* whose process is gone is the proof that a GPU session did not end cleanly, and the CPU is pinned
|
|
9
|
+
* from then on.
|
|
10
|
+
*
|
|
11
|
+
* THE MARKER COVERS A CALL, NOT THE SESSION. An app holds a loaded model for hours; a marker for
|
|
12
|
+
* that whole time would also turn a `kill -9` of an idle window into a verdict on the GPU.
|
|
13
|
+
* Loading, running and unloading are the moments a device is lost, so those are the ones wrapped —
|
|
14
|
+
* one marker per process, counted, so overlapping calls share it.
|
|
15
|
+
*
|
|
16
|
+
* PER USER, NOT PER RUN: the GPU and its driver belong to the machine. The app lifts the pin with
|
|
17
|
+
* {@link GpuGuard.clear}, typically when the user sets its GPU switch to `on` (see
|
|
18
|
+
* `parseGpuSetting`).
|
|
19
|
+
*
|
|
20
|
+
* Extracted from Stamped's semantic search (ADR 0127 decision 11).
|
|
21
|
+
*/
|
|
22
|
+
export interface GpuPin {
|
|
23
|
+
/** When the dead session was noticed, ISO 8601. */
|
|
24
|
+
at: string;
|
|
25
|
+
/** The process that died with the GPU in use. */
|
|
26
|
+
pid: number;
|
|
27
|
+
}
|
|
28
|
+
export interface GpuGuardOptions {
|
|
29
|
+
/** Where the pin and the markers live; {@link defaultGuardDir} for the usual place. */
|
|
30
|
+
dir: string;
|
|
31
|
+
pid?: number;
|
|
32
|
+
/** Is a process still there? Injected so a test does not need a second process. */
|
|
33
|
+
alive?: (pid: number) => boolean;
|
|
34
|
+
now?: () => Date;
|
|
35
|
+
}
|
|
36
|
+
export interface GpuGuard {
|
|
37
|
+
/** The pin when an earlier GPU session died without closing, after sweeping dead markers. */
|
|
38
|
+
pin(): GpuPin | undefined;
|
|
39
|
+
/** Run `work` with this process marked as using the GPU. */
|
|
40
|
+
during<T>(work: () => Promise<T>): Promise<T>;
|
|
41
|
+
/** Lift the pin; the GPU is tried again. */
|
|
42
|
+
clear(): void;
|
|
43
|
+
/** Drop this process's marker; for the `exit` hook, where a counted call can no longer finish. */
|
|
44
|
+
release(): void;
|
|
45
|
+
}
|
|
46
|
+
/** `EPERM` means the process exists and belongs to somebody else. */
|
|
47
|
+
export declare function processAlive(pid: number): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* `$XDG_STATE_HOME/<app>`, else `~/.local/state/<app>`; `%LOCALAPPDATA%\<app>` on Windows.
|
|
50
|
+
*
|
|
51
|
+
* State, not cache: deleting it forgets that the driver hung, which is the one thing it exists
|
|
52
|
+
* to remember.
|
|
53
|
+
*/
|
|
54
|
+
export declare function defaultGuardDir(app: string, env?: Readonly<Record<string, string | undefined>>, platform?: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* The guard NEVER makes the work fail: a state directory that cannot be written (a read-only
|
|
57
|
+
* home, a full disk) means no protection, not no feature.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createGpuGuard(options: GpuGuardOptions): GpuGuard;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { systemProbe, type MachineProbe } from './machine-probe.js';
|
|
2
|
+
export { hasVulkan, parseGpuSetting, type GpuSetting } from './vulkan.js';
|
|
3
|
+
export { createGpuGuard, defaultGuardDir, processAlive, type GpuGuard, type GpuGuardOptions, type GpuPin, } from './gpu-guard.js';
|
|
4
|
+
export { onBattery, onBatteryMac } from './power.js';
|
|
5
|
+
export { probeBackends, type BackendCandidate, type ProbeAttempt, type ProbeAttemptOutcome, type ProbeOptions, type ProbeReason, type ProbeResult, } from './probe-backends.js';
|
|
6
|
+
export { APPLE_DEFAULT_GPU_SHARE, checkFit, estimateFootprint, parseNvidiaSmi, readMachineMemory, type FitPlacement, type FitResult, type FootprintInput, type GpuMemory, type GpuMemorySource, type GpuVendor, type MachineMemory, } from './memory.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything this package asks the machine, behind one interface, so that every answer can be
|
|
3
|
+
* tested against a made-up machine: a fake `/sys`, a fake `nvidia-smi`, a fake RAM size. No test
|
|
4
|
+
* in this package reads the real hardware.
|
|
5
|
+
*
|
|
6
|
+
* Pure `node:*` calls, no GObject: the same code runs under Node (a plain dependency, nothing of
|
|
7
|
+
* gjsify needed) and under GJS, where the build substitutes the `@gjsify/*` implementations.
|
|
8
|
+
*/
|
|
9
|
+
export interface MachineProbe {
|
|
10
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
11
|
+
/** As `process.platform` spells it. */
|
|
12
|
+
readonly platform: string;
|
|
13
|
+
/** As `process.arch` spells it. */
|
|
14
|
+
readonly arch: string;
|
|
15
|
+
exists(path: string): boolean;
|
|
16
|
+
/** The names in `dir`, or `[]` when it cannot be read. */
|
|
17
|
+
list(dir: string): string[];
|
|
18
|
+
/** The file's text, trimmed, or `undefined` when it cannot be read. */
|
|
19
|
+
read(path: string): string | undefined;
|
|
20
|
+
/** The program's stdout, or `undefined` when it is missing, fails or outlasts `timeoutMs`. */
|
|
21
|
+
run(file: string, args: readonly string[], timeoutMs: number): string | undefined;
|
|
22
|
+
totalmem(): number;
|
|
23
|
+
/** What a new allocation can get without swapping: `MemAvailable` on Linux, not `MemFree`. */
|
|
24
|
+
freemem(): number;
|
|
25
|
+
}
|
|
26
|
+
export declare function systemProbe(): MachineProbe;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much memory this machine has for a model, and where: RAM, and per GPU its dedicated or
|
|
3
|
+
* unified memory (ADR 0136).
|
|
4
|
+
*
|
|
5
|
+
* RAM comes from `node:os`, which already answers it everywhere. The GPU side is what `os` lacks,
|
|
6
|
+
* read without native code: sysfs for AMD on Linux, `nvidia-smi` for NVIDIA on Linux and Windows,
|
|
7
|
+
* `sysctl` on Apple silicon. What none of them tells — an Intel GPU's share of RAM, any GPU on
|
|
8
|
+
* Windows without `nvidia-smi` — stays `undefined`, and {@link checkFit} treats undefined as
|
|
9
|
+
* "cannot rule out", never as zero.
|
|
10
|
+
*/
|
|
11
|
+
import { type MachineProbe } from './machine-probe.js';
|
|
12
|
+
export type GpuVendor = 'nvidia' | 'amd' | 'intel' | 'apple' | 'unknown';
|
|
13
|
+
export type GpuMemorySource = 'sysfs' | 'nvidia-smi' | 'sysctl' | 'none';
|
|
14
|
+
export interface GpuMemory {
|
|
15
|
+
readonly vendor: GpuVendor;
|
|
16
|
+
readonly name?: string;
|
|
17
|
+
/** Memory the GPU can use, in bytes; for unified memory the share of RAM it may wire. */
|
|
18
|
+
readonly totalBytes?: number;
|
|
19
|
+
/**
|
|
20
|
+
* In use right now, by ANY process — a model another server holds (Ollama, LM Studio) counts.
|
|
21
|
+
* Unknown for unified memory, where RAM availability says it.
|
|
22
|
+
*/
|
|
23
|
+
readonly usedBytes?: number;
|
|
24
|
+
/** The GPU shares system RAM (Apple silicon, an integrated GPU). */
|
|
25
|
+
readonly unified: boolean;
|
|
26
|
+
readonly source: GpuMemorySource;
|
|
27
|
+
}
|
|
28
|
+
export interface MachineMemory {
|
|
29
|
+
readonly ramTotalBytes: number;
|
|
30
|
+
readonly ramAvailableBytes: number;
|
|
31
|
+
readonly gpus: readonly GpuMemory[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The GPUs `nvidia-smi` reports, or `undefined` when it is not there or says nothing usable.
|
|
35
|
+
*
|
|
36
|
+
* `--format=csv,noheader,nounits` prints one line per GPU, memory in MiB:
|
|
37
|
+
* `NVIDIA GeForce RTX 4090, 24564, 1023`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseNvidiaSmi(output: string | undefined): GpuMemory[] | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Apple silicon: the GPU may wire only part of RAM. `iogpu.wired_limit_mb` is that limit when
|
|
42
|
+
* the user raised it (0 means "the default"); the default share is an approximation of Metal's
|
|
43
|
+
* `recommendedMaxWorkingSetSize`, which is not readable without Metal.
|
|
44
|
+
*/
|
|
45
|
+
export declare const APPLE_DEFAULT_GPU_SHARE = 0.75;
|
|
46
|
+
export declare function readMachineMemory(probe?: MachineProbe): MachineMemory;
|
|
47
|
+
export interface FootprintInput {
|
|
48
|
+
/** Weight count; with {@link bitsPerWeight}. Ignored when {@link weightsBytes} is given. */
|
|
49
|
+
readonly parameters?: number;
|
|
50
|
+
/** Average bits per weight of the quantization: ~4.8 for Q4_K_M, 8.5 for Q8_0, 16 for F16. */
|
|
51
|
+
readonly bitsPerWeight?: number;
|
|
52
|
+
/** The model file's size, when known: the most exact weight figure there is. */
|
|
53
|
+
readonly weightsBytes?: number;
|
|
54
|
+
readonly contextTokens?: number;
|
|
55
|
+
/** KV cache per token of context: `2 × layers × kvHeads × headDim × bytesPerElement`. */
|
|
56
|
+
readonly kvBytesPerToken?: number;
|
|
57
|
+
/** Compute buffers and runtime; the caller knows its engine, this package does not. */
|
|
58
|
+
readonly overheadBytes?: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Weights + KV cache + overhead, in bytes. The numbers that make it right for one engine and one
|
|
62
|
+
* model family come from the caller (ADR 0127 decision 12: the tables stay with the consumer).
|
|
63
|
+
*/
|
|
64
|
+
export declare function estimateFootprint(input: FootprintInput): number;
|
|
65
|
+
/**
|
|
66
|
+
* Where a model of `requiredBytes` can live, judged by memory alone (ADR 0136):
|
|
67
|
+
*
|
|
68
|
+
* • `gpu` — one GPU has that much free.
|
|
69
|
+
* • `gpu+cpu` — only with layers split between a dedicated GPU's free memory and RAM.
|
|
70
|
+
* • `cpu` — only RAM has room.
|
|
71
|
+
* • `unknown` — nothing known is big enough, but a GPU's memory could not be read.
|
|
72
|
+
* • `none` — every pool is known and too small: ruled out, do not offer the download.
|
|
73
|
+
*
|
|
74
|
+
* Only `none` is a verdict. The others say "not ruled out"; whether it is fast enough is for
|
|
75
|
+
* {@link probeBackends} to find out, when the work starts.
|
|
76
|
+
*/
|
|
77
|
+
export type FitPlacement = 'gpu' | 'gpu+cpu' | 'cpu' | 'unknown' | 'none';
|
|
78
|
+
export interface FitResult {
|
|
79
|
+
readonly placement: FitPlacement;
|
|
80
|
+
readonly requiredBytes: number;
|
|
81
|
+
/** Index into {@link MachineMemory.gpus} for `gpu` and `gpu+cpu`. */
|
|
82
|
+
readonly gpu?: number;
|
|
83
|
+
}
|
|
84
|
+
export declare function checkFit(requiredBytes: number, memory: MachineMemory): FitResult;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the machine running on its battery? A probe must not run then (ADR 0127 decision 12), and
|
|
3
|
+
* long background work may want to pause.
|
|
4
|
+
*
|
|
5
|
+
* Linux from sysfs, because that is where it costs nothing: no D-Bus, no dependency. macOS has no
|
|
6
|
+
* sysfs and answers through `pmset`. Anywhere else the answer is "no" — an unknown is not a reason
|
|
7
|
+
* to stop working.
|
|
8
|
+
*/
|
|
9
|
+
import { type MachineProbe } from './machine-probe.js';
|
|
10
|
+
/**
|
|
11
|
+
* True when the MACHINE's battery is discharging and no mains supply is online.
|
|
12
|
+
*
|
|
13
|
+
* A supply with `scope` `Device` is a peripheral (a wireless mouse or keyboard, a headset), not
|
|
14
|
+
* the machine: a desktop with a mouse running down would otherwise count as on battery forever.
|
|
15
|
+
*/
|
|
16
|
+
export declare function onBattery(probe?: MachineProbe, root?: string): boolean;
|
|
17
|
+
/** `pmset -g batt` prints which source the machine draws from: "Battery Power", "AC Power" or "UPS Power". */
|
|
18
|
+
export declare function onBatteryMac(pmsetOutput: string | undefined): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Try the backends in order against a wall-clock budget and take the first that fits
|
|
3
|
+
* (ADR 0127 decisions 11 and 12).
|
|
4
|
+
*
|
|
5
|
+
* THIS PACKAGE KNOWS HOW TO PROBE, NEVER WHAT. The candidates, their order and the budget come
|
|
6
|
+
* from the caller: a speech package owns its engine list, an app owns how long it is willing to
|
|
7
|
+
* wait. The probe is the only evidence that counts for speed — a static memory check
|
|
8
|
+
* ({@link checkFit}) can rule a backend out, never in.
|
|
9
|
+
*
|
|
10
|
+
* WHEN IT RUNS is the caller's call too, and it should be when the work starts, not at install:
|
|
11
|
+
* a GPU that was idle at install time can be busy later. Cache a result only while it is fresh.
|
|
12
|
+
*/
|
|
13
|
+
export interface BackendCandidate {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
/**
|
|
16
|
+
* Do one short, representative piece of the real work (load and run a tiny input). Throwing
|
|
17
|
+
* means "does not work here". Stop when `signal` aborts: the probe has moved on by then.
|
|
18
|
+
*/
|
|
19
|
+
run(signal: AbortSignal): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
export type ProbeAttemptOutcome = 'fits' | 'too-slow' | 'failed';
|
|
22
|
+
export interface ProbeAttempt {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly outcome: ProbeAttemptOutcome;
|
|
25
|
+
/** Wall-clock time of the attempt; the budget for one cut short. */
|
|
26
|
+
readonly ms: number;
|
|
27
|
+
readonly error?: unknown;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Why {@link ProbeResult.chosen} is what it is — the recommendation ADR 0127 decision 11 asks for:
|
|
31
|
+
*
|
|
32
|
+
* • `fits` — measured within budget; use it.
|
|
33
|
+
* • `setting` — the user or app chose it explicitly; nothing was measured.
|
|
34
|
+
* • `none-fit` — every candidate failed or was too slow; the feature should stay off by default.
|
|
35
|
+
* • `on-battery` — not measured, because a probe must not drain a battery; keep what you had.
|
|
36
|
+
*/
|
|
37
|
+
export type ProbeReason = 'fits' | 'setting' | 'none-fit' | 'on-battery';
|
|
38
|
+
export interface ProbeResult {
|
|
39
|
+
readonly chosen: string | undefined;
|
|
40
|
+
readonly reason: ProbeReason;
|
|
41
|
+
readonly attempts: readonly ProbeAttempt[];
|
|
42
|
+
}
|
|
43
|
+
export interface ProbeOptions {
|
|
44
|
+
/** An explicit choice (a setting, an environment variable). It always wins over a probe. */
|
|
45
|
+
readonly setting?: string;
|
|
46
|
+
/** Defaults to this machine's {@link onBattery}. */
|
|
47
|
+
readonly onBattery?: () => boolean;
|
|
48
|
+
/** Milliseconds; injected so a test controls time. */
|
|
49
|
+
readonly now?: () => number;
|
|
50
|
+
readonly setTimer?: (callback: () => void, ms: number) => unknown;
|
|
51
|
+
readonly clearTimer?: (handle: unknown) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run `candidates` one after another, each against `budgetMs`, and return the first that finishes
|
|
55
|
+
* within it.
|
|
56
|
+
*
|
|
57
|
+
* A candidate still running at the budget is aborted and counted `too-slow`; the probe does not
|
|
58
|
+
* wait for it. One that ignores its signal keeps running in the background, so a GPU candidate
|
|
59
|
+
* should honour it — and be wrapped in a {@link GpuGuard} by the caller.
|
|
60
|
+
*/
|
|
61
|
+
export declare function probeBackends(candidates: readonly BackendCandidate[], budgetMs: number, options?: ProbeOptions): Promise<ProbeResult>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type MachineProbe } from './machine-probe.js';
|
|
2
|
+
/**
|
|
3
|
+
* Is there a Vulkan loader AND a driver for it? Linux only; `false` everywhere else.
|
|
4
|
+
*
|
|
5
|
+
* The loader alone is on every desktop (Mesa and the browsers pull it in); the ICD manifest is
|
|
6
|
+
* what says a driver is installed. Both are only a HINT: whether the device works is decided by
|
|
7
|
+
* loading something on it, which is what {@link probeBackends} is for.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hasVulkan(probe?: MachineProbe): boolean;
|
|
10
|
+
export type GpuSetting = 'off' | 'on' | 'auto';
|
|
11
|
+
/**
|
|
12
|
+
* Read a user's GPU switch, from whichever variable the app names (`MYAPP_GPU=off`).
|
|
13
|
+
*
|
|
14
|
+
* `off` is the escape hatch from a misbehaving driver; `on` is its counterpart and also the
|
|
15
|
+
* "I fixed the driver" signal that lifts a {@link GpuGuard} pin. Anything else is `auto`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseGpuSetting(value: string | undefined): GpuSetting;
|
package/package.json
CHANGED
|
@@ -1,6 +1,83 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@gjsify/machine-fit",
|
|
3
|
+
"version": "0.64.19",
|
|
4
|
+
"description": "Will a model run on this machine, and on which backend? A static memory check (RAM, VRAM, unified memory) that can rule a model out before download, plus the probe that decides speed: backends tried against a wall-clock budget, a GPU-hang guard, Vulkan and battery hints. Pure TypeScript on node:* APIs.",
|
|
5
|
+
"module": "lib/esm/index.js",
|
|
6
|
+
"types": "lib/types/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./lib/types/index.d.ts",
|
|
12
|
+
"default": "./lib/esm/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"!lib/types/**/*.spec.d.ts"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"clear": "gjsify clear lib tmp tsconfig.tsbuildinfo test.gjs.mjs test.node.mjs",
|
|
21
|
+
"check": "gjsify tsc --noEmit",
|
|
22
|
+
"build": "gjsify run build:gjsify && gjsify run build:types",
|
|
23
|
+
"build:gjsify": "gjsify build --library 'src/**/*.{ts,js}' --exclude 'src/**/*.spec.{mts,ts}' 'src/test.{mts,ts}'",
|
|
24
|
+
"build:types": "gjsify tsc",
|
|
25
|
+
"build:test": "gjsify run build:test:gjs && gjsify run build:test:node",
|
|
26
|
+
"build:test:gjs": "gjsify build src/test.mts --app gjs --outfile test.gjs.mjs",
|
|
27
|
+
"build:test:node": "gjsify build src/test.mts --app node --outfile test.node.mjs",
|
|
28
|
+
"test": "gjsify run build:gjsify && gjsify run build:test && gjsify run test:node && gjsify run test:gjs",
|
|
29
|
+
"test:gjs": "gjsify run test.gjs.mjs",
|
|
30
|
+
"test:node": "node test.node.mjs"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"gpu",
|
|
34
|
+
"vram",
|
|
35
|
+
"hardware",
|
|
36
|
+
"llm",
|
|
37
|
+
"model-fit",
|
|
38
|
+
"vulkan",
|
|
39
|
+
"battery"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@gjsify/child_process": "^0.64.19",
|
|
43
|
+
"@gjsify/fs": "^0.64.19",
|
|
44
|
+
"@gjsify/os": "^0.64.19",
|
|
45
|
+
"@gjsify/path": "^0.64.19",
|
|
46
|
+
"@gjsify/process": "^0.64.19"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@gjsify/cli": "^0.64.19",
|
|
50
|
+
"@gjsify/unit": "^0.64.19",
|
|
51
|
+
"@types/node": "^25.9.2",
|
|
52
|
+
"typescript": "^6.0.3"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/gjsify/gjsify.git",
|
|
58
|
+
"directory": "packages/gjs/machine-fit"
|
|
59
|
+
},
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/gjsify/gjsify/issues"
|
|
62
|
+
},
|
|
63
|
+
"homepage": "https://github.com/gjsify/gjsify/tree/main/packages/gjs/machine-fit#readme",
|
|
64
|
+
"gjsify": {
|
|
65
|
+
"tier": 3,
|
|
66
|
+
"runtimes": {
|
|
67
|
+
"gjs": "polyfill",
|
|
68
|
+
"node": "none",
|
|
69
|
+
"browser": "none",
|
|
70
|
+
"nativescript": "none"
|
|
71
|
+
},
|
|
72
|
+
"os": {
|
|
73
|
+
"linux": "supported",
|
|
74
|
+
"darwin": "partial",
|
|
75
|
+
"win32": "partial"
|
|
76
|
+
},
|
|
77
|
+
"osNotes": {
|
|
78
|
+
"darwin": "Apple silicon's GPU share of unified memory is approximated (75 % of RAM unless iogpu.wired_limit_mb is set); Metal's recommendedMaxWorkingSetSize needs native code. Intel Macs report their GPU as unknown. hasVulkan() is always false.",
|
|
79
|
+
"win32": "Only NVIDIA VRAM is read (nvidia-smi ships with the driver); other GPUs report unknown memory, because DXGI needs native code and WMI's AdapterRAM stops at 4 GiB. onBattery() is always false and hasVulkan() is always false."
|
|
80
|
+
},
|
|
81
|
+
"headless": true
|
|
82
|
+
}
|
|
83
|
+
}
|