fix(device): fall back to CPU when the GPU arch is unsupported, not 500 every generate (#756) (#757)

* fix(settings): contain + tighten the whole Settings surface (measure cap, container-query stacking, wrap the shared rows)

Two systemic issues drove 'too spread out' + 'elements go out of view' across
many Settings pages:

1. Spread — .settings-content capped at 1280px, so on wide windows every
   label-left/control-right row left a huge void. Introduce a --settings-measure
   token (720px, macOS-like) + --settings-rail, and cap the content to it,
   left-aligned under the nav. One token now controls the reading width.

2. Overflow + bad responsiveness — the row stack break was a *viewport* media
   query (560px), but the 168px nav rail means a 760px-viewport window only has
   ~530px of content, so rows went side-by-side in a cramped box. Make
   .settings-content a container (container-type: inline-size) and stack on the
   CONTENT width via @container, keeping the viewport @media as a fallback for
   the .st-row instances used outside Settings (Splash/FirstRun/Dub/SetupWizard).

3. The shared .perfpanel__row (button/badge row reused by 6+ panels:
   RemoteBackend, HFMirror, LLMEndpoint, Pronunciation, MCPBindings, …) was an
   inline-flex with no wrap and no max-width, so it ran off the right edge —
   add flex-wrap + max-width:100% + min-width:0. Plus two rigid-width fixes that
   escaped the row cap: ApiKeys input min-width:220→0, Appearance scale floor.

Frontend builds clean; tokens, @container query, and the wrap all verified in the
emitted CSS bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(settings): center the settings block + tighten measure (kill the lopsided right void)

The capped content was left-aligned, so on a wide window everything jammed to the
left with a dead empty third on the right (screenshot). Center the whole settings
block (nav rail + content) as a unit via max-width + margin-inline:auto, and drop
the measure 720→660 so label→control rows read denser. The cap is computed from
the tokens (rail + gap + measure + page padding) so the content track lands
exactly at --settings-measure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(device): fall back to CPU when the GPU arch is unsupported, instead of 500-ing every generate (#756)

get_best_device() called check_device_compatibility() and, on an unsupported
compute capability, only LOGGED a warning then still returned 'cuda' — so the
model loaded on a GPU whose kernels can't launch and every generate 500'd with
'CUDA error: no kernel image is available for execution'. Both a too-old card
(Pascal sm_61, GTX 10-series) and a too-new one (Blackwell sm_120 on pre-cu128
wheels) hit this.

Now an unsupported arch falls back to CPU (works, just slower) with a clear
warning; OMNIVOICE_FORCE_CUDA=1 overrides. Belt-and-suspenders: _oom_friendly_reraise
classifies a raw 'no kernel image is available' as an unsupported-GPU error
(switch to CPU / install matching torch) rather than the OOM/Flush message.

Tests: get_best_device → cpu on incompatible, stays cuda on compatible, honors
the force override; reraise gives the actionable GPU message, not OOM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(device): patch detect_host_caps via string path so the #756 fallback test is full-suite robust

The first version aliased the import + inserted backend on sys.path, which patched
a module copy get_best_device's local 'from core.device_caps import detect_host_caps'
didn't resolve in the full suite (passed alone, failed in CI). Use the string-form
monkeypatch target; verified passing alongside the other device/model tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): fold #757 device-fallback entry into [0.3.8]; drop the merge's stale [Unreleased] dupe

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-01 17:59:49 +05:30
committed by GitHub
co-authored by Claude Opus 4.8 mergetest
parent 5e0d6826da
commit c5c57508b3
5 changed files with 97 additions and 0 deletions
+10
View File
@@ -177,6 +177,16 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
Closes the whole class of GPU-job-hang reports (#851#850, #802, #755, #723,
#721, and the 0.3.7 cohort, all tracked in #730).
- **An unsupported GPU now falls back to CPU instead of 500-ing every generate.**
When the installed PyTorch build has no kernels for your GPU's compute
capability — a too-old card (Pascal / GTX 10-series) or a too-new one
(Blackwell RTX 50-series on pre-cu128 wheels) — CUDA failed at launch with the
cryptic `CUDA error: no kernel image is available for execution`. The backend
now detects that up front and runs on CPU (slower, but it works), and any raw
occurrence is reported as "your GPU isn't supported — switch to CPU or install a
matching PyTorch," not a Flush-the-memory dead end. Force the GPU anyway with
`OMNIVOICE_FORCE_CUDA=1`. (#756)
- **The "TRANSLATION FAILED" banner now dismisses and clears itself.** The Dub
translation-error banner used to be sticky — it survived a successful re-try and
never went away. It now has a close (×), auto-clears on the next corrective
+12
View File
@@ -214,6 +214,18 @@ def _oom_friendly_reraise(e):
# sys.stdout/stderr to swallow EPIPE, but a C-level write inside the native
# engine/torch can still raise one past that guard. Flush won't help —
# relaunching the app re-parents the backend to a live shell.
# #756: the GPU's compute capability isn't in this PyTorch build's arch list,
# so CUDA can't launch kernels ("no kernel image is available for execution").
# NOT OOM. get_best_device() now falls back to CPU up front, but classify the
# raw error too in case CUDA was forced (OMNIVOICE_FORCE_CUDA) or a sub-path
# still ran on the GPU — point at the real fix, not the Flush button.
if "no kernel image is available" in _low:
raise RuntimeError(
f"Your GPU isn't supported by the installed PyTorch build (CUDA can't "
f"launch kernels for its compute capability). Switch the compute device "
f"to CPU in Settings, or install a matching PyTorch (e.g. a cu128 build "
f"for newer GPUs). The Flush button won't help. Underlying error: {e}"
) from e
if isinstance(e, BrokenPipeError) or "broken pipe" in _low or "errno 32" in _low:
raise RuntimeError(
f"The backend lost its output pipe mid-generation — the desktop app "
+13
View File
@@ -370,6 +370,19 @@ def get_best_device():
compatible, warning = check_device_compatibility()
if not compatible:
logger.warning(warning)
# #756: the GPU's compute capability isn't in this torch build's arch
# list, so CUDA kernels can't launch ("no kernel image is available
# for execution") — every generate would 500. Too-old (Pascal sm_61)
# and too-new (Blackwell sm_120 on pre-cu128 wheels) both land here.
# Fall back to CPU so the app WORKS (slowly) instead of dead-ending;
# OMNIVOICE_FORCE_CUDA=1 overrides for users who installed a matching
# torch and know the arch_list probe is wrong for their setup.
if not _env_flag("OMNIVOICE_FORCE_CUDA"):
logger.warning(
"Falling back to CPU: this GPU is unsupported by the installed "
"PyTorch build (set OMNIVOICE_FORCE_CUDA=1 to force CUDA anyway)."
)
return "cpu"
return "cuda"
# ── Intel Arc / discrete GPU via IPEX ────────────────────────────
+46
View File
@@ -0,0 +1,46 @@
"""#756: a GPU whose compute capability isn't in the installed PyTorch build's
arch list can't launch CUDA kernels ("no kernel image is available for
execution"), so every generate 500s. get_best_device() must fall back to CPU so
the app still works (slowly) instead of dead-ending — unless the user explicitly
forces CUDA. These tests pin that fallback (and the override) without a GPU.
"""
from types import SimpleNamespace
import pytest
import services.model_manager as mm
@pytest.fixture
def cuda_host(monkeypatch):
# Pretend a CUDA GPU is present. Patch detect_host_caps via its string path so
# the lookup resolves the same module object get_best_device imports locally
# (`from core.device_caps import detect_host_caps`) — patching an aliased
# import can miss that in a full-suite run.
monkeypatch.setattr(
"core.device_caps.detect_host_caps", lambda: SimpleNamespace(family="cuda")
)
monkeypatch.setattr(mm, "_lazy_torch", lambda: SimpleNamespace())
monkeypatch.setattr(mm, "_configure_rocm_if_needed", lambda _torch: None)
monkeypatch.delenv("OMNIVOICE_FORCE_CUDA", raising=False)
def test_unsupported_gpu_falls_back_to_cpu(cuda_host, monkeypatch):
monkeypatch.setattr(
mm, "check_device_compatibility",
lambda: (False, "GTX 1080 Ti (sm_61) is not supported by this PyTorch build"),
)
assert mm.get_best_device() == "cpu"
def test_supported_gpu_stays_on_cuda(cuda_host, monkeypatch):
monkeypatch.setattr(mm, "check_device_compatibility", lambda: (True, None))
assert mm.get_best_device() == "cuda"
def test_force_cuda_overrides_the_fallback(cuda_host, monkeypatch):
monkeypatch.setattr(
mm, "check_device_compatibility", lambda: (False, "unsupported arch"),
)
monkeypatch.setenv("OMNIVOICE_FORCE_CUDA", "1")
assert mm.get_best_device() == "cuda"
+16
View File
@@ -102,6 +102,22 @@ def test_broken_pipe_is_a_lost_pipe_not_oom():
assert "ran out of memory" not in msg
def test_no_kernel_image_is_an_unsupported_gpu_not_oom():
# #756: a GPU whose compute capability isn't in the torch build's arch list
# (Pascal sm_61 on new wheels, Blackwell sm_120 on old wheels) raises "CUDA
# error: no kernel image is available for execution". That's NOT OOM and Flush
# won't help — point at CPU / a matching torch.
err = RuntimeError(
"CUDA error: no kernel image is available for execution on the device"
)
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "GPU isn't supported" in msg or "isn't supported by the installed" in msg
assert "CPU" in msg
assert "ran out of memory" not in msg
def test_winerror_193_is_a_corrupt_binary_not_oom():
# #705: a corrupt / wrong-architecture native component (torch, ffmpeg, an
# engine binary) fails on Windows with "[WinError 193] %1 is not a valid