mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Give the memory back when a local model has been sitting unused
A whisper.cpp or llama.cpp server started for one dictation stayed loaded
until Dikte quit. On this machine that is 1.3 GB of VRAM for large-v3 plus
whatever the cleanup LLM takes, held all day between dictations that last
seconds.
Each Server now carries an idle window. A watcher thread per launch stops the
server once nothing has asked it anything for that long, and the next request
loads it again through serve(), which already starts what is not running.
Settings has one checkbox and one number for both servers, on by default at ten
minutes, and it only appears for a machine that runs a model here. The tray menu
says which models are loaded and offers to unload them now.
Two things the clock alone gets wrong, both held off by a count of requests in
flight:
* A file or a meeting is one address lookup and then minutes of work, which
to a clock started at the lookup looks exactly like a model nobody wants.
api.py and cleanup.py hold the count for the length of the request.
* The count must survive the start it triggered. cleanup._local takes the
hold and only then asks for the address, so a cold start happens inside it;
neither serve() nor _stop_now() resets the count any more.
Unloading by hand runs on the interface's thread, so it asks for the start lock
rather than waiting on it: a model still being read in is refused, the way one
in the middle of a request is, instead of freezing the window for as long as
the load takes.
This commit is contained in:
@@ -161,7 +161,8 @@ running.
|
|||||||
- **It all runs on this machine by default.** Speech to text on whisper.cpp and
|
- **It all runs on this machine by default.** Speech to text on whisper.cpp and
|
||||||
cleanup on llama.cpp, neither installed beforehand: the settings window fetches
|
cleanup on llama.cpp, neither installed beforehand: the settings window fetches
|
||||||
the program and the model, verifies the sha256 and refuses a download published
|
the program and the model, verifies the sha256 and refuses a download published
|
||||||
without one, then keeps a server alive while you dictate. The model list is
|
without one, then keeps a server alive while you dictate and hands the memory
|
||||||
|
back once it has sat unused for ten minutes. The model list is
|
||||||
grouped by model rather than by file size, and the row this machine's memory
|
grouped by model rather than by file size, and the row this machine's memory
|
||||||
and graphics can take is marked. The graphics card is
|
and graphics can take is marked. The graphics card is
|
||||||
reached through CUDA, ROCm or Vulkan where the build allows. No key, no
|
reached through CUDA, ROCm or Vulkan where the build allows. No key, no
|
||||||
|
|||||||
+2
-1
@@ -158,7 +158,8 @@ olmasını ister.
|
|||||||
whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez:
|
whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez:
|
||||||
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
|
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
|
||||||
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
|
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
|
||||||
ayakta tutar. Model listesi dosya boyutuna değil modele göre gruplanır ve bu
|
ayakta tutar ve on dakika kullanılmayan modelin belleğini geri verir. Model
|
||||||
|
listesi dosya boyutuna değil modele göre gruplanır ve bu
|
||||||
makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme
|
makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme
|
||||||
destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
|
destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
|
||||||
üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok.
|
üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok.
|
||||||
|
|||||||
@@ -387,7 +387,12 @@ def _transcribe_request(target, audio_path, language, prompt, response_format,
|
|||||||
if granularity:
|
if granularity:
|
||||||
fields.append(("timestamp_granularities[]", granularity))
|
fields.append(("timestamp_granularities[]", granularity))
|
||||||
body, ctype = _multipart(fields, "file", audio_path)
|
body, ctype = _multipart(fields, "file", audio_path)
|
||||||
|
# An hour of meeting takes the local server a while, and the idle unload has
|
||||||
|
# to count that as the model being used rather than as nobody wanting it.
|
||||||
|
held = (ggml.whisper.busy() if target.provider == "local"
|
||||||
|
else contextlib.nullcontext())
|
||||||
try:
|
try:
|
||||||
|
with held:
|
||||||
return _request(
|
return _request(
|
||||||
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
|
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
|
||||||
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ class Dikte:
|
|||||||
self.update_action.triggered.connect(self.open_release_page)
|
self.update_action.triggered.connect(self.open_release_page)
|
||||||
self.menu.addAction(self.update_action)
|
self.menu.addAction(self.update_action)
|
||||||
|
|
||||||
|
# Named in _refresh_tray, which is where the loaded models are known.
|
||||||
|
self.unload_action = QAction("", self.menu)
|
||||||
|
self.unload_action.triggered.connect(self.unload_models)
|
||||||
|
self.menu.addAction(self.unload_action)
|
||||||
|
|
||||||
self.settings_action = QAction(t("Settings…"), self.menu)
|
self.settings_action = QAction(t("Settings…"), self.menu)
|
||||||
self.settings_action.triggered.connect(self.open_settings)
|
self.settings_action.triggered.connect(self.open_settings)
|
||||||
self.menu.addAction(self.settings_action)
|
self.menu.addAction(self.settings_action)
|
||||||
@@ -303,6 +308,10 @@ class Dikte:
|
|||||||
self.menu.addAction(self.quit_action)
|
self.menu.addAction(self.quit_action)
|
||||||
|
|
||||||
self.tray.setContextMenu(self.menu)
|
self.tray.setContextMenu(self.menu)
|
||||||
|
# A model unloads itself in the background, so what the unload row says
|
||||||
|
# goes stale between state changes. Refreshed as the menu opens, which
|
||||||
|
# is the only moment anybody reads it.
|
||||||
|
self.menu.aboutToShow.connect(self._refresh_tray)
|
||||||
self.tray.setToolTip(t("Dikte: ready"))
|
self.tray.setToolTip(t("Dikte: ready"))
|
||||||
self.tray.activated.connect(self._tray_clicked)
|
self.tray.activated.connect(self._tray_clicked)
|
||||||
self._refresh_update()
|
self._refresh_update()
|
||||||
@@ -398,6 +407,21 @@ class Dikte:
|
|||||||
)
|
)
|
||||||
self.ask_cancel_action.setEnabled(self.ask_state == BUSY)
|
self.ask_cancel_action.setEnabled(self.ask_state == BUSY)
|
||||||
|
|
||||||
|
# A local model holds its memory whether or not anything is using it, so
|
||||||
|
# the menu says which of the two are loaded and offers to give it back.
|
||||||
|
# Hidden on a machine that runs neither: there is nothing to unload and
|
||||||
|
# nothing to report.
|
||||||
|
loaded = [server for server in (ggml.whisper, ggml.llm) if server.running]
|
||||||
|
self.unload_action.setVisible(
|
||||||
|
self.conf["transcribe_provider"] == "local" or self.conf.uses_local_llm()
|
||||||
|
)
|
||||||
|
self.unload_action.setText(
|
||||||
|
t("Unload the models") if len(loaded) > 1
|
||||||
|
else t("Unload the model") if loaded
|
||||||
|
else t("No model loaded")
|
||||||
|
)
|
||||||
|
self.unload_action.setEnabled(bool(loaded))
|
||||||
|
|
||||||
# The agent speaks through the icon only when dictation has nothing to
|
# The agent speaks through the icon only when dictation has nothing to
|
||||||
# say, since dictation is the one being waited on in front of a screen.
|
# say, since dictation is the one being waited on in front of a screen.
|
||||||
if self.state == IDLE and self.ask_state != IDLE:
|
if self.state == IDLE and self.ask_state != IDLE:
|
||||||
@@ -1209,6 +1233,18 @@ class Dikte:
|
|||||||
QDesktopServices.openUrl(
|
QDesktopServices.openUrl(
|
||||||
QUrl(release.url if release is not None else update.RELEASES_PAGE))
|
QUrl(release.url if release is not None else update.RELEASES_PAGE))
|
||||||
|
|
||||||
|
def unload_models(self):
|
||||||
|
"""Give the memory back now rather than when the idle window closes."""
|
||||||
|
held = [server for server in (ggml.whisper, ggml.llm)
|
||||||
|
if not server.unload()]
|
||||||
|
self._refresh_tray()
|
||||||
|
if held:
|
||||||
|
self.tray.showMessage(
|
||||||
|
"Dikte",
|
||||||
|
t("A model is loading or answering right now. Try again in a "
|
||||||
|
"moment."),
|
||||||
|
QSystemTrayIcon.MessageIcon.Information, 5000)
|
||||||
|
|
||||||
# ---- settings ---------------------------------------------------------
|
# ---- settings ---------------------------------------------------------
|
||||||
|
|
||||||
def open_settings(self):
|
def open_settings(self):
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ def _local(text, conf, system_prompt, timeout, aborter=None):
|
|||||||
"""
|
"""
|
||||||
service = t("Local model")
|
service = t("Local model")
|
||||||
try:
|
try:
|
||||||
|
# Held for the length of the request so that the idle unload does not
|
||||||
|
# take the model away from a block still being cleaned up.
|
||||||
|
with ggml.llm.busy():
|
||||||
return api.cleanup(
|
return api.cleanup(
|
||||||
text, "", conf["local_llm_model"], system_prompt,
|
text, "", conf["local_llm_model"], system_prompt,
|
||||||
reasoning=conf["local_llm_reasoning"],
|
reasoning=conf["local_llm_reasoning"],
|
||||||
|
|||||||
@@ -442,6 +442,15 @@ DEFAULTS = {
|
|||||||
# Off rather than empty: a model trained to think will, and 300 tokens of
|
# Off rather than empty: a model trained to think will, and 300 tokens of
|
||||||
# reasoning about a comma is 300 tokens of waiting.
|
# reasoning about a comma is 300 tokens of waiting.
|
||||||
"local_llm_reasoning": "none",
|
"local_llm_reasoning": "none",
|
||||||
|
|
||||||
|
# --- what happens to both of them when nothing is using them -------------
|
||||||
|
# One pair for the two servers rather than a pair each: what is being
|
||||||
|
# decided is whether a machine keeps gigabytes tied up between dictations,
|
||||||
|
# and nobody wants that answered one model at a time. On by default because
|
||||||
|
# a reload costs seconds and the memory costs the rest of the desktop.
|
||||||
|
"local_idle_unload": True,
|
||||||
|
"local_idle_minutes": 10,
|
||||||
|
|
||||||
"cleanup_prompt": "", # empty -> language-specific default
|
"cleanup_prompt": "", # empty -> language-specific default
|
||||||
"auto_paste": True,
|
"auto_paste": True,
|
||||||
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
|
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
|
||||||
@@ -710,6 +719,14 @@ class Config:
|
|||||||
binary=self["local_llm_binary"],
|
binary=self["local_llm_binary"],
|
||||||
context=int(self["local_llm_context"]),
|
context=int(self["local_llm_context"]),
|
||||||
)
|
)
|
||||||
|
ggml.whisper.set_idle(self.idle_seconds())
|
||||||
|
ggml.llm.set_idle(self.idle_seconds())
|
||||||
|
|
||||||
|
def idle_seconds(self):
|
||||||
|
"""How long a loaded model may sit unused. 0 means it is kept."""
|
||||||
|
if not self["local_idle_unload"]:
|
||||||
|
return 0
|
||||||
|
return max(1, int(self["local_idle_minutes"])) * 60
|
||||||
|
|
||||||
def uses_local_llm(self):
|
def uses_local_llm(self):
|
||||||
"""Whether anything is set to run the local cleanup model."""
|
"""Whether anything is set to run the local cleanup model."""
|
||||||
|
|||||||
+128
-2
@@ -26,6 +26,7 @@ interface already knows how to show.
|
|||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import collections
|
import collections
|
||||||
|
import contextlib
|
||||||
import ctypes
|
import ctypes
|
||||||
import ctypes.util
|
import ctypes.util
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -68,6 +69,10 @@ STARTUP_TIMEOUT = 180.0
|
|||||||
# to load takes longer than this to be read in first. The line between "worth
|
# to load takes longer than this to be read in first. The line between "worth
|
||||||
# another port" and "would fail the same way again" is drawn on time.
|
# another port" and "would fail the same way again" is drawn on time.
|
||||||
EARLY_EXIT_WINDOW = 5.0
|
EARLY_EXIT_WINDOW = 5.0
|
||||||
|
# How often the watcher looks at a model it has been asked to unload when idle.
|
||||||
|
# Short next to any window worth setting, so the memory goes back within seconds
|
||||||
|
# of the window closing rather than a minute after it.
|
||||||
|
IDLE_CHECK_SECONDS = 5.0
|
||||||
DOWNLOAD_CHUNK = 1 << 20
|
DOWNLOAD_CHUNK = 1 << 20
|
||||||
|
|
||||||
# `health` is the path that answers only once the model is in memory. whisper
|
# `health` is the path that answers only once the model is in memory. whisper
|
||||||
@@ -1116,6 +1121,15 @@ class Server:
|
|||||||
# The pid this instance last wrote to its pid file, so _forget never
|
# The pid this instance last wrote to its pid file, so _forget never
|
||||||
# removes a file some other Dikte wrote after us.
|
# removes a file some other Dikte wrote after us.
|
||||||
self._pid = 0
|
self._pid = 0
|
||||||
|
# The idle unload. `_idle` is the window in seconds, zero meaning the
|
||||||
|
# model stays loaded until something else stops it; `_used` is when the
|
||||||
|
# address was last handed out or a request last finished; `_busy` counts
|
||||||
|
# the requests still in flight. The count is there because a file being
|
||||||
|
# transcribed is one address lookup and then minutes of work, which to a
|
||||||
|
# clock started at the lookup looks exactly like a model nobody wants.
|
||||||
|
self._idle = 0.0
|
||||||
|
self._used = 0.0
|
||||||
|
self._busy = 0
|
||||||
|
|
||||||
# ---- settings --------------------------------------------------------
|
# ---- settings --------------------------------------------------------
|
||||||
|
|
||||||
@@ -1133,6 +1147,23 @@ class Server:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return dict(self._settings)
|
return dict(self._settings)
|
||||||
|
|
||||||
|
def set_idle(self, seconds):
|
||||||
|
"""How long a loaded model may sit unused before the memory goes back.
|
||||||
|
|
||||||
|
Deliberately not one of the settings above: those describe the server
|
||||||
|
that is running, and changing one has to restart it. This describes how
|
||||||
|
long to keep it, which the server it is applied to never needs to know.
|
||||||
|
Zero keeps the model until something else stops it.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._idle = max(0.0, float(seconds))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def idle(self):
|
||||||
|
"""The window `set_idle` was last given, in seconds."""
|
||||||
|
with self._lock:
|
||||||
|
return self._idle
|
||||||
|
|
||||||
def _settings_key(self):
|
def _settings_key(self):
|
||||||
"""What a running server would have to be restarted for."""
|
"""What a running server would have to be restarted for."""
|
||||||
return json.dumps(self._settings, sort_keys=True, default=str)
|
return json.dumps(self._settings, sort_keys=True, default=str)
|
||||||
@@ -1172,13 +1203,83 @@ class Server:
|
|||||||
proc, port, log = self._launch(settings)
|
proc, port, log = self._launch(settings)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._proc, self._port, self._log, self._key = proc, port, log, key
|
self._proc, self._port, self._log, self._key = proc, port, log, key
|
||||||
|
# Only the clock. The count is not this launch's to reset: a
|
||||||
|
# caller that took a hold and then asked for the address, which
|
||||||
|
# is what the local cleanup does, would have it wiped here and
|
||||||
|
# spend the whole request unprotected.
|
||||||
|
self._used = time.monotonic()
|
||||||
|
threading.Thread(target=self._watch, args=(proc,), daemon=True).start()
|
||||||
return self.base_url()
|
return self.base_url()
|
||||||
|
|
||||||
def _current_url(self):
|
def _current_url(self):
|
||||||
|
"""The address of a server running the current settings, or "".
|
||||||
|
|
||||||
|
Asking counts as using it. Everything that asks is about to send a
|
||||||
|
request, and the idle watcher reads the same clock, so the stamp has to
|
||||||
|
be set here rather than where the answer comes back.
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
up = self._proc is not None and self._proc.poll() is None
|
up = self._proc is not None and self._proc.poll() is None
|
||||||
return (f"http://{HOST}:{self._port}/v1"
|
if not (up and self._key == self._settings_key()):
|
||||||
if up and self._key == self._settings_key() else "")
|
return ""
|
||||||
|
self._used = time.monotonic()
|
||||||
|
return f"http://{HOST}:{self._port}/v1"
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def busy(self):
|
||||||
|
"""Hold the model for the length of one request.
|
||||||
|
|
||||||
|
A dictation is over a second after the address was handed out, but a
|
||||||
|
file is minutes of it, and an hour of meeting is longer still. Without
|
||||||
|
the count the watcher would unload the model out from under the request
|
||||||
|
that started it.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._busy += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
# Nothing else moves the count, so every hold that was taken is
|
||||||
|
# given back here and it stays balanced across a restart. A hold
|
||||||
|
# outliving the server it was taken against only keeps the next
|
||||||
|
# one loaded a moment longer, which is the safe way round.
|
||||||
|
self._busy -= 1
|
||||||
|
self._used = time.monotonic()
|
||||||
|
|
||||||
|
def _idle_now(self, proc):
|
||||||
|
"""Whether `proc` is still ours and has been sitting unused long enough."""
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is not proc or not self._idle or self._busy:
|
||||||
|
return False
|
||||||
|
return time.monotonic() - self._used >= self._idle
|
||||||
|
|
||||||
|
def _watch(self, proc):
|
||||||
|
"""Give the memory back when nothing has asked anything for a while.
|
||||||
|
|
||||||
|
One thread per launch, holding the process it was started for, so that a
|
||||||
|
server stopped and started again is watched by the new thread alone and
|
||||||
|
this one leaves on the first pass that finds its own process gone.
|
||||||
|
|
||||||
|
Started whatever the window is, zero included: turning the unload on in
|
||||||
|
Settings has to reach a model that is already loaded, and a thread that
|
||||||
|
wakes every few seconds to read one number is cheaper than the machinery
|
||||||
|
for starting one later.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
time.sleep(IDLE_CHECK_SECONDS)
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is not proc:
|
||||||
|
return # stopped, or replaced by a later launch
|
||||||
|
if not self._idle_now(proc):
|
||||||
|
continue
|
||||||
|
with self._starting:
|
||||||
|
# Asked once more under the lock a start has to take. An address
|
||||||
|
# handed out while this thread waited its turn stamps _used, and
|
||||||
|
# the request behind it must not arrive at a server killed here.
|
||||||
|
if self._idle_now(proc):
|
||||||
|
self._stop_now()
|
||||||
|
return
|
||||||
|
|
||||||
def _launch(self, settings):
|
def _launch(self, settings):
|
||||||
args = self._build(settings) # raises LocalError when unusable
|
args = self._build(settings) # raises LocalError when unusable
|
||||||
@@ -1285,6 +1386,31 @@ class Server:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def unload(self):
|
||||||
|
"""Stop the server unless it is in the middle of something.
|
||||||
|
|
||||||
|
The same rule the idle watcher goes by, taken by hand from the menu, and
|
||||||
|
it says no for the same reason: the memory is worth having back, but not
|
||||||
|
at the price of the dictation waiting on it. A model still being read in
|
||||||
|
counts as in the middle of something too, and that is why the lock is
|
||||||
|
asked for rather than waited on: this runs on the interface's own
|
||||||
|
thread, and a start holds _starting for as long as the load takes, which
|
||||||
|
for a large model on a cold cache is most of a minute. True when nothing
|
||||||
|
is loaded any more, either way.
|
||||||
|
"""
|
||||||
|
if not self._starting.acquire(blocking=False):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is None:
|
||||||
|
return True
|
||||||
|
if self._busy:
|
||||||
|
return False
|
||||||
|
self._stop_now()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
self._starting.release()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
# Taking _starting means a stop cannot slide past a launch in flight:
|
# Taking _starting means a stop cannot slide past a launch in flight:
|
||||||
# serve() finishes registering its child first, and the child is then
|
# serve() finishes registering its child first, and the child is then
|
||||||
|
|||||||
@@ -782,6 +782,26 @@ TR = {
|
|||||||
"On this machine": "Bu makinede",
|
"On this machine": "Bu makinede",
|
||||||
"Use the graphics card": "Ekran kartını kullan",
|
"Use the graphics card": "Ekran kartını kullan",
|
||||||
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
||||||
|
"Models on this machine": "Bu makinedeki modeller",
|
||||||
|
"Unload a model that is sitting unused": "Kullanılmayan modeli bellekten çıkar",
|
||||||
|
"A loaded model holds its memory whether anything is using it or "
|
||||||
|
"not: over a gigabyte for whisper, several for an LLM. Unloading "
|
||||||
|
"gives that back to the rest of the desktop, and the next "
|
||||||
|
"dictation loads it again at the cost of the seconds that takes.":
|
||||||
|
"Yüklü bir model, kullanılsa da kullanılmasa da belleği tutar: whisper "
|
||||||
|
"için bir gigabaytın üzerinde, bir LLM için birkaç gigabayt. Bellekten "
|
||||||
|
"çıkarmak bunu masaüstünün geri kalanına iade eder, sonraki dikte de "
|
||||||
|
"modeli birkaç saniye bekleyerek yeniden yükler.",
|
||||||
|
" minute": " dakika",
|
||||||
|
" minutes": " dakika",
|
||||||
|
"After": "Şu kadar sonra",
|
||||||
|
"Unload the model": "Modeli bellekten çıkar",
|
||||||
|
"Unload the models": "Modelleri bellekten çıkar",
|
||||||
|
"No model loaded": "Yüklü model yok",
|
||||||
|
"A model is loading or answering right now. Try again in a "
|
||||||
|
"moment.":
|
||||||
|
"Bir model şu anda yükleniyor ya da cevap veriyor. Az sonra tekrar "
|
||||||
|
"deneyin.",
|
||||||
"Local whisper": "Yerel whisper",
|
"Local whisper": "Yerel whisper",
|
||||||
"Local model": "Yerel model",
|
"Local model": "Yerel model",
|
||||||
"Not installed.": "Kurulu değil.",
|
"Not installed.": "Kurulu değil.",
|
||||||
|
|||||||
@@ -1309,9 +1309,47 @@ class SettingsWindow(QDialog):
|
|||||||
orr_form.addRow(self.local_llm_options)
|
orr_form.addRow(self.local_llm_options)
|
||||||
|
|
||||||
outer.addWidget(orr)
|
outer.addWidget(orr)
|
||||||
|
|
||||||
|
# One box for both servers rather than a row inside each: what is being
|
||||||
|
# decided is whether this machine keeps gigabytes tied up between
|
||||||
|
# dictations, and that is not a question anybody wants to answer once
|
||||||
|
# per model.
|
||||||
|
self.local_box = QGroupBox(t("Models on this machine"))
|
||||||
|
local_form = QFormLayout(self.local_box)
|
||||||
|
self.local_idle_unload = QCheckBox(t("Unload a model that is sitting unused"))
|
||||||
|
self.local_idle_unload.setToolTip(
|
||||||
|
t("A loaded model holds its memory whether anything is using it or "
|
||||||
|
"not: over a gigabyte for whisper, several for an LLM. Unloading "
|
||||||
|
"gives that back to the rest of the desktop, and the next "
|
||||||
|
"dictation loads it again at the cost of the seconds that takes."))
|
||||||
|
self.local_idle_minutes = QSpinBox()
|
||||||
|
self.local_idle_minutes.setRange(1, 720)
|
||||||
|
self.local_idle_minutes.valueChanged.connect(self._idle_suffix)
|
||||||
|
self._idle_suffix(self.local_idle_minutes.value())
|
||||||
|
self.local_idle_unload.toggled.connect(self.local_idle_minutes.setEnabled)
|
||||||
|
local_form.addRow("", self.local_idle_unload)
|
||||||
|
local_form.addRow(t("After"), self.local_idle_minutes)
|
||||||
|
outer.addWidget(self.local_box)
|
||||||
|
|
||||||
outer.addStretch(1)
|
outer.addStretch(1)
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
def _idle_suffix(self, minutes):
|
||||||
|
"""The spin box's own noun, since its lowest value is one of them.
|
||||||
|
|
||||||
|
Turkish is handed both and translates them the same: a number there is
|
||||||
|
followed by the singular however many it counts.
|
||||||
|
"""
|
||||||
|
self.local_idle_minutes.setSuffix(
|
||||||
|
t(" minute") if minutes == 1 else t(" minutes"))
|
||||||
|
|
||||||
|
def _refresh_local_box(self):
|
||||||
|
"""The idle unload is only on screen when something here runs locally."""
|
||||||
|
self.local_box.setVisible(
|
||||||
|
(self.transcribe_provider.currentData() or "local") == "local"
|
||||||
|
or (self.cleanup_provider.currentData() or "openrouter") == "local"
|
||||||
|
)
|
||||||
|
|
||||||
def _prompt_tab(self):
|
def _prompt_tab(self):
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
layout = QVBoxLayout(page)
|
layout = QVBoxLayout(page)
|
||||||
@@ -2078,6 +2116,9 @@ class SettingsWindow(QDialog):
|
|||||||
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
||||||
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
||||||
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
||||||
|
self.local_idle_unload.setChecked(conf["local_idle_unload"])
|
||||||
|
self.local_idle_minutes.setValue(int(conf["local_idle_minutes"]))
|
||||||
|
self.local_idle_minutes.setEnabled(conf["local_idle_unload"])
|
||||||
# The defaults as they read NOW, kept for the save comparison: after a
|
# The defaults as they read NOW, kept for the save comparison: after a
|
||||||
# language switch the boxes still hold the old language's default, and
|
# language switch the boxes still hold the old language's default, and
|
||||||
# comparing against the new one would store that text as a custom
|
# comparing against the new one would store that text as a custom
|
||||||
@@ -2209,6 +2250,8 @@ class SettingsWindow(QDialog):
|
|||||||
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
||||||
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
||||||
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
||||||
|
conf["local_idle_unload"] = self.local_idle_unload.isChecked()
|
||||||
|
conf["local_idle_minutes"] = self.local_idle_minutes.value()
|
||||||
|
|
||||||
# Store an empty prompt when it matches a default: the one it was
|
# Store an empty prompt when it matches a default: the one it was
|
||||||
# loaded with, or today's (a Reset click in a session that switched
|
# loaded with, or today's (a Reset click in a session that switched
|
||||||
@@ -2351,6 +2394,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.stt_form.setRowVisible(self.transcribe_status, not local)
|
self.stt_form.setRowVisible(self.transcribe_status, not local)
|
||||||
self.stt_form.setRowVisible(self.local_whisper, local)
|
self.stt_form.setRowVisible(self.local_whisper, local)
|
||||||
self.stt_form.setRowVisible(self.local_options, local)
|
self.stt_form.setRowVisible(self.local_options, local)
|
||||||
|
self._refresh_local_box()
|
||||||
if local:
|
if local:
|
||||||
return
|
return
|
||||||
self.transcribe_model.clear()
|
self.transcribe_model.clear()
|
||||||
@@ -2859,6 +2903,7 @@ class SettingsWindow(QDialog):
|
|||||||
provider != "local")
|
provider != "local")
|
||||||
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
||||||
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
||||||
|
self._refresh_local_box()
|
||||||
binary = cleanup.executable(provider)
|
binary = cleanup.executable(provider)
|
||||||
found = shutil.which(binary) if binary else ""
|
found = shutil.which(binary) if binary else ""
|
||||||
if provider == "local":
|
if provider == "local":
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ is blocked on, and a faked urlopen has no socket to cut, so those tests talk to
|
|||||||
a server of their own on the loopback interface.
|
a server of their own on the loopback interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -621,6 +622,7 @@ class FakeServer:
|
|||||||
self.fails = fails
|
self.fails = fails
|
||||||
self.log = log
|
self.log = log
|
||||||
self.starts = 0
|
self.starts = 0
|
||||||
|
self.held = 0
|
||||||
|
|
||||||
def serve(self):
|
def serve(self):
|
||||||
self.starts += 1
|
self.starts += 1
|
||||||
@@ -628,6 +630,14 @@ class FakeServer:
|
|||||||
raise ggml.LocalError(self.fails)
|
raise ggml.LocalError(self.fails)
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def busy(self):
|
||||||
|
self.held += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.held -= 1
|
||||||
|
|
||||||
def error(self):
|
def error(self):
|
||||||
return self.log
|
return self.log
|
||||||
|
|
||||||
|
|||||||
@@ -689,3 +689,24 @@ class ReadyToRun(DikteTest):
|
|||||||
self.assertEqual(ggml.whisper.settings()["threads"], 4)
|
self.assertEqual(ggml.whisper.settings()["threads"], 4)
|
||||||
self.assertFalse(ggml.whisper.settings()["gpu"])
|
self.assertFalse(ggml.whisper.settings()["gpu"])
|
||||||
self.assertEqual(ggml.llm.settings()["context"], 4096)
|
self.assertEqual(ggml.llm.settings()["context"], 4096)
|
||||||
|
|
||||||
|
def test_the_idle_window_is_in_seconds(self):
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=15)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 900)
|
||||||
|
|
||||||
|
def test_an_unchecked_box_keeps_the_model(self):
|
||||||
|
conf = self.config(local_idle_unload=False, local_idle_minutes=15)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 0)
|
||||||
|
|
||||||
|
def test_a_window_of_no_minutes_is_still_a_window(self):
|
||||||
|
"""The spin box will not go below one; a config edited by hand can."""
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=0)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 60)
|
||||||
|
|
||||||
|
def test_both_servers_are_told_the_window(self):
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=3)
|
||||||
|
self.addCleanup(ggml.llm.set_idle, 0)
|
||||||
|
self.addCleanup(ggml.whisper.set_idle, 0)
|
||||||
|
conf.apply_local()
|
||||||
|
self.assertEqual(ggml.whisper.idle, 180)
|
||||||
|
self.assertEqual(ggml.llm.idle, 180)
|
||||||
|
|||||||
+121
-1
@@ -783,7 +783,9 @@ STAND_IN = textwrap.dedent("""
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
class Servers(Local):
|
class ServerCase(Local):
|
||||||
|
"""The stand-in server and the fixture around it, with no tests of its own."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.path("data").mkdir(parents=True, exist_ok=True)
|
self.path("data").mkdir(parents=True, exist_ok=True)
|
||||||
@@ -806,6 +808,8 @@ class Servers(Local):
|
|||||||
self.addCleanup(made.stop)
|
self.addCleanup(made.stop)
|
||||||
return made
|
return made
|
||||||
|
|
||||||
|
|
||||||
|
class Servers(ServerCase):
|
||||||
def test_a_started_server_hands_back_its_address(self):
|
def test_a_started_server_hands_back_its_address(self):
|
||||||
server = self.server()
|
server = self.server()
|
||||||
url = server.serve()
|
url = server.serve()
|
||||||
@@ -1037,6 +1041,122 @@ class Servers(Local):
|
|||||||
self.assertFalse(server.sweep()) # and the pid file went with it
|
self.assertFalse(server.sweep()) # and the pid file went with it
|
||||||
|
|
||||||
|
|
||||||
|
class IdleUnload(ServerCase):
|
||||||
|
"""Giving the memory back when nothing has asked anything for a while."""
|
||||||
|
|
||||||
|
IDLE = 0.3
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# The real check runs every five seconds against a window of minutes.
|
||||||
|
# Both are scaled down here; what is being tested is the decision, and
|
||||||
|
# nothing in it reads the clock in units of its own.
|
||||||
|
self.patch_attr(ggml, "IDLE_CHECK_SECONDS", 0.05)
|
||||||
|
|
||||||
|
def idle_server(self, seconds=None, **settings):
|
||||||
|
server = self.server(**settings)
|
||||||
|
server.set_idle(self.IDLE if seconds is None else seconds)
|
||||||
|
return server
|
||||||
|
|
||||||
|
def wait_for(self, predicate, timeout=5.0):
|
||||||
|
"""True as soon as `predicate` holds, False once the wait runs out."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
time.sleep(0.02)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_a_model_nobody_is_using_is_unloaded(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_the_default_is_to_keep_it(self):
|
||||||
|
"""A server nobody set a window on stays until something stops it."""
|
||||||
|
server = self.server()
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_a_window_of_zero_keeps_it_too(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_a_request_in_flight_holds_the_model(self):
|
||||||
|
"""A file is one address lookup and then minutes of work: the clock
|
||||||
|
alone would call that idle and unload it mid-transcription."""
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
with server.busy():
|
||||||
|
self.assertFalse(
|
||||||
|
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_asking_for_the_address_puts_the_window_back(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
first = server.serve()
|
||||||
|
for _ in range(4):
|
||||||
|
time.sleep(self.IDLE / 2)
|
||||||
|
self.assertEqual(server.serve(), first) # never restarted
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
|
||||||
|
def test_the_next_request_loads_it_again(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
first = server.serve()
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
second = server.serve()
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
self.assertNotEqual(second, first) # a new process, a new port
|
||||||
|
|
||||||
|
def test_the_watcher_of_a_stopped_server_does_not_touch_the_next_one(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
server.stop()
|
||||||
|
server.set_idle(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_unloading_by_hand_does_not_wait_for_the_window(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertTrue(server.unload())
|
||||||
|
self.assertFalse(server.running)
|
||||||
|
|
||||||
|
def test_a_hold_taken_before_the_start_survives_it(self):
|
||||||
|
"""The local cleanup takes the hold and only then asks for the address,
|
||||||
|
so the start it triggers must not be what drops the hold."""
|
||||||
|
server = self.idle_server()
|
||||||
|
with server.busy():
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(
|
||||||
|
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_unloading_is_refused_while_the_model_is_still_loading(self):
|
||||||
|
"""It runs on the interface's thread, and a start holds its lock for as
|
||||||
|
long as the load takes: waiting there would freeze the whole window."""
|
||||||
|
server = self.idle_server(0, extra=["--wait", "0.6"])
|
||||||
|
thread = threading.Thread(target=server.serve)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
began = time.monotonic()
|
||||||
|
self.assertFalse(server.unload())
|
||||||
|
self.assertLess(time.monotonic() - began, 0.2)
|
||||||
|
finally:
|
||||||
|
thread.join(timeout=10)
|
||||||
|
|
||||||
|
def test_unloading_is_refused_while_a_request_is_in_flight(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
with server.busy():
|
||||||
|
self.assertFalse(server.unload())
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
|
||||||
|
def test_unloading_nothing_is_not_a_refusal(self):
|
||||||
|
self.assertTrue(self.server().unload())
|
||||||
|
|
||||||
|
|
||||||
class Arguments(Local):
|
class Arguments(Local):
|
||||||
"""What the two command lines say, since neither program is here to say it."""
|
"""What the two command lines say, since neither program is here to say it."""
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ CHANGED = {
|
|||||||
"local_llm_gpu": False,
|
"local_llm_gpu": False,
|
||||||
"local_llm_preload": True,
|
"local_llm_preload": True,
|
||||||
"local_llm_reasoning": "low",
|
"local_llm_reasoning": "low",
|
||||||
|
"local_idle_unload": False,
|
||||||
|
"local_idle_minutes": 45,
|
||||||
"cleanup_prompt": "Only fix the punctuation.",
|
"cleanup_prompt": "Only fix the punctuation.",
|
||||||
"file_cleanup_prompt": "Keep the stamps where they are.",
|
"file_cleanup_prompt": "Keep the stamps where they are.",
|
||||||
"transcribe_prompt": "Paraşüt, OpenFrame",
|
"transcribe_prompt": "Paraşüt, OpenFrame",
|
||||||
@@ -1701,6 +1703,26 @@ class LocalModels(DikteTest):
|
|||||||
# Its own thinking box, because the two default to opposite things.
|
# Its own thinking box, because the two default to opposite things.
|
||||||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
||||||
|
|
||||||
|
def test_the_idle_unload_is_offered_to_whoever_runs_a_model_here(self):
|
||||||
|
for transcriber, cleaner in (("local", "openrouter"),
|
||||||
|
("openai", "local"),
|
||||||
|
("local", "local")):
|
||||||
|
with self.subTest(transcriber=transcriber, cleaner=cleaner):
|
||||||
|
window = self.window(self.config(transcribe_provider=transcriber,
|
||||||
|
cleanup_provider=cleaner))
|
||||||
|
self.assertTrue(window.local_box.isVisibleTo(window))
|
||||||
|
|
||||||
|
def test_a_machine_that_runs_neither_is_not_asked_about_memory(self):
|
||||||
|
window = self.window(self.config(transcribe_provider="openai",
|
||||||
|
cleanup_provider="openrouter"))
|
||||||
|
self.assertFalse(window.local_box.isVisibleTo(window))
|
||||||
|
|
||||||
|
def test_the_minutes_follow_the_checkbox(self):
|
||||||
|
window = self.window(self.config(local_idle_unload=False))
|
||||||
|
self.assertFalse(window.local_idle_minutes.isEnabled())
|
||||||
|
window.local_idle_unload.setChecked(True)
|
||||||
|
self.assertTrue(window.local_idle_minutes.isEnabled())
|
||||||
|
|
||||||
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
rows = {"openrouter": window.cleanup_model_row,
|
rows = {"openrouter": window.cleanup_model_row,
|
||||||
|
|||||||
Reference in New Issue
Block a user