diff --git a/dikte/cli.py b/dikte/cli.py index 0900c76..01eab45 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -854,23 +854,32 @@ def _local_where(entry): return where + (f" ({detail})" if detail else "") -def _local_note(entry): - """Why the card is not in use, for a setup that asked for it and got none.""" +def _local_note(name, entry): + """Why the card is not in use, for a setup that asked for it and got none. + + Three answers, because the thing to do about it differs: no card here, a + build that could never use one, and a build Dikte downloaded that could + never use one, which is the only case with a fix worth naming. + """ if not entry.get("gpu_wanted"): return "" if ggml.accel_kind({**entry, "running": True}) != "cpu": return "" - return (" - the graphics card is switched on and this build carries none" - if ggml.cpu_only_build(entry) - else " - the graphics card is switched on and none was found") + if not ggml.cpu_only_build(entry): + return " - the graphics card is switched on and none was found" + if entry.get("downloaded"): + return (f" - the graphics card is switched on and the downloaded build " + f"has no GPU backend; a {name}-server on your system would be " + f"used ahead of it") + return " - the graphics card is switched on and this build carries none" -def _local_line(entry): +def _local_line(name, entry): if not entry.get("running"): return "not loaded" model = entry.get("model") or "" return (f"loaded on {_local_where(entry)}" - + (f", {model}" if model else "") + _local_note(entry)) + + (f", {model}" if model else "") + _local_note(name, entry)) def _last_local(conf): @@ -882,12 +891,17 @@ def _last_local(conf): rather than a reading of a live one. """ rows = {} - for program, used, gpu in ( + for program, used, gpu, custom in ( (ggml.WHISPER, conf["transcribe_provider"] == "local", - bool(conf["local_gpu"])), - (ggml.LLAMA, conf.uses_local_llm(), bool(conf["local_llm_gpu"]))): + bool(conf["local_gpu"]), conf["local_binary"]), + (ggml.LLAMA, conf.uses_local_llm(), bool(conf["local_llm_gpu"]), + conf["local_llm_binary"])): accel = ggml.last_accel(program) + binary = ggml.program_path(program, custom) rows[program.name] = { + # Which copy would run now, since the log does not say which one + # wrote it; the advice only differs for Dikte's own download. + "downloaded": ggml.is_downloaded(binary), # Whether one ever started here at all, which the backend cannot # say on its own: a server that ran and named no backend and one # that never ran both leave it empty. @@ -922,7 +936,7 @@ def cmd_status(opts): # from an instance too old to have been asked. for name, entry in (reply.get("local") or {}).items(): if entry.get("used") or entry.get("running"): - lines.append(f"{name + ':':11}{_local_line(entry)}") + lines.append(f"{name + ':':11}{_local_line(name, entry)}") return out(opts, reply, "\n".join(lines)) @@ -1016,12 +1030,12 @@ def cmd_doctor(opts): lines.append(f"· {name:14} the running instance is too old to say; " f"reload it with: dikte restart") elif entry.get("running"): - lines.append(f"✓ {name:14} {_local_line(entry)}") + lines.append(f"✓ {name:14} {_local_line(name, entry)}") elif live: lines.append(f"· {name:14} not loaded") elif entry.get("backend"): lines.append(f"· {name:14} last run on " - f"{_local_where(entry)}{_local_note(entry)}") + f"{_local_where(entry)}{_local_note(name, entry)}") elif entry.get("ran"): lines.append(f"· {name:14} last run said nothing about what it " f"was running on") diff --git a/dikte/ggml.py b/dikte/ggml.py index 51229ef..f1356a4 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -670,19 +670,23 @@ def _enumerated(text, backend, index): def _card_name(text, handle): """The card behind a ggml handle like "Vulkan0", named the way it sells. - Three places carry a name and only the third is always meaningful: the - handle says which slot, whisper's own device listing says whichever the - backend reported, and the backend's enumeration says what the thing is - called. Whichever of them is not just the handle again wins. + The backend's own enumeration is asked first because it is the only listing + indexed the way the handle is. whisper numbers every device it can see in + one sequence, so the Vulkan card can be its device 1 while being Vulkan0, + and reading that row by the handle's digit names whatever else was in slot + zero. The handle itself is never an answer: it says which slot, and a line + reading "Vulkan, Vulkan0" tells nobody which card is doing the work. """ parts = _HANDLE.match(handle or "") backend, index = (parts.group(1), parts.group(2) or "0") if parts else ("", "0") - for slot, name, _kind in _WHISPER_DEVICE.findall(text): - if slot == index and name.strip() and not _BARE.match(name.strip()): - return name.strip() - # The handle itself is not an answer: it says which slot, and a line - # reading "Vulkan, Vulkan0" tells nobody which card is doing the work. - return _enumerated(text, backend, index) + found = _enumerated(text, backend, index) + if found: + return found + # Nothing enumerated: whisper's own listing is all there is, and a single + # named device in it can only be the one that ran. + named = [name.strip() for _slot, name, kind in _WHISPER_DEVICE.findall(text) + if kind != "0" and name.strip() and not _BARE.match(name.strip())] + return named[0] if len(named) == 1 else "" # The startup chatter is the first few hundred lines; the rest of the file is a # line per request and grows for as long as the server lives. @@ -805,6 +809,10 @@ class Server: # first, and reporting the new model beside the old process would name # a model this server is not running. self._live = {} + # The copy that is running, resolved rather than configured: the + # setting is usually empty, meaning whichever one program_path finds, + # and which one it found decides what advice is worth giving. + self._binary = "" # The pid this instance last wrote to its pid file, so _forget never # removes a file some other Dikte wrote after us. self._pid = 0 @@ -864,6 +872,8 @@ class Server: "device": accel.device, "layers": accel.layers, "available": list(accel.available), + "binary": self._binary if up else "", + "downloaded": bool(up and is_downloaded(self._binary)), } def error(self): @@ -891,6 +901,8 @@ class Server: with self._lock: self._proc, self._port, self._log, self._key = proc, port, log, key self._accel, self._live = accel, settings + self._binary = program_path(self.program, + settings.get("binary", "")) return self.base_url() def _current_url(self): @@ -1019,6 +1031,7 @@ class Server: proc, self._proc = self._proc, None self._port, self._log, self._key = 0, "", None self._accel, self._live = NO_ACCEL, {} + self._binary = "" self._kill(proc, gently=True) if proc is not None: self._forget() @@ -1207,6 +1220,11 @@ def state(): return {server.program.name: server.state() for server in SERVERS} +def is_downloaded(path): + """Whether `path` is a copy Dikte fetched rather than one the system has.""" + return bool(path) and _under(path, BIN_DIR) + + def server_log(program): """Where this program's server writes, which outlives the process.""" return DATA_DIR / f"{program.name}-server.log" diff --git a/dikte/i18n.py b/dikte/i18n.py index 3619797..6f04115 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -783,12 +783,16 @@ TR = { "Loaded on the graphics card ({detail}).": "Ekran kartına yüklendi ({detail}).", "Loaded on the processor ({detail}).": "İşlemciye yüklendi ({detail}).", - "Loaded on the processor: this build carries no graphics backend, so the " - "box above cannot change that. A build from your distribution, or one you " - "point at above, may reach the card.": - "İşlemciye yüklendi: bu sürümde ekran kartı arka ucu yok, yukarıdaki " - "kutu bunu değiştiremez. Dağıtımınızın kendi sürümü ya da yukarıda yol " - "göstereceğiniz bir kopya karta ulaşabilir.", + "Loaded on the processor: the build Dikte downloaded carries no graphics " + "backend. A {binary} from your own system is used ahead of it, so " + "installing one is what reaches the card.": + "İşlemciye yüklendi: Dikte'nin indirdiği sürümde ekran kartı arka ucu " + "yok. Sistemdeki bir {binary} indirilene tercih edilir, yani karta " + "ulaşmanın yolu onu kurmak.", + "Loaded on the processor: this {binary} carries no graphics backend, so " + "the box above cannot change that.": + "İşlemciye yüklendi: bu {binary} ekran kartı arka ucu taşımıyor, " + "yukarıdaki kutu bunu değiştiremez.", "Loaded on the processor: the graphics card is switched on, but none was " "found.": "İşlemciye yüklendi: ekran kartı açık, ama bulunamadı.", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index ec17495..02d1671 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -691,11 +691,13 @@ class SettingsWindow(QDialog): def _show_local_state(self): """What each model on this machine is loaded on, as it is now.""" local = ggml.state() - self.local_state.setText(self._local_state_text(local.get("whisper", {}))) - self.local_llm_state.setText(self._local_state_text(local.get("llama", {}))) + self.local_state.setText( + self._local_state_text(ggml.WHISPER, local.get("whisper", {}))) + self.local_llm_state.setText( + self._local_state_text(ggml.LLAMA, local.get("llama", {}))) @staticmethod - def _local_state_text(entry): + def _local_state_text(program, entry): """One line: whether the model is loaded, and what it ended up on. Four answers rather than two, because "could not tell" is a real one: a @@ -714,13 +716,20 @@ class SettingsWindow(QDialog): return t("Loaded on the graphics card ({detail}).", detail=detail) if not entry.get("gpu_wanted"): return t("Loaded on the processor ({detail}).", detail=detail) - if ggml.cpu_only_build(entry): - return t("Loaded on the processor: this build carries no graphics " - "backend, so the box above cannot change that. A build " - "from your distribution, or one you point at above, may " - "reach the card.") - return t("Loaded on the processor: the graphics card is switched on, " - "but none was found.") + if not ggml.cpu_only_build(entry): + return t("Loaded on the processor: the graphics card is switched " + "on, but none was found.") + # The one case with a fix worth naming: whisper.cpp publishes no build + # that can reach a card on most systems, and Dikte runs a system copy + # ahead of its own, so installing one is the whole remedy. + if entry.get("downloaded"): + return t("Loaded on the processor: the build Dikte downloaded " + "carries no graphics backend. A {binary} from your own " + "system is used ahead of it, so installing one is what " + "reaches the card.", binary=program.binary) + return t("Loaded on the processor: this {binary} carries no graphics " + "backend, so the box above cannot change that.", + binary=program.binary) def _scrolled(self, page): """A tab that scrolls instead of growing the window to fit.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 93ca3f2..5c9e70e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -806,6 +806,12 @@ class LocalModels(DikteTest): self.assertIn("loaded on the processor", line) self.assertIn("this build carries none", line) + def test_the_downloaded_build_is_told_where_a_working_one_comes_from(self): + line = self.status({"whisper": self.entry( + backend="CPU", device="CPU", available=["CPU"], downloaded=True)}) + self.assertIn("downloaded build has no GPU backend", line) + self.assertIn("whisper-server on your system", line) + def test_a_card_the_build_could_have_used_says_something_else(self): line = self.status({"whisper": self.entry( backend="CPU", device="CPU", available=["CUDA", "CPU"])}) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 994b6cd..5f7b777 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -608,6 +608,36 @@ load_tensors: offloaded 0/29 layers to GPU """ +# A downloaded processor-only build pointed at the system's Vulkan backend +# through GGML_BACKEND_PATH. whisper numbers every device it can see in one +# sequence, so the card is its device 1 while still being Vulkan0. +WHISPER_LENT_BACKEND = """\ +load_backend: loaded CPU backend from /data/bin/whisper/libggml-cpu-haswell.so +ggml_vulkan: Found 1 Vulkan devices: +ggml_vulkan: 0 = AMD Radeon RX 6600 (RADV NAVI23) (radv) | uma: 0 +load_backend: loaded Vulkan backend from /usr/lib/ggml/libggml-vulkan.so +whisper_model_load: Vulkan0 total size = 189.49 MB +whisper_backend_init_gpu: device 0: CPU (type: 0) +whisper_backend_init_gpu: device 1: Vulkan0 (type: 1) +whisper_backend_init_gpu: found GPU device 1: Vulkan0 (type: 1, cnt: 0) +whisper_backend_init_gpu: using Vulkan0 backend +""" + +# Two cards, and the one that ran is not the one in the slot the handle names. +# Reading whisper's listing by the handle's digit would name the other card. +WHISPER_TWO_CARDS = """\ +ggml_vulkan: Found 1 Vulkan devices: +ggml_vulkan: 0 = AMD Radeon RX 6600 (RADV NAVI23) (radv) | uma: 0 +load_backend: loaded CUDA backend from /opt/whisper/libggml-cuda.so +load_backend: loaded Vulkan backend from /opt/whisper/libggml-vulkan.so +load_backend: loaded CPU backend from /opt/whisper/libggml-cpu-haswell.so +whisper_model_load: Vulkan0 total size = 189.49 MB +whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 (type: 1) +whisper_backend_init_gpu: device 1: Vulkan0 (type: 1) +whisper_backend_init_gpu: using Vulkan0 backend +""" + + class WhatItRunsOn(Local): """Reading the backend back out of the log the server wrote.""" @@ -648,6 +678,18 @@ class WhatItRunsOn(Local): self.assertNotIn("(radv)", self.read(ggml.WHISPER, WHISPER_VULKAN).device) + def test_a_card_numbered_one_way_and_handled_another_is_still_named(self): + accel = self.read(ggml.WHISPER, WHISPER_LENT_BACKEND) + self.assertEqual(accel.backend, "Vulkan") + self.assertEqual(accel.device, "AMD Radeon RX 6600 (RADV NAVI23)") + + def test_the_card_named_is_the_one_the_handle_belongs_to(self): + # whisper's device 0 is the other card. The handle is Vulkan0, and + # Vulkan's own device 0 is the AMD one. + accel = self.read(ggml.WHISPER, WHISPER_TWO_CARDS) + self.assertEqual(accel.device, "AMD Radeon RX 6600 (RADV NAVI23)") + self.assertNotIn("NVIDIA", ggml.accel_detail(accel)) + def test_a_card_that_failed_to_start_is_not_a_card_in_use(self): # It was listed, it was tried, it did not work, and whisper went on # without it. The listing alone would have called this a graphics card. @@ -694,6 +736,14 @@ class WhatItRunsOn(Local): self.assertEqual(ggml.accel_detail(self.read(ggml.WHISPER, WHISPER_CPU)), "CPU") + def test_which_copy_is_running_decides_what_advice_is_worth_giving(self): + mine = ggml.BIN_DIR / "whisper" / "b1" / "whisper-server" + mine.parent.mkdir(parents=True, exist_ok=True) + mine.write_text("#!/bin/sh\n") + self.assertTrue(ggml.is_downloaded(str(mine))) + self.assertFalse(ggml.is_downloaded("/usr/bin/whisper-server")) + self.assertFalse(ggml.is_downloaded("")) + def test_nothing_is_running_is_not_a_backend(self): self.assertEqual(ggml.accel_kind({"running": False, "backend": "CUDA"}), "off") diff --git a/tests/test_ui.py b/tests/test_ui.py index d87a54c..350c1d1 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1205,6 +1205,20 @@ class LocalModels(DikteTest): self.assertIn("processor", whisper) self.assertIn("no graphics backend", whisper) + def test_the_downloaded_build_is_told_what_to_install(self): + # whisper.cpp publishes nothing that reaches a card on this system, and + # a copy on the PATH is used ahead of Dikte's own, so that is the fix. + whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"], + downloaded=True) + self.assertIn("Dikte downloaded", whisper) + self.assertIn("whisper-server", whisper) + + def test_a_system_build_is_not_told_to_install_itself(self): + whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"], + downloaded=False) + self.assertIn("no graphics backend", whisper) + self.assertNotIn("Dikte downloaded", whisper) + def test_a_build_that_could_have_used_one_says_the_other_thing(self): whisper, _ = self.shown(backend="CPU", device="CPU", available=["CUDA", "CPU"])