fix(qmd): treat Auto install and GPU probe failures as unsuitable
Auto now rolls back and reports "not suitable" when Node.js is too old, npm or the model download fails. A failing GPU probe falls back to the CPU in both Yes and Auto. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60f739ceb9
commit
f1d7894ba0
2 files changed
+63
-5
No files matched your search
+15
-3
@@ -147,8 +147,12 @@ class Setup:
|
|||||||
atomic_json(self.state_path, state)
|
atomic_json(self.state_path, state)
|
||||||
|
|
||||||
def detect_gpu(self):
|
def detect_gpu(self):
|
||||||
found = json.loads(self.node('qmd-benchmark.mjs', '--detect', timeout=DEVICE_TIMEOUT) or '{}')
|
try:
|
||||||
gpu = found.get('gpu') or False
|
found = json.loads(self.node('qmd-benchmark.mjs', '--detect', timeout=DEVICE_TIMEOUT) or '{}')
|
||||||
|
gpu = found.get('gpu') or False
|
||||||
|
except Exception as error: # any probe failure means: use the CPU
|
||||||
|
self.say(f'QMD GPU detection failed, using the CPU: {str(error)[:200]}')
|
||||||
|
return False
|
||||||
names = ', '.join(found.get('devices') or []) or 'unnamed device'
|
names = ', '.join(found.get('devices') or []) or 'unnamed device'
|
||||||
self.say(f"QMD devices: {f'{str(gpu).upper()}: {names}' if gpu else 'no GPU'}; CPU threads: {found.get('threads', '?')}")
|
self.say(f"QMD devices: {f'{str(gpu).upper()}: {names}' if gpu else 'no GPU'}; CPU threads: {found.get('threads', '?')}")
|
||||||
return gpu
|
return gpu
|
||||||
@@ -219,7 +223,15 @@ class Setup:
|
|||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
self.say('DRYRUN benchmark QMD local search on GPU, then CPU')
|
self.say('DRYRUN benchmark QMD local search on GPU, then CPU')
|
||||||
return True
|
return True
|
||||||
self.install(state)
|
try:
|
||||||
|
self.install(state)
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error:
|
||||||
|
print(f'QMD local search is not suitable for this computer: {error}')
|
||||||
|
try:
|
||||||
|
self.remove_all(state)
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as cleanup:
|
||||||
|
print(f'QMD cleanup was incomplete: {cleanup}')
|
||||||
|
return False
|
||||||
results = []
|
results = []
|
||||||
devices = (['gpu'] if self.detect_gpu() else []) + ['cpu']
|
devices = (['gpu'] if self.detect_gpu() else []) + ['cpu']
|
||||||
for device in devices:
|
for device in devices:
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -13,16 +16,22 @@ spec.loader.exec_module(qmd)
|
|||||||
|
|
||||||
|
|
||||||
class FakeRunner:
|
class FakeRunner:
|
||||||
def __init__(self, installed=False, gpu='cuda', gpu_result=None, cpu_result=None, previous=None, fail_pull=False, list_output=None):
|
def __init__(self, installed=False, gpu='cuda', gpu_result=None, cpu_result=None, previous=None, fail_pull=False, list_output=None,
|
||||||
|
node='v22.4.0', fail=(), on_call=None):
|
||||||
self.calls, self.installed, self.gpu = [], installed, gpu
|
self.calls, self.installed, self.gpu = [], installed, gpu
|
||||||
self.previous, self.fail_pull, self.list_output = previous, fail_pull, list_output
|
self.previous, self.fail_pull, self.list_output = previous, fail_pull, list_output
|
||||||
|
self.node, self.fail, self.on_call = node, fail, on_call
|
||||||
self.results = {'gpu': gpu_result, 'cpu': cpu_result}
|
self.results = {'gpu': gpu_result, 'cpu': cpu_result}
|
||||||
|
|
||||||
def __call__(self, arguments, env=None, timeout=None, check=True):
|
def __call__(self, arguments, env=None, timeout=None, check=True):
|
||||||
self.calls.append(list(arguments))
|
self.calls.append(list(arguments))
|
||||||
joined = ' '.join(arguments)
|
joined = ' '.join(arguments)
|
||||||
|
if self.on_call:
|
||||||
|
self.on_call(arguments)
|
||||||
|
if any(part in joined for part in self.fail):
|
||||||
|
raise ValueError(f'{joined} failed')
|
||||||
if arguments[:2] == ['node', '--version']:
|
if arguments[:2] == ['node', '--version']:
|
||||||
return 'v22.4.0'
|
return self.node
|
||||||
if arguments[-1:] == ['set-models']:
|
if arguments[-1:] == ['set-models']:
|
||||||
return json.dumps({'previous': self.previous}) + chr(10)
|
return json.dumps({'previous': self.previous}) + chr(10)
|
||||||
if self.fail_pull and arguments[:2] == ['qmd', 'pull']:
|
if self.fail_pull and arguments[:2] == ['qmd', 'pull']:
|
||||||
@@ -47,6 +56,10 @@ class SetupTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.home = Path(tempfile.mkdtemp())
|
self.home = Path(tempfile.mkdtemp())
|
||||||
self.addCleanup(shutil.rmtree, self.home, ignore_errors=True)
|
self.addCleanup(shutil.rmtree, self.home, ignore_errors=True)
|
||||||
|
environment = mock.patch.dict(os.environ)
|
||||||
|
environment.start()
|
||||||
|
self.addCleanup(environment.stop)
|
||||||
|
os.environ.pop('XDG_CACHE_HOME', None)
|
||||||
(self.home / '.cache/qmd/models').mkdir(parents=True)
|
(self.home / '.cache/qmd/models').mkdir(parents=True)
|
||||||
|
|
||||||
def setup(self, runner, dry_run=False):
|
def setup(self, runner, dry_run=False):
|
||||||
@@ -221,6 +234,39 @@ class SetupTest(unittest.TestCase):
|
|||||||
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls))
|
self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls))
|
||||||
|
|
||||||
|
def scripts_dir(self):
|
||||||
|
return self.home / '.my-ai-configuration/qmd'
|
||||||
|
|
||||||
|
def test_benchmark_with_too_old_node_is_unsuitable(self):
|
||||||
|
runner = FakeRunner(node='v20.11.1', cpu_result={'device': 'cpu', 'passed': True})
|
||||||
|
self.assertFalse(self.setup(runner).benchmark())
|
||||||
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
|
self.assertFalse(any('npm install' in ' '.join(c) for c in runner.calls))
|
||||||
|
|
||||||
|
def test_benchmark_npm_install_failure_is_unsuitable(self):
|
||||||
|
runner = FakeRunner(fail=('npm install',), cpu_result={'device': 'cpu', 'passed': True})
|
||||||
|
self.assertFalse(self.setup(runner).benchmark())
|
||||||
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
|
self.assertFalse(any('--device' in c for c in runner.calls))
|
||||||
|
|
||||||
|
def test_benchmark_download_failure_cleans_up_what_it_created(self):
|
||||||
|
runner = FakeRunner(fail_pull=True, cpu_result={'device': 'cpu', 'passed': True})
|
||||||
|
self.assertFalse(self.setup(runner).benchmark())
|
||||||
|
self.assertTrue(any(c[-1:] == ['unset-models'] for c in runner.calls))
|
||||||
|
self.assertIn(['npm', 'uninstall', '--global', '@tobilu/qmd'], runner.calls)
|
||||||
|
self.assertFalse(self.scripts_dir().exists())
|
||||||
|
self.assertFalse((self.home / '.my-ai-configuration/qmd.json').exists())
|
||||||
|
|
||||||
|
def test_benchmark_detect_crash_runs_cpu_only(self):
|
||||||
|
runner = FakeRunner(fail=('--detect',), gpu_result={'device': 'gpu', 'passed': True}, cpu_result={'device': 'cpu', 'passed': True})
|
||||||
|
self.assertTrue(self.setup(runner).benchmark())
|
||||||
|
self.assertEqual(self.state()['device'], 'cpu')
|
||||||
|
self.assertFalse(any('--device' in c and 'gpu' in c for c in runner.calls))
|
||||||
|
|
||||||
|
def test_enable_with_detect_crash_uses_cpu(self):
|
||||||
|
self.setup(FakeRunner(fail=('--detect',))).enable(['claude'])
|
||||||
|
self.assertEqual(self.state()['device'], 'cpu')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user