diff --git a/README.md b/README.md index 7b2a4a4..fa92573 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ ffrwd [] [-h] [-f FILE] [-v NAME=VALUE] [-q] [query | recipe] | command | what it does | flags | |---|---|---| -| `run` | **the default**: a query with a media destination compiles and executes it; one without prints its result set as a table, psql-style, executing nothing | `--timeout SECS` (default: ten times the longest input's duration, at least 600, none when an input is live) · `-y` (overwrite) · `--jobs N` (cap the sidecar's threads) · `--profile` (each sidecar's bytes and time per stage, as a row) · `--show` / `--show-only` (below) · `--remote` (below) | +| `run` | **the default**: a query with a media destination compiles and executes it; one without prints its result set as a table, psql-style, executing nothing | `--timeout SECS` (default: ten times the longest input's duration, at least 600, none when an input is live) · `-y` (overwrite) · `--jobs N` (cap the sidecar's threads) · `--profile` (each sidecar's bytes and time per stage, as a row) · `--gpu-adapter SPEC` (the GPU the sidecar works on, by its index in `setup gpu`'s list or part of its name; `FFRWD_GPU_ADAPTER` when absent) · `--show` / `--show-only` (below) · `--remote` (below) | | `compile` | print the full ffmpeg command | `--graph-only` (just the filtergraph string) | | `explain` | dump the compiled graph as JSON | `--mermaid` (as a flowchart) · `--diagram` (rendered in the terminal; needs `ffrwd[diagram]`) | | `validate` | exit 0 if the query compiles, else a line-anchored error | `--json` (machine-readable error object on stdout) | @@ -217,6 +217,7 @@ ffrwd [] [-h] [-f FILE] [-v NAME=VALUE] [-q] [query | recipe] | `publish` | run the package's tests, pack it, and publish it to the registry | | | `setup` | download everything detection wants on this machine: ffmpeg and ffprobe where PATH has none, and the ONNX Runtime tiers | `--cuda` · `--full` as below; `FFRWD_OFFLINE=1` refuses every fetch | | `setup nn` | download the ONNX Runtime the model-running modules need, the tiers chosen for this machine | `--cuda` · `--full` to take a tier detection did not | +| `setup gpu` | list the GPU adapters the sidecar can work on, and the one a run uses; downloads nothing | | | `jobs` | list, watch, cancel or fetch your remote runs | `--watch` · `--cancel ID` · `--fetch ID` · `-y` · `--json` | | `prompt` | print the LLM system prompt | | | `mcp` | serve the compiler to an editor or agent over MCP (stdio) | `--allow-unsafe` (also expose the tools that do more than answer about a query) | diff --git a/cli/ffrwd/cli.py b/cli/ffrwd/cli.py index fb5eb13..f5b9881 100644 --- a/cli/ffrwd/cli.py +++ b/cli/ffrwd/cli.py @@ -130,6 +130,13 @@ such block. It exists for the printed ``loudnorm2`` command line, which pipes pass 1 into it. +``compile`` and ``run`` take ``--gpu-adapter SPEC``, the GPU adapter every +sidecar opens its device on: an index into the list ``ffrwd setup gpu`` +prints, or a case-insensitive part of the adapter's name. Absent, it is +`FFRWD_GPU_ADAPTER`'s, and with neither the sidecar opens the adapter it +always has and the command is unchanged. ``setup gpu`` downloads nothing: it +prints the adapters the sidecar lists, and which one a run opens. + ``compile``/``explain``/``validate``/``run`` take the query as SQL TEXT on the command line. ``-f/--file PATH`` reads it from a file instead (``-f -`` reads stdin, e.g. for the LLM repair loop's pipe). Exactly one of the two is @@ -196,6 +203,7 @@ binaries, credentials, diagram, + gpu, loudnorm, nn, redact, @@ -363,7 +371,8 @@ def main(argv: list[str] | None = None) -> int: # twice (the table-query fallback), and the reader wants each warning once. warnings = WarningLog() try: - return handler(args, warnings) + with gpu.chosen(getattr(args, "gpu_adapter", None)): + return handler(args, warnings) except FfrwdError as err: # A refusal raised outside a command's own handling -- FFRWD_OFFLINE # refusing the ffmpeg provider's download wherever ffmpeg is first @@ -466,6 +475,35 @@ def _add_profile_argument(subparser: argparse.ArgumentParser) -> None: subparser.add_argument("--profile", action="store_true", help=_PROFILE_HELP) +_GPU_ADAPTER_HELP = ( + f"the GPU adapter every sidecar opens its device on: its index in `{gpu.SETUP_COMMAND}`'s " + f"list, or part of its name (default: ${gpu.ADAPTER_VAR}, else the sidecar's own choice)" +) + + +def _add_gpu_adapter_argument(subparser: argparse.ArgumentParser) -> None: + subparser.add_argument( + "--gpu-adapter", + default=None, + metavar="SPEC", + dest="gpu_adapter", + help=_GPU_ADAPTER_HELP, + ) + + +def _check_gpu_adapter(args: argparse.Namespace) -> int: + """0 unless an adapter was named for a remote run, which runs on the + runner's own; 2 with the usage error printed.""" + if getattr(args, "gpu_adapter", None) is None or not args.remote: + return 0 + print( + f"error: {args.command}: --gpu-adapter names an adapter on this machine", + file=sys.stderr, + ) + print("hint: drop --gpu-adapter, or drop --remote", file=sys.stderr) + return 2 + + def _check_jobs(args: argparse.Namespace) -> int: """0 for a usable ``--jobs``, or 2 with the usage error printed.""" jobs: int | None = getattr(args, "jobs", None) @@ -559,6 +597,7 @@ def _build_parser() -> argparse.ArgumentParser: ) _add_jobs_argument(compile_p) _add_profile_argument(compile_p) + _add_gpu_adapter_argument(compile_p) explain_p = subparsers.add_parser("explain", help="dump the compiled IR graph as JSON") _add_query_arguments(explain_p) _add_quiet_argument(explain_p) @@ -605,6 +644,7 @@ def _build_parser() -> argparse.ArgumentParser: ) _add_jobs_argument(run_p) _add_profile_argument(run_p) + _add_gpu_adapter_argument(run_p) showing = run_p.add_mutually_exclusive_group() showing.add_argument( "--show", @@ -796,9 +836,10 @@ def _build_parser() -> argparse.ArgumentParser: setup_p.add_argument( "what", nargs="?", - choices=["nn"], + choices=["nn", "gpu"], help="nn: only the ONNX Runtime the modules that run models load; " - "omitted, ffmpeg and ffprobe too, where PATH does not have them", + "omitted, ffmpeg and ffprobe too, where PATH does not have them. gpu: " + "download nothing, and list the GPU adapters a run can name with --gpu-adapter", ) setup_p.add_argument( "--cuda", @@ -1663,6 +1704,9 @@ def _cmd_run(args: argparse.Namespace, on_warning: OnWarning) -> int: if code != 0: return code code = _check_target(args) + if code != 0: + return code + code = _check_gpu_adapter(args) if code != 0: return code try: @@ -3706,6 +3750,15 @@ def _cmd_setup(args: argparse.Namespace, on_warning: OnWarning) -> int: tier whatever detection said, either way. """ console = _console(args) + if args.what == "gpu": + if args.cuda or args.full: + print( + "error: --cuda and --full choose ONNX Runtime libraries; " + "`ffrwd setup gpu` downloads nothing", + file=sys.stderr, + ) + return 2 + return _setup_gpu() if args.full and not args.cuda: print( "error: --full adds the libraries the CUDA provider needs; pass --cuda too", @@ -3717,6 +3770,41 @@ def _cmd_setup(args: argparse.Namespace, on_warning: OnWarning) -> int: return _setup_all(args, console) +def _setup_gpu() -> int: + """``ffrwd setup gpu``: the adapters the sidecar lists, and the one a run + opens, which `FFRWD_GPU_ADAPTER` decides where it is set.""" + given = gpu.spec() + try: + found = gpu.info(given) + except FfrwdError as err: + _print_error(err) + return 1 + if not found.adapters: + print("the sidecar finds no GPU adapter on this machine") + return 0 + print(gpu.describe(found)) + if given is None: + default = next((one for one in found.adapters if one.default), None) + if default is not None: + print( + f"a run opens {default.index}, {default.name} ({default.backend}); " + f"--gpu-adapter or {gpu.ADAPTER_VAR} names another" + ) + return 0 + try: + gpu.check() + except FfrwdError as err: + _print_error(err) + return 1 + picked = found.adapters[found.chosen] if found.chosen is not None else None + if picked is not None: + print( + f"{gpu.ADAPTER_VAR}={given} names {picked.index}, " + f"{picked.name} ({picked.backend}): a run opens it" + ) + return 0 + + def _setup_command(args: argparse.Namespace) -> str: """The ``ffrwd setup`` spelling this invocation is, for a refusal to name.""" words = ["ffrwd", "setup"] diff --git a/cli/ffrwd/errors.py b/cli/ffrwd/errors.py index 9928ce3..a6d3c84 100644 --- a/cli/ffrwd/errors.py +++ b/cli/ffrwd/errors.py @@ -34,8 +34,9 @@ class ErrorCode(str, Enum): OUTPUT_EXISTS = "OUTPUT_EXISTS" # a destination file is already there, and no -y NOTHING_TO_SHOW = "NOTHING_TO_SHOW" # --show/--show-only, no video-bearing output PLAYER_NOT_FOUND = "PLAYER_NOT_FOUND" # --show asked for, ffplay not on PATH - # setup nn: no ONNX Runtime pinned for this platform; or FFRWD_OFFLINE - # refusing a download ffmpeg or the ONNX Runtime needs + # setup nn: no ONNX Runtime pinned for this platform; FFRWD_OFFLINE + # refusing a download ffmpeg or the ONNX Runtime needs; or a run naming a + # GPU adapter this machine does not have RUNTIME_NOT_FOUND = "RUNTIME_NOT_FOUND" UNBOUNDED_LIVE_INPUT = "UNBOUNDED_LIVE_INPUT" # one-open input, uncountable paths LIVE_LEAD = "LIVE_LEAD" # a live node fed later than the lead it needs diff --git a/cli/ffrwd/gpu.py b/cli/ffrwd/gpu.py new file mode 100644 index 0000000..f9583a3 --- /dev/null +++ b/cli/ffrwd/gpu.py @@ -0,0 +1,286 @@ +"""Which GPU adapter a sidecar opens its device on. + +The sidecar opens one device per process, and the modules granted ``-gpu``, +its decoder, its encoders and its filters all work on it. ``--gpu-adapter`` +on ``run`` and ``compile``, or `FFRWD_GPU_ADAPTER` where the flag is absent, +names the adapter: a zero-based index into the list ``ffrwd-wasm +--gpu-info`` prints, or a case-insensitive part of its name. Neither set, +the sidecar opens the adapter it always has and nothing is spelled. + +:func:`spawn_args` is the ``-gpu-adapter`` a sidecar holding frames on the +GPU is given, :func:`info` what the sidecar lists, and :func:`check` the +refusal a run gets before anything is spawned when the spec names no one +adapter. +""" + +from __future__ import annotations + +import functools +import json +import os +import subprocess +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass + +from . import binaries +from .errors import ErrorCode, FfrwdError +from .table import CellValue, TableResult, render_table + +__all__ = [ + "ADAPTER_FLAG", + "ADAPTER_VAR", + "SETUP_COMMAND", + "Adapter", + "Info", + "check", + "chosen", + "describe", + "info", + "spawn_args", + "spec", +] + +# The environment variable naming the adapter where the command line does not. +ADAPTER_VAR = "FFRWD_GPU_ADAPTER" + +# The sidecar's flag naming it. +ADAPTER_FLAG = "-gpu-adapter" + +SETUP_COMMAND = "ffrwd setup gpu" + +# What the sidecar's own refusal ends with; the CLI's hint names the CLI's +# command instead. +_SIDECAR_HINT = "; `ffrwd-wasm --gpu-info` lists them" + +# How long the sidecar gets to answer --gpu-info: it opens no device, but +# asks every backend for its adapters. +_INFO_TIMEOUT = 30.0 + +# ``--gpu-adapter``'s value while a command runs, None outside one or where +# it was not given. +_flag: str | None = None + + +@contextmanager +def chosen(flag: str | None) -> Iterator[None]: + """``--gpu-adapter``'s value for what runs inside, over the environment's. + + The variable is set to it too, so a process this one starts, a sidecar + or a node's runner, reads the same. None leaves the environment in + charge. + """ + global _flag + if flag is None: + yield + return + before, named = _flag, os.environ.get(ADAPTER_VAR) + _flag = flag + os.environ[ADAPTER_VAR] = flag + try: + yield + finally: + _flag = before + if named is None: + os.environ.pop(ADAPTER_VAR, None) + else: + os.environ[ADAPTER_VAR] = named + + +def spec() -> str | None: + """The adapter this run names: the flag's, else `FFRWD_GPU_ADAPTER`'s, + where either is set and not blank; else None.""" + given = _flag if _flag is not None else os.environ.get(ADAPTER_VAR, "") + return given if given.strip() else None + + +def _label(given: str) -> str: + """How the CLI names the spec in a refusal.""" + if _flag is not None: + return f"--gpu-adapter {given}" + return f"{ADAPTER_VAR}={given}" + + +def spawn_args() -> list[str]: + """The ``-gpu-adapter`` a sidecar holding frames on the GPU is given: + empty where nothing names an adapter, so the command is what it always + was.""" + given = spec() + return [ADAPTER_FLAG, given] if given is not None else [] + + +def _reject( + message: str, hint: str, *, code: ErrorCode = ErrorCode.UNSUPPORTED_SQL +) -> FfrwdError: + return FfrwdError(code, message, hint=hint) + + +@dataclass(frozen=True) +class Adapter: + """One adapter ``ffrwd-wasm --gpu-info`` lists.""" + + index: int + name: str + # wgpu's name for the backend: dx12, vulkan, metal, gl. + backend: str + vendor: int + device: int + # discrete, integrated, virtual, software or other. + kind: str + # Whether the sidecar opens it when nothing names an adapter. + default: bool + + +@dataclass(frozen=True) +class Info: + """What ``ffrwd-wasm --gpu-info`` answers, and what it made of a spec. + + `chosen` is the index the spec names, and `refused` the sidecar's + reason where it names no one adapter; both None where no spec was + asked about. + """ + + adapters: tuple[Adapter, ...] + chosen: int | None = None + refused: str | None = None + + +@functools.cache +def info(given: str | None = None) -> Info: + """Ask the sidecar for its adapters, and which one `given` names, once + per process for each spec. + + Raises ``FfrwdError`` -- and nothing else -- when the sidecar is not + installed, will not run, or answers with something that is not an + adapter list. + """ + sidecar = binaries.ffrwd_wasm_path() + if sidecar is None: + raise _reject( + "the ffrwd-wasm sidecar is not installed, and it is what lists the GPU adapters", + hint=binaries.INSTALL_HINT, + ) + argv = [sidecar, "--gpu-info"] + if given is not None: + argv += [ADAPTER_FLAG, given] + # The spec asked about is the one on the argv, never the variable. + env = {k: v for k, v in os.environ.items() if k != ADAPTER_VAR} + try: + done = subprocess.run( + argv, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=_INFO_TIMEOUT, + check=False, + env=env, + ) + except OSError as err: + raise _reject( + f"could not run the ffrwd-wasm sidecar at {sidecar}: {err.strerror or err}", + hint=binaries.INSTALL_HINT, + ) from err + except subprocess.TimeoutExpired as err: + raise _reject( + f"the ffrwd-wasm sidecar did not answer --gpu-info within {_INFO_TIMEOUT:.0f}s", + hint=binaries.INSTALL_HINT, + ) from err + if done.returncode != 0: + raise _reject( + f"the ffrwd-wasm sidecar at {sidecar} does not answer --gpu-info", + hint="it is older than this ffrwd; install the two together", + ) + try: + payload = json.loads(done.stdout) + except ValueError as err: + raise _reject( + "the ffrwd-wasm sidecar's answer to --gpu-info is not JSON", + hint="the sidecar found may be a different version than this ffrwd", + ) from err + return _info_document(payload) + + +def _info_document(payload: object) -> Info: + """`payload` as an :class:`Info`, refused where it is not one.""" + wrong = _reject( + "the ffrwd-wasm sidecar's answer to --gpu-info is not an adapter list", + hint="the sidecar found may be a different version than this ffrwd", + ) + if not isinstance(payload, dict) or not isinstance(payload.get("adapters"), list): + raise wrong + adapters = [] + for one in payload["adapters"]: + if not isinstance(one, dict): + raise wrong + try: + adapters.append( + Adapter( + index=int(one["index"]), + name=str(one["name"]), + backend=str(one["backend"]), + vendor=int(one["vendor"]), + device=int(one["device"]), + kind=str(one["type"]), + default=bool(one["default"]), + ) + ) + except (KeyError, TypeError, ValueError) as err: + raise wrong from err + picked = payload.get("chosen") + refused = payload.get("refused") + return Info( + adapters=tuple(adapters), + chosen=picked if isinstance(picked, int) else None, + refused=refused if isinstance(refused, str) else None, + ) + + +def check() -> None: + """Refuse a spec that names no one adapter, before a run spawns a sidecar + that would refuse it itself. Nothing to do where no spec is set. + + Raises ``FfrwdError`` with the sidecar's reason, the adapters there are, + and the setup command that lists them. + """ + given = spec() + if given is None: + return + found = info(given) + if found.refused is None: + return + reason = found.refused + prefix = f"{ADAPTER_FLAG} {given}: " + if reason.startswith(prefix): + reason = f"{_label(given)}: {reason[len(prefix):]}" + reason = reason.removesuffix(_SIDECAR_HINT) + raise _reject( + reason, + hint=f"`{SETUP_COMMAND}` lists the adapters this machine has", + code=ErrorCode.RUNTIME_NOT_FOUND, + ) + + +def _hex(value: int) -> str: + return f"0x{value:04x}" + + +def describe(found: Info) -> str: + """The table ``ffrwd setup gpu`` prints: every adapter, with its index.""" + rows: list[list[CellValue]] = [ + [ + one.index, + one.name, + one.backend, + one.kind, + _hex(one.vendor), + _hex(one.device), + "yes" if one.default else "", + ] + for one in found.adapters + ] + return render_table( + TableResult( + columns=["index", "name", "backend", "type", "vendor", "device", "default"], + rows=rows, + ) + ) diff --git a/cli/ffrwd/wasm.py b/cli/ffrwd/wasm.py index cbeb96e..d695a98 100644 --- a/cli/ffrwd/wasm.py +++ b/cli/ffrwd/wasm.py @@ -55,7 +55,7 @@ from pathlib import Path from typing import Literal, Protocol, cast -from . import av, binaries, nn, probe +from . import av, binaries, gpu, nn, probe from .emit import build_network_graph, build_node_network from .errors import ErrorCode, FfrwdError from .execute import STDIN, STDOUT @@ -126,6 +126,7 @@ "model_binding", "model_path", "copy_argv", + "opens_device", "probe_source", "read_packet_rows", "rows_arms", @@ -1047,11 +1048,14 @@ def describe(path: str) -> Described: def _grant_args(described: Described, path: str) -> list[str]: """The ``-http``/``-udp``/``-tcp``/``-gpu`` grants `described`'s own imports need for `path`, which :func:`invoke` and :func:`probe_source` both put ahead - of the flag that dispatches their call.""" + of the flag that dispatches their call. A ``-gpu`` grant brings the + ``-gpu-adapter`` the run names, where it names one.""" argv: list[str] = [] for effect in EFFECTS: if getattr(described, effect): argv += [_GRANT_FLAGS[effect], path] + if described.gpu: + argv += gpu.spawn_args() return argv @@ -1989,6 +1993,7 @@ def _argv( jobs: int | None = None, av_runtime: Sequence[str] = (), profile: bool = False, + adapter: Sequence[str] = (), ) -> list[str]: """One sidecar process as argv, run by `binary`. @@ -2032,7 +2037,9 @@ def _argv( ``-http``, ``-udp``, ``-tcp`` and ``-gpu`` grant one module its effects, also ahead of the module table: the sidecar denies each to any module the - argv never names. + argv never names. `adapter` is the ``-gpu-adapter`` the run names, written + after them for a process that opens the sidecar's device + (:func:`opens_device`), and empty where the run names none. `reads` is one path per stream the process is handed, each written as its own ``-f nut -i ``: stdin for the ordinary one-input process, and a @@ -2117,6 +2124,8 @@ def _argv( argv += list(av_runtime) for grant in process.grants: argv += [_GRANT_FLAGS[grant.effect], grant.module] + if opens_device(process): + argv += list(adapter) if process.codec: # A codec package's module may export both halves; the run says which. argv += [_CODEC_FLAG, process.codec] @@ -2146,6 +2155,13 @@ def _argv( return argv +def opens_device(process: SidecarProcess) -> bool: + """Whether `process` opens the sidecar's GPU device: a module granted + ``gpu``, or a ``decode``, ``encode`` or ``filter`` node, which work on + that device where the region holds its pictures there.""" + return process.av or any(grant.effect == "gpu" for grant in process.grants) + + def _rows_in_args(process: SidecarProcess) -> list[str]: """One ``-rows-in =`` per rows document this filter reads. @@ -2399,7 +2415,9 @@ def sidecar_argv( and what to run it on, which a printed command has no business spelling: the directory is under THIS machine's cache. A process decoding or encoding itself is told where the installed libav libraries are, by the - same rule. + same rule. A process opening the GPU device is told the adapter the run + names, where it names one, after the name is checked against what the + sidecar lists. `jobs` is the worker-thread cap the run asked for, passed to every sidecar; None is the default, the sidecar sizing itself to the machine. @@ -2407,7 +2425,8 @@ def sidecar_argv( row when it ends. Raises ``FfrwdError`` when the sidecar is not installed -- the same - rejection, and the same hint, a describe gives. + rejection, and the same hint, a describe gives -- or when the adapter the + run names is not one this machine has. """ binary = binaries.ffrwd_wasm_path() if binary is None: @@ -2416,6 +2435,8 @@ def sidecar_argv( "needs it to run", hint=INSTALL_HINT, ) + if opens_device(process): + gpu.check() return _argv( binary, process, @@ -2425,6 +2446,7 @@ def sidecar_argv( jobs, av.spawn_args() if process.av else (), profile, + gpu.spawn_args(), ) @@ -2440,8 +2462,18 @@ def shown_argv( The same convention a printed ffmpeg command follows: what is shown is what a reader would type, resolved by PATH, not the absolute path this machine happens to have found -- and so with no runtime directory either. + The adapter a run names is shown, since it is the reader's choice. """ - return _argv(binaries.SIDECAR_EXECUTABLE, process, reads, writes, (), jobs, profile=profile) + return _argv( + binaries.SIDECAR_EXECUTABLE, + process, + reads, + writes, + (), + jobs, + profile=profile, + adapter=gpu.spawn_args(), + ) def model_path(module: str, export: str) -> str: diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py index 8143cfa..052e721 100644 --- a/cli/tests/conftest.py +++ b/cli/tests/conftest.py @@ -145,6 +145,15 @@ def _isolated_store( clear_leaks(_store_home, leaks) +@pytest.fixture(autouse=True) +def _default_gpu_adapter(monkeypatch: pytest.MonkeyPatch) -> None: + """No test sees the developer's `FFRWD_GPU_ADAPTER`, which would spell + ``-gpu-adapter`` into every pinned command holding frames on the GPU.""" + from ffrwd import gpu + + monkeypatch.delenv(gpu.ADAPTER_VAR, raising=False) + + @pytest.fixture(autouse=True) def _offline_registry( _store_home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/cli/tests/test_gpu.py b/cli/tests/test_gpu.py new file mode 100644 index 0000000..e956691 --- /dev/null +++ b/cli/tests/test_gpu.py @@ -0,0 +1,207 @@ +"""The GPU adapter a run names: what reaches each sidecar's argv, and how a +spec the sidecar cannot match is refused.""" + +from __future__ import annotations + +import os + +import pytest + +from ffrwd import cli, gpu, wasm +from ffrwd.errors import ErrorCode, FfrwdError +from ffrwd.processes import EffectGrant, SidecarProcess +from ffrwd.wasm import Described + +MODULE = "modules/gpu_box.wasm" + +GRANTED = SidecarProcess( + id="sidecar0", + module=MODULE, + node="n", + grants=(EffectGrant(effect="gpu", module=MODULE),), +) +# A region decoding or encoding itself works on the device where it holds its +# pictures there. +DECODING = SidecarProcess(id="sidecar1", module=MODULE, node="n", av=True) +PLAIN = SidecarProcess(id="sidecar2", module=MODULE, node="n") + + +def _without(argv: list[str]) -> list[str]: + """`argv` with its ``-gpu-adapter`` pair taken out.""" + if gpu.ADAPTER_FLAG not in argv: + return argv + at = argv.index(gpu.ADAPTER_FLAG) + return argv[:at] + argv[at + 2 :] + + +def _spelled(argv: list[str]) -> str | None: + if gpu.ADAPTER_FLAG not in argv: + return None + return argv[argv.index(gpu.ADAPTER_FLAG) + 1] + + +def test_a_named_adapter_reaches_each_sidecar_that_opens_the_device( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The flag, else the variable, is spelled for a sidecar holding a GPU + module or an av node and for a compile-time call granted the GPU, right + after the grants, and nowhere else; with neither, no argv moves.""" + monkeypatch.setattr(wasm.binaries, "ffrwd_wasm_path", lambda: "ffrwd-wasm") + monkeypatch.setattr(wasm.av, "spawn_args", lambda: []) + checked: list[str | None] = [] + monkeypatch.setattr(gpu, "check", lambda: checked.append(gpu.spec())) + described = Described(world="ffrwd:av@0.17.0", name="p", gpu=True) + renders = (wasm.shown_argv, wasm.sidecar_argv) + + monkeypatch.delenv(gpu.ADAPTER_VAR, raising=False) + bare = {(render, p.id): render(p) for render in renders for p in (GRANTED, DECODING, PLAIN)} + assert not any(gpu.ADAPTER_FLAG in argv for argv in bare.values()) + assert wasm._grant_args(described, MODULE) == ["-gpu", MODULE] + + for env, flag, want in ( + ("1", None, "1"), + (None, "radeon", "radeon"), + ("1", "4090", "4090"), + (" ", None, None), + ): + if env is None: + monkeypatch.delenv(gpu.ADAPTER_VAR, raising=False) + else: + monkeypatch.setenv(gpu.ADAPTER_VAR, env) + checked.clear() + with gpu.chosen(flag): + for render in renders: + for process in (GRANTED, DECODING, PLAIN): + argv = render(process) + expected = None if process is PLAIN else want + assert _spelled(argv) == expected, (render.__name__, process.id, env, flag) + assert _without(argv) == bare[(render, process.id)] + if process is GRANTED and want is not None: + at = argv.index(gpu.ADAPTER_FLAG) + assert argv[at - 2 : at] == ["-gpu", MODULE] + assert _spelled(wasm._grant_args(described, MODULE)) == want + # Only a run's argv is checked against what the sidecar lists, once + # per process opening the device. + assert checked == [want, want] + assert os.environ.get(gpu.ADAPTER_VAR) == env + + +SIDECAR_SAYS = ( + "-gpu-adapter radeon: no adapter's name contains 'radeon'; the adapters are " + "0 NVIDIA GeForce RTX 4090 (dx12), 1 Microsoft Basic Render Driver (dx12); " + "`ffrwd-wasm --gpu-info` lists them" +) + + +def test_a_spec_the_sidecar_refuses_is_refused_as_the_run_spelled_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapters = ( + gpu.Adapter(0, "NVIDIA GeForce RTX 4090", "dx12", 0x10DE, 0x2684, "discrete", True), + gpu.Adapter(1, "Microsoft Basic Render Driver", "dx12", 0x1414, 0x8C, "software", False), + ) + asked: list[str | None] = [] + + def info(given: str | None = None) -> gpu.Info: + asked.append(given) + if given == "radeon": + return gpu.Info(adapters=adapters, refused=SIDECAR_SAYS) + return gpu.Info(adapters=adapters, chosen=1) + + monkeypatch.setattr(gpu, "info", info) + listed = ( + "no adapter's name contains 'radeon'; the adapters are 0 NVIDIA GeForce " + "RTX 4090 (dx12), 1 Microsoft Basic Render Driver (dx12)" + ) + monkeypatch.setenv(gpu.ADAPTER_VAR, "radeon") + for flag, message in ( + (None, f"FFRWD_GPU_ADAPTER=radeon: {listed}"), + ("radeon", f"--gpu-adapter radeon: {listed}"), + ): + with gpu.chosen(flag), pytest.raises(FfrwdError) as caught: + gpu.check() + assert caught.value.code is ErrorCode.RUNTIME_NOT_FOUND + assert caught.value.message == message + assert caught.value.hint == "`ffrwd setup gpu` lists the adapters this machine has" + with gpu.chosen("basic"): + gpu.check() + monkeypatch.delenv(gpu.ADAPTER_VAR) + gpu.check() + assert asked == ["radeon", "radeon", "basic"] + + +def test_the_sidecars_list_is_read_and_a_wrong_one_refused() -> None: + read = gpu._info_document( + { + "adapters": [ + { + "index": 0, + "name": "llvmpipe", + "backend": "vulkan", + "vendor": 65541, + "device": 0, + "type": "software", + "default": True, + } + ], + "spec": "0", + "chosen": 0, + "refused": None, + } + ) + assert read == gpu.Info( + adapters=(gpu.Adapter(0, "llvmpipe", "vulkan", 65541, 0, "software", True),), + chosen=0, + ) + for wrong in ({}, {"adapters": [{"index": 0}]}, {"adapters": ["x"]}, []): + with pytest.raises(FfrwdError, match="not an adapter list"): + gpu._info_document(wrong) + + +def test_a_remote_run_names_no_adapter(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["run", "SELECT 1", "--remote", "--gpu-adapter", "0"]) == 2 + err = capsys.readouterr().err + assert "error: run: --gpu-adapter names an adapter on this machine" in err + assert "hint: drop --gpu-adapter, or drop --remote" in err + + +def test_setup_gpu_prints_the_adapters_and_the_one_a_run_opens( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + adapters = ( + gpu.Adapter(0, "NVIDIA GeForce RTX 4090", "dx12", 0x10DE, 0x2684, "discrete", True), + gpu.Adapter(1, "Microsoft Basic Render Driver", "dx12", 0x1414, 0x8C, "software", False), + ) + named = {None: None, "basic": 1, "1": 1} + monkeypatch.setattr( + gpu, + "info", + lambda given=None: gpu.Info( + adapters=adapters, + chosen=named.get(given), + refused=None if given in named else SIDECAR_SAYS, + ), + ) + table = ( + " index | name | backend | type | vendor | device | default\n" + "-------+-------------------------------+---------+----------+--------+--------+---------\n" + " 0 | NVIDIA GeForce RTX 4090 | dx12 | discrete | 0x10de | 0x2684 | yes\n" + " 1 | Microsoft Basic Render Driver | dx12 | software | 0x1414 | 0x008c |\n" + "(2 rows)\n" + ) + assert cli.main(["setup", "gpu"]) == 0 + assert capsys.readouterr().out == table + ( + "a run opens 0, NVIDIA GeForce RTX 4090 (dx12); " + "--gpu-adapter or FFRWD_GPU_ADAPTER names another\n" + ) + monkeypatch.setenv(gpu.ADAPTER_VAR, "basic") + assert cli.main(["setup", "gpu"]) == 0 + assert capsys.readouterr().out == table + ( + "FFRWD_GPU_ADAPTER=basic names 1, Microsoft Basic Render Driver (dx12): a run opens it\n" + ) + monkeypatch.setenv(gpu.ADAPTER_VAR, "radeon") + assert cli.main(["setup", "gpu"]) == 1 + said = capsys.readouterr() + assert said.out == table + assert "RUNTIME_NOT_FOUND: FFRWD_GPU_ADAPTER=radeon: no adapter's name" in said.err + assert cli.main(["setup", "gpu", "--cuda"]) == 2 diff --git a/docs/dialect.md b/docs/dialect.md index b2f045c..95fa201 100644 --- a/docs/dialect.md +++ b/docs/dialect.md @@ -330,7 +330,12 @@ dest := 'path' | STDOUT | ( value-expression ) | sink(value, ...) `-udp `, `-tcp `, `-gpu `), which the compiler emits from the module's own describe; each is denied without it. Every worker of a sidecar process shares its one GPU device, so a GPU module - takes the same workers as any other. Secrets do not belong in the + takes the same workers as any other. The device is on the adapter + `--gpu-adapter` on `run` or `compile` names (an index in the list + `ffrwd setup gpu` prints, or part of the adapter's name), else + `FFRWD_GPU_ADAPTER`'s, else the sidecar's own choice; the compiler + spells `-gpu-adapter` on each sidecar that opens the device only when + one is named. Secrets do not belong in the value arguments - the query text is the command line. Recipes [98](examples.md#98-post-what-a-module-found-as-it-is-found), [99](corpus.md#99-watch-the-frames-go-by). diff --git a/docs/errors.md b/docs/errors.md index e520d47..461049a 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -688,7 +688,7 @@ This code, `PLAYER_NOT_FOUND`, `RUNTIME_NOT_FOUND` and `UNKNOWN_RECIPE` refuse a **Meaning:** `ffrwd setup nn` (or a query reaching a model, provisioning the same tiers on its way) asked for a tier of the ONNX Runtime this machine's platform has no artifacts pinned for -- `--cuda`/`--full` on a platform that pins no such tier (detection itself never asks for one), or a sidecar demanding a runtime version this ffrwd carries no table for at all. Never a query problem: the table is keyed on the sidecar's own reported version and platform, not on anything the SQL names. -**Fires when:** a tier passed to `nn.provision()` is absent from the pinned table for this platform, or the sidecar's `--nn-info` names an ONNX Runtime version/platform pair nothing is pinned for. Also when `FFRWD_OFFLINE` is set to any non-blank value and a fetch would have started: the ffmpeg provisioner, a runtime tier for a run or an install, each named with its size, and the hint is the `ffrwd setup` command that fetches it on a connected machine. A machine that already holds what the run needs is unaffected by the variable. +**Fires when:** a tier passed to `nn.provision()` is absent from the pinned table for this platform, or the sidecar's `--nn-info` names an ONNX Runtime version/platform pair nothing is pinned for. Also when `FFRWD_OFFLINE` is set to any non-blank value and a fetch would have started: the ffmpeg provisioner, a runtime tier for a run or an install, each named with its size, and the hint is the `ffrwd setup` command that fetches it on a connected machine. A machine that already holds what the run needs is unaffected by the variable. Also when `run --gpu-adapter`, or `FFRWD_GPU_ADAPTER`, names no one adapter the sidecar lists: an index past the end of the list, a name no adapter has, or a name several adapters of one backend share. The message names the adapters there are, and the hint is `ffrwd setup gpu`. **Error JSON:** diff --git a/docs/examples.md b/docs/examples.md index 12d02fe..6268773 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1586,6 +1586,8 @@ ffmpeg -i tests/fixtures/testsrc.mp4 -map 0:v:0 -c:0 copy -avoid_negative_ts dis With `memory => 'host'` the same module holds nothing on the GPU, and its region takes raw frames on both pipes unless the declaration says `SET ffrwd.edges = 'coded'`. The region's `decode` is told `hw=auto`: where the machine's decoder and the device share one (D3D12 on Windows, Metal on macOS) the decoder's frames land on the device directly; elsewhere the decoded picture reaches it through the sidecar's own upload at the module's input and comes back through one download at the output, which the `upload` and `download` rows of `--profile` count. +The device is on the adapter the sidecar picks by default, the one `ffrwd setup gpu` marks in its list. `--gpu-adapter` on `run` or `compile`, or `FFRWD_GPU_ADAPTER` where the flag is absent, names another, by its index in that list or by part of its name: `ffrwd run --gpu-adapter radeon -f query.sql` opens the device on that adapter, so `gpu_box` and the decoder and encoder sharing the device run there, and the printed command carries `-gpu-adapter radeon` after the `-gpu` grant. With neither, the command is the one above. Where the adapter has no decoder for the stream, `hw=auto` falls back as it does anywhere, to a decoder on a device of its own or to software. + ## 158. Scale on the device, inside the region A filter between a module that holds its frames on the GPU and the rest of its region runs in the region too, on the sidecar's device, where the sidecar's libavfilter has a variant of it for that device. Here `scale` reads `gpu_box`'s picture and is read by `h264_d3d12va`, an encoder the sidecar hosts that takes the device's pictures, so it becomes the sidecar's own `filter` node running `scale_d3d12`. The picture is decoded onto the device, boxed, scaled and encoded there, and never crosses to the host: diff --git a/sidecar/NODE-CLI.md b/sidecar/NODE-CLI.md index fc43c94..9eb561a 100644 --- a/sidecar/NODE-CLI.md +++ b/sidecar/NODE-CLI.md @@ -433,6 +433,45 @@ tick that held them. rate the first stream bound to that port states in its header, or at its time base's inverse where it states none. +## The GPU adapter + + ffrwd-wasm ... -gpu-adapter + +names the adapter the process's one GPU device is opened on: the device +the modules granted `-gpu` are handed, a decoder holding its pictures on +the GPU decodes onto, the encoders read from and `filter` runs on. +`` is a zero-based index into the list `--gpu-info` prints, or a +case-insensitive part of an adapter's name (`radeon`, `4090`); digits +past the end of the list are read as a name. A name is looked for in the +preferred backend first (D3D12 on Windows, Metal on macOS, Vulkan +elsewhere), so `4090` is the D3D12 adapter where Vulkan reports the same +card; a name two adapters of that backend share is refused, and an index +names either. `FFRWD_GPU_ADAPTER` says the same where the flag is absent. +With neither, the device is on the adapter wgpu picks for high +performance, which `--gpu-info` marks `default`. + +A spec naming no one adapter ends the run, exit 2, when the device is +first opened, with one line naming the spec and the adapters there are: + + ffrwd-wasm: -gpu-adapter radeon: no adapter's name contains 'radeon'; the adapters are 0 NVIDIA GeForce RTX 4090 (dx12), 1 Microsoft Basic Render Driver (dx12), 2 NVIDIA GeForce RTX 4090 (vulkan); `ffrwd-wasm --gpu-info` lists them + +No other adapter is opened in its place. A run that never opens the +device does not read the spec. + + ffrwd-wasm --gpu-info [-gpu-adapter ] + +prints one JSON line listing the adapters in the order an index counts +them, and opens none: + + {"adapters":[{"backend":"dx12","default":true,"device":9860,"index":0,"name":"NVIDIA GeForce RTX 4090","type":"discrete","vendor":4318},{"backend":"dx12","default":false,"device":140,"index":1,"name":"Microsoft Basic Render Driver","type":"software","vendor":5140},{"backend":"vulkan","default":false,"device":9860,"index":2,"name":"NVIDIA GeForce RTX 4090","type":"discrete","vendor":4318}]} + +`type` is `discrete`, `integrated`, `virtual`, `software` (WARP, +lavapipe) or `other`; `vendor` and `device` are the backend's ids for +the adapter, the PCI ids on D3D12 and Vulkan. Given a spec, or with +`FFRWD_GPU_ADAPTER` set, it adds `spec`, `chosen` (the index the spec +names, or null) and `refused` (the line a run would end with, or null). +`WGPU_BACKEND` narrows the list as it narrows what a run opens. + ## `-profile` ffrwd-wasm ... -profile diff --git a/sidecar/ffrwd-wasm/src/main.rs b/sidecar/ffrwd-wasm/src/main.rs index 04ba3eb..6d04de3 100644 --- a/sidecar/ffrwd-wasm/src/main.rs +++ b/sidecar/ffrwd-wasm/src/main.rs @@ -47,6 +47,7 @@ use std::time::Instant; use anyhow::{anyhow, bail, Context, Result}; use ffrwd_wasm::nut; +use ffrwd_wasm_runtime::gpu::adapters as gpu_adapters; use ffrwd_wasm_runtime::profile::PROFILE; use ffrwd_wasm_runtime::runtime::{ self, AudioFormat, Filter, Format, Frame, Media, StreamInfo, TimeBase, VideoFormat, @@ -778,6 +779,64 @@ fn take_grant_args(argv: Vec) -> Result> { Ok(rest) } +/// Takes `-gpu-adapter ` out of the argv and leaves the rest: the +/// adapter the process's device is opened on, by its index in the list +/// `--gpu-info` prints or by a case-insensitive part of its name. Read +/// before the argv is dispatched, the way the grants are. +fn take_gpu_adapter_args(argv: Vec) -> Result<(Option, Vec)> { + let mut spec = None; + let mut rest = Vec::with_capacity(argv.len()); + let mut it = argv.into_iter(); + while let Some(arg) = it.next() { + if arg == gpu_adapters::ADAPTER_FLAG { + let value = it + .next() + .ok_or_else(|| anyhow!("{} requires a value", gpu_adapters::ADAPTER_FLAG))?; + spec = Some(value); + } else { + rest.push(arg); + } + } + Ok((spec, rest)) +} + +/// What `--gpu-info` prints: every adapter the device could be opened on, +/// which one it is by default, and, where `-gpu-adapter` or +/// `FFRWD_GPU_ADAPTER` names one, which that is or why it is refused. +fn gpu_info(spec: Option<&gpu_adapters::Spec>) -> serde_json::Value { + let listed = ffrwd_wasm_runtime::gpu::list_adapters(); + let default = gpu_adapters::default_index(&listed); + let adapters: Vec = listed + .iter() + .enumerate() + .map(|(index, one)| { + serde_json::json!({ + "index": index, + "name": one.name, + "backend": one.backend, + "vendor": one.vendor, + "device": one.device, + "type": one.kind.label(), + "default": default == Some(index), + }) + }) + .collect(); + let mut info = serde_json::json!({ "adapters": adapters }); + if let Some(spec) = spec { + let (chosen, refused) = match gpu_adapters::resolve(spec, &listed) { + Ok(index) => (serde_json::json!(index), serde_json::Value::Null), + Err(refusal) => ( + serde_json::Value::Null, + serde_json::json!(refusal.to_string()), + ), + }; + info["spec"] = serde_json::json!(spec.raw); + info["chosen"] = chosen; + info["refused"] = refused; + } + info +} + fn parse_args(argv: Vec) -> Result { let mut it = argv.into_iter(); @@ -4220,6 +4279,22 @@ fn main() { exit(0); } + // The adapters the process's device could be opened on. It opens none, + // so it answers whatever a spec says, naming a refused one. + if argv.first().map(String::as_str) == Some("--gpu-info") { + match take_gpu_adapter_args(argv[1..].to_vec()) { + Ok((flag, _)) => { + let spec = gpu_adapters::Spec::given(flag); + println!("{}", gpu_info(spec.as_ref())); + exit(0); + } + Err(e) => { + eprintln!("ffrwd-wasm: {e:#}"); + exit(2); + } + } + } + // The relay runs no module, so it reads none of the flags a module run // takes, and its edges arrive on stdin rather than argv. if argv.first().map(String::as_str) == Some("relay") { @@ -4263,6 +4338,21 @@ fn main() { } }; + // The adapter is named before anything opens the device; a spec naming + // no one adapter ends the run when the device is first wanted. + let raw_args = match take_gpu_adapter_args(raw_args) { + Ok((flag, rest)) => { + if let Some(spec) = gpu_adapters::Spec::given(flag) { + ffrwd_wasm_runtime::gpu::choose_adapter(spec); + } + rest + } + Err(e) => { + eprintln!("ffrwd-wasm: {e:#}"); + exit(2); + } + }; + // Models load once, before any component is instantiated. A run binding // none touches no ONNX Runtime. if let Err(e) = nn::configure(&nn_config) { diff --git a/sidecar/ffrwd-wasm/tests/gpu_frames.rs b/sidecar/ffrwd-wasm/tests/gpu_frames.rs index af9b9ab..496f893 100644 --- a/sidecar/ffrwd-wasm/tests/gpu_frames.rs +++ b/sidecar/ffrwd-wasm/tests/gpu_frames.rs @@ -103,9 +103,28 @@ fn run(memory: &str, jobs: &str, frames: &[Vec]) -> Output { /// `run` with `extra` ahead of the inputs. fn run_with(memory: &str, jobs: &str, frames: &[Vec], extra: &[&str]) -> Output { + run_in(memory, jobs, frames, extra, None) +} + +/// The environment variable naming the adapter when the argv does not. +const ADAPTER_VAR: &str = "FFRWD_GPU_ADAPTER"; + +/// `run_with`, with [`ADAPTER_VAR`] set to `adapter` or, for None, unset. +fn run_in( + memory: &str, + jobs: &str, + frames: &[Vec], + extra: &[&str], + adapter: Option<&str>, +) -> Output { let module = module(); let wiring = format!("[v=0:v]gpu_box=memory={memory}:size={SIDE}[out=o]"); - let mut child = Command::new(env!("CARGO_BIN_EXE_ffrwd-wasm")) + let mut command = Command::new(env!("CARGO_BIN_EXE_ffrwd-wasm")); + match adapter { + Some(spec) => command.env(ADAPTER_VAR, spec), + None => command.env_remove(ADAPTER_VAR), + }; + let mut child = command .args(extra) .args(["-gpu", &module, "-jobs", jobs, "-f", "nut", "-i", "-", "-m"]) .arg(format!("gpu_box={module}")) @@ -245,6 +264,197 @@ fn frames_held_on_the_gpu_come_back_with_the_same_box() { assert_eq!(got, again, "-jobs 1 and -jobs 4 write the same bytes"); } +/// What `--gpu-info` lists on this machine, None where it lists nothing +/// (and [`GPU_REQUIRED`] does not say there is something). +fn adapters() -> Option> { + let output = Command::new(env!("CARGO_BIN_EXE_ffrwd-wasm")) + .arg("--gpu-info") + .env_remove(ADAPTER_VAR) + .output() + .expect("spawn ffrwd-wasm --gpu-info"); + assert!( + output.status.success(), + "--gpu-info exited {:?}:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).expect("one JSON line"); + let listed = info["adapters"] + .as_array() + .expect("a list of adapters") + .clone(); + let required = std::env::var_os(GPU_REQUIRED).is_some_and(|v| !v.is_empty()); + assert!( + !(listed.is_empty() && required), + "{GPU_REQUIRED} is set, and --gpu-info lists no adapter" + ); + (!listed.is_empty()).then_some(listed) +} + +/// The adapter a run's `[gpu] device:` line names, as ` ()`. +fn device_line(output: &Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + stderr + .lines() + .find_map(|line| line.strip_prefix("[gpu] device: ")) + .unwrap_or_else(|| panic!("no [gpu] device: line on stderr:\n{stderr}")) + .to_string() +} + +/// How the `[gpu] device:` line names `adapter`: wgpu's Debug spelling of +/// its backend. +fn named_as(adapter: &serde_json::Value) -> String { + let backend = match adapter["backend"].as_str().expect("a backend") { + "dx12" => "Dx12", + "vulkan" => "Vulkan", + "metal" => "Metal", + "gl" => "Gl", + other => panic!("a backend wgpu does not name: {other}"), + }; + format!("{} ({backend})", adapter["name"].as_str().expect("a name")) +} + +#[test] +fn gpu_info_lists_each_adapter_and_marks_the_one_opened_by_default() { + let Some(listed) = adapters() else { + eprintln!("skipped: this machine has no GPU adapter"); + return; + }; + let rank = |kind: &str| match kind { + "discrete" => 1, + "integrated" => 2, + "other" => 3, + "virtual" => 4, + "software" => 5, + other => panic!("a type --gpu-info does not spell: {other}"), + }; + for (index, adapter) in listed.iter().enumerate() { + assert_eq!(adapter["index"], index, "{adapter}"); + assert!( + adapter["vendor"].is_u64() && adapter["device"].is_u64(), + "{adapter}" + ); + rank(adapter["type"].as_str().expect("a type")); + } + let defaults: Vec<&serde_json::Value> = + listed.iter().filter(|a| a["default"] == true).collect(); + let [default] = defaults[..] else { + panic!("one adapter is the default: {listed:?}"); + }; + // The best kind the first backend has, the first of it where several are. + let first = &listed[0]["backend"]; + let best = listed + .iter() + .filter(|a| &a["backend"] == first) + .min_by_key(|a| rank(a["type"].as_str().unwrap())) + .unwrap(); + assert_eq!(default, best, "{listed:?}"); + + let frames: Vec> = (0..2u8).map(picture).collect(); + let output = run("gpu", "1", &frames); + assert_eq!(device_line(&output), named_as(default)); +} + +#[test] +fn an_adapter_named_by_index_or_by_name_is_the_one_opened() { + let Some(listed) = adapters() else { + eprintln!("skipped: this machine has no GPU adapter"); + return; + }; + let frames: Vec> = (0..3u8).map(picture).collect(); + let host = frames_of(&run("host", "1", &frames), "gpu_box in host memory"); + for adapter in &listed { + let index = adapter["index"].to_string(); + let output = run_with("gpu", "1", &frames, &["-gpu-adapter", &index]); + assert_eq!( + device_line(&output), + named_as(adapter), + "-gpu-adapter {index}" + ); + assert_eq!(frames_of(&output, &named_as(adapter)), host); + } + // A name in another case names the first backend's adapter of that name. + let first = &listed[0]; + let name = first["name"].as_str().unwrap().to_uppercase(); + let alone = listed + .iter() + .filter(|a| a["backend"] == first["backend"] && a["name"] == first["name"]) + .count() + == 1; + if alone { + let output = run_with("gpu", "1", &frames, &["-gpu-adapter", &name]); + assert_eq!(device_line(&output), named_as(first), "-gpu-adapter {name}"); + let output = run_in("gpu", "1", &frames, &[], Some(&name)); + assert_eq!( + device_line(&output), + named_as(first), + "{ADAPTER_VAR}={name}" + ); + } +} + +#[test] +fn a_software_adapter_named_runs_the_module_on_itself() { + let Some(listed) = adapters() else { + eprintln!("skipped: this machine has no GPU adapter"); + return; + }; + let Some(software) = listed.iter().find(|a| a["type"] == "software") else { + eprintln!("skipped: this machine lists no software adapter"); + return; + }; + let name = software["name"].as_str().unwrap(); + let frames: Vec> = (0..3u8).map(picture).collect(); + let output = run_with("gpu", "1", &frames, &["-gpu-adapter", name]); + assert_eq!(device_line(&output), named_as(software)); + let got = frames_of(&output, name); + assert_eq!(got.len(), 3); + for (index, (_, data)) in got.iter().enumerate() { + assert_eq!(*data, boxed(index as u8), "frame {index} on {name}"); + } +} + +#[test] +fn an_adapter_spec_naming_none_ends_the_run_rather_than_open_another() { + if adapters().is_none() { + eprintln!("skipped: this machine has no GPU adapter"); + return; + } + let frames: Vec> = (0..2u8).map(picture).collect(); + let hint = "`ffrwd-wasm --gpu-info` lists them"; + for (output, said) in [ + ( + run_with("gpu", "1", &frames, &["-gpu-adapter", "no such card"]), + "ffrwd-wasm: -gpu-adapter no such card: no adapter's name contains 'no such card'; \ + the adapters are 0 ", + ), + ( + run_with("gpu", "1", &frames, &["-gpu-adapter", "987654321"]), + "ffrwd-wasm: -gpu-adapter 987654321: there is no adapter 987654321, they are \ + numbered from 0 to ", + ), + ( + run_in("gpu", "1", &frames, &[], Some("no such card")), + "ffrwd-wasm: FFRWD_GPU_ADAPTER=no such card: no adapter's name contains", + ), + ] { + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(2), "{stderr}"); + assert!(stderr.contains(said) && stderr.contains(hint), "{stderr}"); + assert!(!stderr.contains("[gpu] device:"), "{stderr}"); + assert!(output.stdout.is_empty(), "nothing is written"); + } + // The flag replaces the variable. + let output = run_in( + "gpu", + "1", + &frames, + &["-gpu-adapter", "0"], + Some("no such card"), + ); + assert_eq!(frames_of(&output, "-gpu-adapter 0").len(), 2); +} + // A decoder on the process's own device: `decode` with `hw=d3d12va` on // Windows or `hw=videotoolbox` on macOS, read by an input held on the GPU, // leaves its pictures there, and gpu_box reads them as textures with diff --git a/sidecar/runtime/src/gpu.rs b/sidecar/runtime/src/gpu.rs index 73dbaeb..6524cca 100644 --- a/sidecar/runtime/src/gpu.rs +++ b/sidecar/runtime/src/gpu.rs @@ -19,11 +19,15 @@ //! backend is the platform's own, D3D12 on Windows, Metal on macOS, Vulkan //! elsewhere, since every decoder there writes that API's images; //! `WGPU_BACKEND` (wgpu's own variable, a comma list such as `vulkan` or -//! `dx12`) narrows what is tried. +//! `dx12`) narrows what is tried. The adapter is the one wgpu picks for a +//! high-performance request, unless `-gpu-adapter` or `FFRWD_GPU_ADAPTER` +//! names another ([`adapters`]); the decoder, the encoders and the filters +//! that share the device move with it. use anyhow::Result; use wasmtime::component::{Component, Linker}; +pub mod adapters; #[cfg(feature = "gpu")] mod convert; #[cfg(all(feature = "gpu", windows))] @@ -84,6 +88,8 @@ mod enabled { use wasi_webgpu_wasmtime::reexports::{wgpu_core, wgpu_types}; use wasi_webgpu_wasmtime::SharedDevice; + use super::adapters::{self, Kind, Listed, Refusal, Spec}; + /// A store's GPU: the wgpu instance its module's calls go to, and the /// process's device when the module is granted one. pub struct StoreGpu { @@ -165,31 +171,85 @@ mod enabled { | wgpu_types::Features::TEXTURE_FORMAT_NV12 } - /// Opens the process's device on the first adapter a preferred backend - /// finds. None when the machine has no adapter at all, which every - /// caller treats as "no GPU". - fn open_device() -> Option> { + /// The adapter `-gpu-adapter` or `FFRWD_GPU_ADAPTER` names, set before + /// anything opens the device. + static SPEC: OnceLock = OnceLock::new(); + + fn kind(device_type: wgpu_types::DeviceType) -> Kind { + match device_type { + wgpu_types::DeviceType::DiscreteGpu => Kind::Discrete, + wgpu_types::DeviceType::IntegratedGpu => Kind::Integrated, + wgpu_types::DeviceType::VirtualGpu => Kind::Virtual, + wgpu_types::DeviceType::Cpu => Kind::Software, + wgpu_types::DeviceType::Other => Kind::Other, + } + } + + /// Every adapter of each preferred backend, in the order + /// [`adapters`] lists them, each with its id on the shared instance. + fn enumerate() -> Vec<(wgpu_core::id::AdapterId, Listed)> { let instance = shared(); + let mut found = Vec::new(); + for backends in PREFERENCE { + for id in instance.enumerate_adapters(*backends) { + let info = instance.adapter_get_info(id); + found.push(( + id, + Listed { + name: info.name, + backend: info.backend.to_str(), + vendor: info.vendor, + device: info.device, + kind: kind(info.device_type), + }, + )); + } + } + found + } + + /// The adapters `--gpu-info` lists. + pub fn list_adapters() -> Vec { + enumerate() + .into_iter() + .map(|(id, listed)| { + shared().adapter_drop(id); + listed + }) + .collect() + } + + /// Names the adapter the process's device is opened on. Nothing is + /// enumerated until the device is first wanted, so a run that never + /// opens it pays nothing for the spec. + pub fn choose_adapter(spec: Spec) { + let _ = SPEC.set(spec); + } + + /// The adapter `spec` names, the others dropped. + fn named(spec: &Spec) -> Result { + let found = enumerate(); + let list: Vec = found.iter().map(|(_, listed)| listed.clone()).collect(); + let chosen = adapters::resolve(spec, &list); + for (index, (id, _)) in found.iter().enumerate() { + if chosen.as_ref().ok() != Some(&index) { + shared().adapter_drop(*id); + } + } + chosen.map(|index| found[index].0) + } + + /// The first adapter a preferred backend finds for a high-performance + /// request. + fn requested() -> Option { let options = wgpu_types::RequestAdapterOptions { power_preference: wgpu_types::PowerPreference::HighPerformance, force_fallback_adapter: false, compatible_surface: None, }; - #[cfg(windows)] - let validating = wgpu_types::InstanceDescriptor::new_without_display_handle_from_env() - .flags - .contains(wgpu_types::InstanceFlags::VALIDATION); - #[cfg(windows)] - if validating { - super::d3d12::quiet_queue_validation(); - } - let mut found = None; for backends in PREFERENCE { - match instance.request_adapter(&options, *backends, None) { - Ok(adapter) => { - found = Some(adapter); - break; - } + match shared().request_adapter(&options, *backends, None) { + Ok(adapter) => return Some(adapter), Err(wgpu_types::RequestAdapterError::NotFound { .. }) => continue, Err(e) => { eprintln!("[gpu] asking for an adapter: {e}"); @@ -197,7 +257,32 @@ mod enabled { } } } - let adapter = found?; + None + } + + /// Opens the process's device on the adapter a spec names, else on the + /// first adapter a preferred backend finds. None when the machine has + /// no adapter at all, which every caller treats as "no GPU". + fn open_device() -> Option> { + let instance = shared(); + #[cfg(windows)] + let validating = wgpu_types::InstanceDescriptor::new_without_display_handle_from_env() + .flags + .contains(wgpu_types::InstanceFlags::VALIDATION); + #[cfg(windows)] + if validating { + super::d3d12::quiet_queue_validation(); + } + let adapter = match SPEC.get() { + // A spec naming no one adapter ends the run: falling back to + // another device, or to none, would run somewhere it did not + // ask for. + Some(spec) => named(spec).unwrap_or_else(|refusal| { + eprintln!("ffrwd-wasm: {refusal}"); + std::process::exit(2); + }), + None => requested()?, + }; let features = instance.adapter_features(adapter) & wanted_features(); let limits = instance.adapter_limits(adapter); let info = instance.adapter_get_info(adapter); @@ -255,7 +340,7 @@ mod enabled { } #[cfg(feature = "gpu")] -pub use enabled::{shared, shared_device, StoreGpu}; +pub use enabled::{choose_adapter, list_adapters, shared, shared_device, StoreGpu}; /// A build without the `gpu` feature holds nothing per store. #[cfg(not(feature = "gpu"))] @@ -268,6 +353,17 @@ impl StoreGpu { } } +/// A build without the `gpu` feature has no adapter to list. +#[cfg(not(feature = "gpu"))] +pub fn list_adapters() -> Vec { + Vec::new() +} + +/// A build without the `gpu` feature opens no device, so a spec chooses +/// nothing. +#[cfg(not(feature = "gpu"))] +pub fn choose_adapter(_spec: adapters::Spec) {} + /// Adds `wasi:webgpu` (and the `wasi:graphics-context` its types name) to a /// linker whose store data hands out a webgpu view. #[cfg(feature = "gpu")] diff --git a/sidecar/runtime/src/gpu/adapters.rs b/sidecar/runtime/src/gpu/adapters.rs new file mode 100644 index 0000000..5927387 --- /dev/null +++ b/sidecar/runtime/src/gpu/adapters.rs @@ -0,0 +1,347 @@ +//! Which adapter the process's device is opened on: the list `--gpu-info` +//! prints, the one wgpu would pick, and the one a `-gpu-adapter` spec (or +//! `FFRWD_GPU_ADAPTER`) names. +//! +//! The list is every adapter of each preferred backend, the backends in +//! their preferred order and each backend's adapters in the order it +//! reports them. A spec is a zero-based index into it, or a case-insensitive +//! substring of an adapter's name; digits past the end of the list are read +//! as a name, so `4090` names a card. A name is looked for backend by backend: +//! the first backend with an adapter so named answers, so `4090` is the +//! D3D12 adapter on Windows even where Vulkan reports the same card, and a +//! backend with several so named refuses it. + +use std::fmt; + +/// The environment variable naming the adapter when the argv does not. +pub const ADAPTER_VAR: &str = "FFRWD_GPU_ADAPTER"; + +/// The flag naming the adapter. +pub const ADAPTER_FLAG: &str = "-gpu-adapter"; + +/// Where the full list is printed, named in every refusal. +const INFO_HINT: &str = "`ffrwd-wasm --gpu-info` lists them"; + +/// What kind of adapter it is, as `--gpu-info` spells it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Discrete, + Integrated, + Virtual, + Software, + Other, +} + +impl Kind { + pub fn label(self) -> &'static str { + match self { + Kind::Discrete => "discrete", + Kind::Integrated => "integrated", + Kind::Virtual => "virtual", + Kind::Software => "software", + Kind::Other => "other", + } + } + + /// wgpu's order for a high-performance request: discrete first, then + /// integrated, other, virtual, software. + fn rank(self) -> u8 { + match self { + Kind::Discrete => 1, + Kind::Integrated => 2, + Kind::Other => 3, + Kind::Virtual => 4, + Kind::Software => 5, + } + } +} + +/// One adapter of the list. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Listed { + pub name: String, + /// wgpu's name for the backend: `dx12`, `vulkan`, `metal`, `gl`. + pub backend: &'static str, + pub vendor: u32, + pub device: u32, + pub kind: Kind, +} + +/// The index wgpu's own high-performance request picks: in the first +/// backend that has any adapter, the first of the best kind. +pub fn default_index(list: &[Listed]) -> Option { + let first = list.first()?.backend; + list.iter() + .enumerate() + .filter(|(_, one)| one.backend == first) + .min_by_key(|(_, one)| one.kind.rank()) + .map(|(index, _)| index) +} + +/// A spec, and where it was given. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Spec { + pub raw: String, + pub from_env: bool, +} + +impl Spec { + /// The flag's value, which replaces the environment's; else the + /// environment's, where it is set and not blank. + pub fn given(flag: Option) -> Option { + if let Some(raw) = flag { + return Some(Spec { + raw, + from_env: false, + }); + } + let raw = std::env::var_os(ADAPTER_VAR)?.to_string_lossy().to_string(); + if raw.trim().is_empty() { + return None; + } + Some(Spec { + raw, + from_env: true, + }) + } + + /// How a refusal names it: `-gpu-adapter radeon` or + /// `FFRWD_GPU_ADAPTER=radeon`. + fn label(&self) -> String { + if self.from_env { + format!("{ADAPTER_VAR}={}", self.raw) + } else { + format!("{ADAPTER_FLAG} {}", self.raw) + } + } +} + +/// Why a spec names no one adapter. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Why { + /// The spec is blank. + Blank, + /// The machine has no adapter at all. + NoAdapters, + /// Digits past the end of the list, which no adapter's name contains + /// either. + OutOfRange, + /// No adapter's name contains it. + NoMatch, + /// Several adapters of one backend have it in their names, at these + /// indices. + Ambiguous(Vec), +} + +/// A spec refused, with the list it was matched against. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Refusal { + pub spec: Spec, + pub why: Why, + pub adapters: Vec, +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = self.spec.label(); + let raw = self.spec.raw.trim(); + match &self.why { + Why::Blank => return write!(f, "{label}: names no adapter; {INFO_HINT}"), + Why::NoAdapters => return write!(f, "{label}: this machine has no GPU adapter"), + Why::OutOfRange => write!( + f, + "{label}: there is no adapter {raw}, they are numbered from 0 to {}, and no \ + adapter's name contains '{raw}'", + self.adapters.len() - 1 + )?, + Why::NoMatch => write!(f, "{label}: no adapter's name contains '{raw}'")?, + Why::Ambiguous(indices) => { + let backend = self.adapters[indices[0]].backend; + write!( + f, + "{label}: {} {backend} adapters' names contain '{raw}'; name one by its \ + index", + indices.len() + )? + } + } + let listed: Vec = self + .adapters + .iter() + .enumerate() + .map(|(index, one)| format!("{index} {} ({})", one.name, one.backend)) + .collect(); + write!(f, "; the adapters are {}; {INFO_HINT}", listed.join(", ")) + } +} + +impl std::error::Error for Refusal {} + +/// The index of the adapter `spec` names in `list`. +pub fn resolve(spec: &Spec, list: &[Listed]) -> Result { + let refuse = |why: Why| Refusal { + spec: spec.clone(), + why, + adapters: list.to_vec(), + }; + let raw = spec.raw.trim(); + if raw.is_empty() { + return Err(refuse(Why::Blank)); + } + if list.is_empty() { + return Err(refuse(Why::NoAdapters)); + } + let digits = raw.bytes().all(|b| b.is_ascii_digit()); + if digits { + if let Ok(index) = raw.parse::() { + if index < list.len() { + return Ok(index); + } + } + } + let wanted = raw.to_lowercase(); + let named: Vec = list + .iter() + .enumerate() + .filter(|(_, one)| one.name.to_lowercase().contains(&wanted)) + .map(|(index, _)| index) + .collect(); + let Some(&first) = named.first() else { + return Err(refuse(if digits { + Why::OutOfRange + } else { + Why::NoMatch + })); + }; + let backend = list[first].backend; + let same: Vec = named + .into_iter() + .filter(|&index| list[index].backend == backend) + .collect(); + match same.as_slice() { + [one] => Ok(*one), + _ => Err(refuse(Why::Ambiguous(same))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn adapter(name: &str, backend: &'static str, kind: Kind) -> Listed { + Listed { + name: name.to_string(), + backend, + vendor: 0, + device: 0, + kind, + } + } + + fn machine() -> Vec { + vec![ + adapter("Microsoft Basic Render Driver", "dx12", Kind::Software), + adapter("AMD Radeon(TM) Graphics", "dx12", Kind::Integrated), + adapter("NVIDIA GeForce RTX 4090", "dx12", Kind::Discrete), + adapter("NVIDIA GeForce RTX 4090", "vulkan", Kind::Discrete), + adapter("llvmpipe (LLVM 17.0.6, 256 bits)", "vulkan", Kind::Software), + ] + } + + fn flag(raw: &str) -> Spec { + Spec { + raw: raw.to_string(), + from_env: false, + } + } + + #[test] + fn the_default_is_the_best_kind_of_the_first_backend() { + assert_eq!(default_index(&machine()), Some(2)); + assert_eq!(default_index(&machine()[3..]), Some(0)); + assert_eq!(default_index(&machine()[..2]), Some(1)); + assert_eq!(default_index(&[]), None); + } + + #[test] + fn a_spec_is_an_index_or_a_name_looked_for_backend_by_backend() { + let list = machine(); + for (raw, index) in [ + ("0", 0), + ("3", 3), + (" 4 ", 4), + ("radeon", 1), + ("4090", 2), + ("17", 4), + ("RTX", 2), + ("Basic Render", 0), + ("llvmpipe", 4), + ] { + assert_eq!(resolve(&flag(raw), &list), Ok(index), "{raw}"); + } + } + + #[test] + fn a_spec_naming_no_one_adapter_is_refused_with_the_list() { + let list = machine(); + let names = "the adapters are 0 Microsoft Basic Render Driver (dx12), \ + 1 AMD Radeon(TM) Graphics (dx12), 2 NVIDIA GeForce RTX 4090 (dx12), \ + 3 NVIDIA GeForce RTX 4090 (vulkan), 4 llvmpipe (LLVM 17.0.6, 256 bits) (vulkan); \ + `ffrwd-wasm --gpu-info` lists them"; + let env = Spec { + raw: "intel".to_string(), + from_env: true, + }; + for (spec, list, message) in [ + ( + flag("arc"), + &list[..], + format!("-gpu-adapter arc: no adapter's name contains 'arc'; {names}"), + ), + ( + env, + &list[..], + format!("FFRWD_GPU_ADAPTER=intel: no adapter's name contains 'intel'; {names}"), + ), + ( + flag("8"), + &list[..], + format!( + "-gpu-adapter 8: there is no adapter 8, they are numbered from 0 to 4, and \ + no adapter's name contains '8'; {names}" + ), + ), + ( + flag("99999999999999999999999"), + &list[..], + format!( + "-gpu-adapter 99999999999999999999999: there is no adapter \ + 99999999999999999999999, they are numbered from 0 to 4, and no adapter's \ + name contains '99999999999999999999999'; {names}" + ), + ), + ( + flag("geforce"), + &[list[2].clone(), list[2].clone(), list[3].clone()][..], + "-gpu-adapter geforce: 2 dx12 adapters' names contain 'geforce'; name one \ + by its index; the adapters are 0 NVIDIA GeForce RTX 4090 (dx12), \ + 1 NVIDIA GeForce RTX 4090 (dx12), 2 NVIDIA GeForce RTX 4090 (vulkan); \ + `ffrwd-wasm --gpu-info` lists them" + .to_string(), + ), + ( + flag(" "), + &list[..], + "-gpu-adapter : names no adapter; `ffrwd-wasm --gpu-info` lists them".to_string(), + ), + ( + flag("0"), + &[][..], + "-gpu-adapter 0: this machine has no GPU adapter".to_string(), + ), + ] { + let refused = resolve(&spec, list).expect_err(&spec.raw); + assert_eq!(refused.to_string(), message); + } + } +} diff --git a/sidecar/wasm-gpu/README.md b/sidecar/wasm-gpu/README.md index 496e8b9..6c99ce2 100644 --- a/sidecar/wasm-gpu/README.md +++ b/sidecar/wasm-gpu/README.md @@ -64,7 +64,12 @@ place of `ffrwd:av`'s `encode` and `decode`, which still serve elsewhere. `sidecar/modules/testcodec`, built as `testcodec_gpu`, is such a module. A module's `request-device` is answered with the sidecar's one device, -and a feature that device lacks is refused. On Windows the device is +and a feature that device lacks is refused. The device is on the adapter +wgpu picks for high performance, unless `-gpu-adapter` or +`FFRWD_GPU_ADAPTER` names another (`sidecar/NODE-CLI.md`, "The GPU +adapter"); a module's `request-adapter` answers that adapter whatever it +asks for, and the decoder, the encoders and the filters that hold +pictures on the GPU use the same device. On Windows the device is D3D12, whose `subgroups` need DXC: the release wheel's sidecar for Windows x86_64 has it compiled in, and any other build needs `dxcompiler.dll` (the Windows SDK's, or the DirectX Shader Compiler