diff --git a/.mise/config.js.toml b/.mise/config.js.toml index acb815c..c5b0f6a 100644 --- a/.mise/config.js.toml +++ b/.mise/config.js.toml @@ -13,6 +13,10 @@ # same npm-package-served-as-a-module pattern as onnxruntime-web (resolved in libs/edge-toolkit/src/config.rs). "npm:@mediapipe/tasks-vision" = "latest" "npm:onnxruntime-web" = "latest" +# stats-gl is the GPU utilisation meter overlay on the ws-server index page. +# index.html loads it at runtime via /modules/stats-gl (same npm-package-served-as-a-module pattern as +# onnxruntime-web, resolved in libs/edge-toolkit/src/config.rs). +"npm:stats-gl" = "latest" # oxlint-tsgolint is oxlint's type-aware engine. # It's a typescript-go type checker oxlint shells out to under `--type-aware`. npm-only: it ships no GitHub # release assets, distributing per-platform binaries via `@oxlint-tsgolint/` optional deps, so every OS diff --git a/CLAUDE.md b/CLAUDE.md index 7b925a3..e45b76a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,12 @@ All tasks run through `mise run `. The aggregates below act on Rust + the ## Formatters & checks by file type +**Verify the change actually works before running the lint/format battery.** Functional verification comes first: +run the affected tests, exercise the endpoint, or load the page in the browser and confirm the behaviour is right. +Only then move on to the formatter/check passes below. Linting a broken change wastes the whole battery -- the +functional fix that follows (a renamed symbol, a rewritten block, a CSS edit discovered only in the browser) dirties +the files again and every check has to be re-run. Green checks on code that doesn't work are worthless. + **Agents must not run `mise run check` (or `check-all`).** They are aggregate gates intended for CI and humans -- running the whole battery for every edit is slow, expensive, and almost always wasteful when only a couple of file types changed. Pick the targeted tasks from the tables below that match the extensions of the files you actually diff --git a/config/ast-grep/rules/doc-summary-ends-with-period.yaml b/config/ast-grep/rules/doc-summary-ends-with-period.yaml index 768f28b..bb474de 100644 --- a/config/ast-grep/rules/doc-summary-ends-with-period.yaml +++ b/config/ast-grep/rules/doc-summary-ends-with-period.yaml @@ -28,6 +28,7 @@ files: - libs/edge-toolkit/tests/no_mise.rs - libs/test-helpers/src/lib.rs - libs/ws-runner-common/tests/config.rs + - services/modules/tests/api_modules.rs - services/websockify/src/lib.rs - services/websockify/tests/relay.rs - services/ws-modules/except1/src/lib.rs diff --git a/libs/edge-toolkit/src/config.rs b/libs/edge-toolkit/src/config.rs index 0a2e72f..69603b9 100644 --- a/libs/edge-toolkit/src/config.rs +++ b/libs/edge-toolkit/src/config.rs @@ -129,6 +129,24 @@ pub fn default_modules_folders() -> Vec { ); } } + // stats-gl (the GPU utilisation meter overlay on the ws-server index page). Same + // npm-package-served-as-a-module pattern as onnxruntime-web above. + match mise_npm_modules_path("stats-gl") { + Some(path) => { + log::info!("Resolved npm:stats-gl modules path: {}", path.display()); + paths.push(path); + } + None => { + log::warn!( + "{}", + concat!( + "npm:stats-gl install path not found via `mise where` -- ", + "requests to /modules/stats-gl/* will 404. ", + "Run `mise install npm:stats-gl` and verify the package layout.", + ) + ); + } + } // Pyodide is installed from its GitHub release tarball (see // `.mise/config.python.toml`), // not via `npm:pyodide`. mise's http backend extracts the archive flat, diff --git a/services/modules/tests/api_modules.rs b/services/modules/tests/api_modules.rs index 9324c3e..98fa81a 100644 --- a/services/modules/tests/api_modules.rs +++ b/services/modules/tests/api_modules.rs @@ -41,10 +41,11 @@ async fn list_modules_api() { } } - // onnxruntime-web is staged by the `js` env (npm:onnxruntime-web in - // config.js.toml); when MISE_ENV doesn't load js, the package isn't on - // disk and the modules listing doesn't include it. + // onnxruntime-web and stats-gl are staged by the `js` env (npm:onnxruntime-web + // and npm:stats-gl in config.js.toml); when MISE_ENV doesn't load js, the + // packages aren't on disk and the modules listing doesn't include them. if mise_env_includes(Language::Js) { assert!(resp.contains(&"onnxruntime-web".to_string())); + assert!(resp.contains(&"stats-gl".to_string())); } } diff --git a/services/ws-server/Dockerfile b/services/ws-server/Dockerfile index 3eb4cdc..0eb9d5a 100644 --- a/services/ws-server/Dockerfile +++ b/services/ws-server/Dockerfile @@ -23,7 +23,7 @@ COPY . . # step picks up a real directory. RUN pnpm add --prod --dir /workspace/runtime-deps --config.node-linker=hoisted \ --ignore-scripts --config.ignoredBuiltDependencies=protobufjs \ - onnxruntime-web pyodide + onnxruntime-web pyodide stats-gl RUN cargo build -p et-ws-server --release --locked FROM debian:bookworm-slim AS runtime @@ -43,6 +43,7 @@ COPY --from=builder /workspace/services/ws-modules ./services/ws-modules COPY --from=builder /workspace/data/model-modules ./data/model-modules COPY --from=builder /workspace/runtime-deps/node_modules/onnxruntime-web ./node_modules/onnxruntime-web COPY --from=builder /workspace/runtime-deps/node_modules/pyodide ./node_modules/pyodide +COPY --from=builder /workspace/runtime-deps/node_modules/stats-gl ./node_modules/stats-gl RUN mkdir -p /app/storage \ && chown -R app:app /app diff --git a/services/ws-server/static/index.html b/services/ws-server/static/index.html index 430a7d4..a506b5f 100644 --- a/services/ws-server/static/index.html +++ b/services/ws-server/static/index.html @@ -41,5 +41,6 @@

WASM web agent

Booting...
+ diff --git a/services/ws-server/static/meters.js b/services/ws-server/static/meters.js new file mode 100644 index 0000000..e7b77c9 --- /dev/null +++ b/services/ws-server/static/meters.js @@ -0,0 +1,285 @@ +// GPU utilisation meter overlay, backed by the stats-gl npm package (served at /modules/stats-gl). +// +// stats-gl's GPU panel needs WebGPU "timestamp-query" timestamps written around real GPU passes. +// This page owns no render loop, so a tiny fixed compute probe is dispatched every animation frame +// with stats-gl's timestampWrites: the panel then reports how long that constant workload takes on +// the device, which rises as other GPU work (module matmuls, onnxruntime-web inference) contends +// with it. Only the GPU panel is shown (no FPS/CPU), with a value line sized to span most of the +// panel width and without the flickering "(min-max)" range suffix. Browsers without WebGPU or +// without the timestamp-query feature get no overlay at all. The meter starts in the top-left +// corner; it can be dragged anywhere, resized from its bottom-right corner handle, and closed with +// the corner x button, which also stops the probe and releases the WebGPU device. + +import Stats from "/modules/stats-gl/dist/main.js"; + +const PROBE_SHADER = ` + @group(0) @binding(0) var data: array; + + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) id: vec3) { + var acc = data[id.x]; + for (var i = 0u; i < 256u; i = i + 1u) { + acc = acc * 1.0000001 + 0.0000001; + } + data[id.x] = acc; + } +`; + +const PROBE_INVOCATIONS = 64; + +// stats-gl's native panel is 90x48 CSS px; the meter starts at 2x that and the corner handle +// resizes it (aspect locked) between MIN_SCALE and MAX_SCALE. +const PANEL_BASE_WIDTH = 90; +const PANEL_BASE_HEIGHT = 48; +const START_SCALE = 2; +const MIN_SCALE = 1; +const MAX_SCALE = 8; +// Font height per panel device-pixel-ratio unit (stats-gl's own is 9). At bold 16, a worst-case +// "999.99 GPU" value line spans ~85 of the 90-unit panel width, so the text mostly fills the meter. +const FONT_PX = 16; + +const requestTimestampDevice = async () => { + if (!navigator.gpu) { + return null; + } + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter || !adapter.features.has("timestamp-query")) { + return null; + } + return adapter.requestDevice({ requiredFeatures: ["timestamp-query"] }); +}; + +const createProbe = async (device) => { + const module = device.createShaderModule({ code: PROBE_SHADER }); + const pipeline = await device.createComputePipelineAsync({ + layout: "auto", + compute: { module, entryPoint: "main" }, + }); + const buffer = device.createBuffer({ + size: PROBE_INVOCATIONS * 4, + usage: GPUBufferUsage.STORAGE, + }); + const bindGroup = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer } }], + }); + return { bindGroup, device, pipeline }; +}; + +const encodeProbePass = (stats, probe) => { + const encoder = probe.device.createCommandEncoder(); + const pass = encoder.beginComputePass({ timestampWrites: stats.getTimestampWrites("render") }); + pass.setPipeline(probe.pipeline); + pass.setBindGroup(0, probe.bindGroup); + pass.dispatchWorkgroups(1); + pass.end(); + stats.end(encoder); + probe.device.queue.submit([encoder.finish()]); +}; + +// stats-gl hardcodes the panel's 90x48 layout, 15-unit text strip, and 9-unit font in the Panel +// constructor, so re-derive the metrics here for a given scale. Scaling the panel's device-pixel- +// ratio from a stored base (rather than stretching the canvas with CSS) keeps the meter crisp at +// every size, and the taller text strip plus FONT_PX make the value line span most of the panel +// width. The container is sized to match so the corner controls anchor to its edges and the drag +// clamp can read its offset size. update()/updateGraph() read all of these fields per frame, and +// never reset the font, so the overrides stick. +const applyMeterScale = (el, panel, scale) => { + panel.basePR ??= panel.PR; + const pr = panel.basePR * scale; + panel.PR = pr; + panel.WIDTH = PANEL_BASE_WIDTH * pr; + panel.HEIGHT = PANEL_BASE_HEIGHT * pr; + panel.TEXT_X = 3 * pr; + panel.TEXT_Y = 3 * pr; + panel.GRAPH_X = 3 * pr; + panel.GRAPH_Y = 22 * pr; + panel.GRAPH_WIDTH = 84 * pr; + panel.GRAPH_HEIGHT = 23 * pr; + panel.canvas.width = panel.WIDTH; + panel.canvas.height = panel.HEIGHT; + panel.canvas.style.width = `${PANEL_BASE_WIDTH * scale}px`; + panel.canvas.style.height = `${PANEL_BASE_HEIGHT * scale}px`; + el.style.width = `${PANEL_BASE_WIDTH * scale}px`; + el.style.height = `${PANEL_BASE_HEIGHT * scale}px`; + panel.initializeCanvas(); + panel.context.font = `bold ${FONT_PX * pr}px Helvetica,Arial,sans-serif`; +}; + +// panel.update draws a flickering " (min-max)" range as a separate fillText call after the value; +// dropping that call at the context level keeps one steady number without forking the panel +// drawing code. The wrapper survives resizes: reallocating the canvas resets context state but +// keeps the same context object, and with it this own-property override. +const suppressRangeSuffix = (panel) => { + const fillText = panel.context.fillText.bind(panel.context); + panel.context.fillText = (...args) => { + if (!String(args[0]).trimStart().startsWith("(")) { + fillText(...args); + } + }; +}; + +// Move/up handlers for dragging and resizing sit on window rather than using setPointerCapture: +// capture can silently drop in embedded browser panes, stranding the gesture after one step, while +// window listeners keep receiving events wherever the cursor goes. +const makeDraggable = (el) => { + let activePointerId = null; + let offsetX = 0; + let offsetY = 0; + el.style.cursor = "grab"; + el.style.touchAction = "none"; + + el.addEventListener("pointerdown", (event) => { + activePointerId = event.pointerId; + offsetX = event.clientX - el.offsetLeft; + offsetY = event.clientY - el.offsetTop; + el.style.cursor = "grabbing"; + event.preventDefault(); + }); + + window.addEventListener("pointermove", (event) => { + if (activePointerId === null || event.pointerId !== activePointerId) { + return; + } + const x = event.clientX - offsetX; + const y = event.clientY - offsetY; + el.style.left = `${Math.min(Math.max(x, 0), window.innerWidth - el.offsetWidth)}px`; + el.style.top = `${Math.min(Math.max(y, 0), window.innerHeight - el.offsetHeight)}px`; + }); + + const release = (event) => { + if (event.pointerId === activePointerId) { + activePointerId = null; + el.style.cursor = "grab"; + } + }; + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); +}; + +// Corner controls swallow pointerdown so the drag handler underneath doesn't fire. +const addCloseButton = (el, onClose) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "x"; + button.title = "Close GPU meter"; + Object.assign(button.style, { + background: "rgba(0,0,0,0.55)", + border: "0", + borderRadius: "3px", + color: "#ddd", + cursor: "pointer", + font: "bold 11px/16px sans-serif", + height: "16px", + padding: "0", + position: "absolute", + right: "2px", + top: "2px", + width: "16px", + }); + button.addEventListener("pointerdown", (event) => event.stopPropagation()); + button.addEventListener("click", onClose); + el.appendChild(button); +}; + +const addResizeHandle = (el, onScale) => { + const handle = document.createElement("div"); + handle.title = "Resize GPU meter"; + Object.assign(handle.style, { + background: "linear-gradient(135deg,transparent 50%,rgba(255,255,255,0.4) 50%)", + bottom: "0", + cursor: "nwse-resize", + height: "14px", + position: "absolute", + right: "0", + width: "14px", + }); + let activePointerId = null; + let startX = 0; + let startWidth = 0; + + handle.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + event.preventDefault(); + activePointerId = event.pointerId; + startX = event.clientX; + startWidth = el.offsetWidth; + }); + + window.addEventListener("pointermove", (event) => { + if (activePointerId === null || event.pointerId !== activePointerId) { + return; + } + const scale = (startWidth + event.clientX - startX) / PANEL_BASE_WIDTH; + onScale(Math.min(Math.max(scale, MIN_SCALE), MAX_SCALE)); + }); + + const release = (event) => { + if (event.pointerId === activePointerId) { + activePointerId = null; + } + }; + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + el.appendChild(handle); +}; + +const startMeters = async () => { + let device = null; + try { + device = await requestTimestampDevice(); + } catch (error) { + console.warn("meters: WebGPU device request failed:", error); + } + if (!device) { + console.warn("meters: WebGPU timestamp queries unavailable, GPU meter not shown"); + return; + } + + const stats = new Stats({ trackFPS: false, trackGPU: true }); + let probe = null; + try { + await stats.init(device); + probe = await createProbe(device); + } catch (error) { + console.warn("meters: WebGPU probe setup failed, GPU meter not shown:", error); + return; + } + + const overlay = stats.dom; + applyMeterScale(overlay, stats.gpuPanel, START_SCALE); + suppressRangeSuffix(stats.gpuPanel); + document.body.appendChild(overlay); + makeDraggable(overlay); + addResizeHandle(overlay, (scale) => applyMeterScale(overlay, stats.gpuPanel, scale)); + + let frameHandle = 0; + addCloseButton(overlay, () => { + cancelAnimationFrame(frameHandle); + probe = null; + overlay.remove(); + device.destroy(); + }); + + void device.lost.then((info) => { + if (info.reason === "destroyed") { + return; + } + probe = null; + console.warn(`meters: WebGPU device lost (${info.reason}), GPU meter frozen`); + }); + + const frame = () => { + stats.begin(); + if (probe) { + encodeProbePass(stats, probe); + } else { + stats.end(); + } + stats.update(); + frameHandle = requestAnimationFrame(frame); + }; + frameHandle = requestAnimationFrame(frame); +}; + +await startMeters(); diff --git a/services/ws-server/static/package.json b/services/ws-server/static/package.json index 321be16..95ed1fb 100644 --- a/services/ws-server/static/package.json +++ b/services/ws-server/static/package.json @@ -7,10 +7,12 @@ "app.js", "favicon.png", "index.html", + "meters.js", "style.css" ], "dependencies": { "et-ws-wasm-agent": "link:../../ws-wasm-agent/pkg", - "onnxruntime-web": "*" + "onnxruntime-web": "*", + "stats-gl": "*" } } diff --git a/services/ws-server/static/style.css b/services/ws-server/static/style.css index 14d71ca..24c0576 100644 --- a/services/ws-server/static/style.css +++ b/services/ws-server/static/style.css @@ -94,8 +94,9 @@ textarea { min-height: 180px; } -video, -canvas { +/* Scoped to main so the stats-gl overlay's own canvases keep their inline sizing. */ +main video, +main canvas { box-sizing: border-box; display: block; width: auto;