Skip to content

Commit bc98113

Browse files
wilke0818claude
andcommitted
subprocess_venv: two-stage install to fix torch/torchaudio CUDA-tag split
PR #516 routes the install through ``--index-url <cuda> --extra-index-url https://pypi.org/simple`` so non-torch packages can still resolve from PyPI. But uv treats ``--extra-index-url`` as having higher priority than ``--index-url`` (opposite of pip), so PyPI wins for every package — torch and torchaudio included. On hosts where PyPI ships those with mismatched ``+cu`` local-version tags (currently ``torch==X+cu129`` vs ``torchaudio==X`` with no tag, internally cu128), the resulting venv reproduces the same ABI mismatch this PR is meant to fix: RuntimeError: Detected that PyTorch and TorchAudio were compiled with different CUDA versions. PyTorch has CUDA version 12.9 whereas TorchAudio has CUDA version 12.8. Reproduced on MIT ORCD with the env override set (``SENSELAB_TORCH_INDEX_URL=https://download.pytorch.org/whl/cu128``) — override URL was honored in the marker but uv still resolved both wheels from PyPI. So the override path was non-functional too. Fix: split the install into two stages. Stage 1 — ``uv pip install --index-url <chosen> torch torchaudio`` with NO ``--extra-index-url``. The chosen CUDA index is unambiguously primary; both wheels and their ``nvidia-cuda-runtime-cu12`` transitives come from the same toolchain. Pins from the caller's ``requirements`` list flow through verbatim; multiple constraints for the same package (``["torch>=2.8", "torch<2.9"]``) are all forwarded so uv can combine them at resolve time. Stage 2 — ``uv pip install <rest of requirements> safetensors numpy`` with no index flags. Backend-pinned torch / torchaudio specs are filtered out so uv can't be tempted to re-resolve them from PyPI against the matched ``+cu128`` wheels installed in Stage 1. uv sees both already installed and satisfying constraints from their transitives' point of view, and leaves them alone. The PyTorch index now governs only the two packages it's designed for; stale wheels on the CUDA index for utilities like setuptools / pyarrow stay out of the picture. Add ``requires_torch=True`` parameter to ``ensure_venv`` for opt-out: subprocess venvs that genuinely don't touch torch (e.g. consuming a pure-Python GitHub repo) can pass ``requires_torch=False`` to skip the probe, skip Stage 1, and skip the IPC ``torchaudio`` append entirely — single install pass against default PyPI. Marker omits ``torch_index`` in that case; a later ``requires_torch=True`` call against the same name correctly invalidates and rebuilds via the existing URL-mismatch clause. Behavioral guarantees preserved from PR #516: marker schema (extended, not changed), cache-hit fast path, env override (``SENSELAB_TORCH_INDEX_URL``), ``SenselabCudaCompatibilityError`` wrapping on wheel-not-found errors (now scoped to Stage 1 where it semantically belongs), pass-through of unrelated ``CalledProcessError``, half-built-venv cleanup on failure, host-CUDA probe diagnostic, cross-backend routing. Tests: existing call-count assertions updated for the extra ``uv pip install``; the two install-argv tests split into seven new ones covering (1) Stage 1 names only ``--index-url`` with torch + torchaudio pinned per requirements, (2) Stage 1 falls back to bare names when requirements omit them (qwen backend), (3) Stage 2 carries no index flags + IPC deps, (4) Stage 2 filters torch / torchaudio specs from caller's requirements, (5) multiple constraints for the same package all forward through Stage 1 verbatim, (6) ``requires_torch=False`` skips the probe + Stage 1 + IPC torchaudio entirely, and (7) switching from ``requires_torch=False`` to ``True`` on the same name invalidates the cache and rebuilds. The cross-backend regression test now asserts both that Stage 1 routes through the chosen index AND that Stage 2 contains no torch / torchaudio specs at all — guarding against any future re-introduction of ``--extra-index-url`` on the torch install or a torch spec leaking into Stage 2. Review feedback addressed: - @satra: ``requires_torch`` opt-out for venvs that don't need torch - gemini-code-assist #1: filter torch / torchaudio specs from Stage 2 - gemini-code-assist #2: list-based ``_torch_install_specs`` keeps every matching constraint instead of clobbering through a dict Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 92d8b3e commit bc98113

2 files changed

Lines changed: 410 additions & 78 deletions

File tree

src/senselab/utils/subprocess_venv.py

Lines changed: 184 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,23 @@ def ensure_venv(
195195
name: str,
196196
requirements: list[str],
197197
python_version: Optional[str] = None,
198+
requires_torch: bool = True,
198199
) -> Path:
199200
"""Create or reuse an isolated virtual environment.
200201
201202
Args:
202203
name: Unique identifier for this venv (e.g., "coqui", "ppgs").
203204
requirements: List of pip install specs (e.g., ["coqui-tts~=0.27"]).
204205
python_version: Python version (e.g., "3.11"). Defaults to current.
206+
requires_torch: When True (default), runs the CUDA-aware two-stage
207+
install so ``torch`` + ``torchaudio`` come from a single matched
208+
PyTorch wheel index, and ``torchaudio`` is always installed for
209+
IPC. When False, skips the probe + Stage-1 torch install +
210+
``torchaudio`` append entirely — installs ``requirements`` +
211+
``safetensors`` + ``numpy`` from default PyPI in a single pass.
212+
Use False for subprocess venvs that genuinely don't touch torch
213+
(e.g. consuming a pure-Python GitHub repo) so they don't pay
214+
the multi-GB CUDA wheel cost.
205215
206216
Returns:
207217
Path to the venv directory.
@@ -211,29 +221,38 @@ def ensure_venv(
211221
marker = venv_dir / ".senselab-installed"
212222

213223
with FileLock(str(lock_path), timeout=600):
214-
# Resolve the torch wheel index FIRST so the marker-match check
215-
# below can compare the cached index URL. Order: env override
216-
# decides the URL; the host-CUDA probe still runs (even with the
217-
# override) so its result can be surfaced in the diagnostic when
218-
# an install failure wraps into ``SenselabCudaCompatibilityError``.
219-
env_override = os.getenv("SENSELAB_TORCH_INDEX_URL") or None
220-
host_cuda = detect_host_cuda()
221-
torch_index = pick_torch_index(host_cuda, env_override=env_override)
222-
224+
# Resolve the torch wheel index FIRST (when this venv uses torch)
225+
# so the marker-match check below can compare the cached index URL.
226+
# Order: env override decides the URL; the host-CUDA probe still
227+
# runs (even with the override) so its result can be surfaced in
228+
# the diagnostic when an install failure wraps into
229+
# ``SenselabCudaCompatibilityError``. When ``requires_torch=False``,
230+
# neither the override nor the probe matters — the install path
231+
# doesn't touch the PyTorch index at all.
232+
host_cuda: Optional[HostCuda] = None
233+
torch_index: Optional[TorchIndex] = None
234+
if requires_torch:
235+
env_override = os.getenv("SENSELAB_TORCH_INDEX_URL") or None
236+
probed = detect_host_cuda()
237+
host_cuda = probed
238+
torch_index = pick_torch_index(probed, env_override=env_override)
239+
240+
expected_index_url = torch_index.url if torch_index is not None else None
223241
if marker.is_file():
224242
stored = json.loads(marker.read_text())
225243
stored_index_url = (stored.get("torch_index") or {}).get("url")
226-
if stored.get("requirements") == sorted(requirements) and stored_index_url == torch_index.url:
244+
if stored.get("requirements") == sorted(requirements) and stored_index_url == expected_index_url:
227245
logger.debug("Reusing existing venv: %s", venv_dir)
228246
return venv_dir
229247

230248
uv = _find_uv()
231249
py_ver = python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
250+
index_label = torch_index.tag if torch_index is not None else "n/a (torch-free)"
232251
logger.info(
233252
"Creating isolated venv '%s' with Python %s (torch index: %s)",
234253
name,
235254
py_ver,
236-
torch_index.tag,
255+
index_label,
237256
)
238257

239258
if venv_dir.exists():
@@ -254,62 +273,124 @@ def ensure_venv(
254273
shutil.rmtree(venv_dir, ignore_errors=True)
255274
raise
256275

257-
# Always include IPC serialization deps (safetensors for tensors,
258-
# numpy for arrays, torchaudio for FLAC audio encoding). Route
259-
# torch/torchaudio through the chosen PyTorch wheel index; keep
260-
# PyPI as the extra index so non-torch packages still resolve.
261-
all_reqs = [*requirements, "safetensors", "numpy", "torchaudio"]
276+
if requires_torch:
277+
assert torch_index is not None # narrows the Optional for type-checkers
278+
# Two-stage install — works around uv's flag-precedence quirk.
279+
#
280+
# uv treats ``--extra-index-url`` as having higher priority
281+
# than ``--index-url`` (opposite of pip). So the obvious one-
282+
# shot form ``--index-url <cuda> --extra-index-url pypi`` lets
283+
# PyPI win for every package, including ``torch`` and
284+
# ``torchaudio``. On hosts where PyPI ships those two with
285+
# mismatched ``+cu`` local-version tags (currently
286+
# ``torch==X+cu129`` vs ``torchaudio==X`` with no tag), the
287+
# resulting venv hits the ABI mismatch this routing was meant
288+
# to prevent (``RuntimeError: PyTorch has CUDA version 12.9
289+
# whereas TorchAudio has CUDA version 12.8``).
290+
#
291+
# Stage 1: install torch + torchaudio with ONLY the chosen
292+
# CUDA index named (no ``--extra-index-url``), so the index
293+
# is unambiguously primary and both wheels — plus their
294+
# ``nvidia-cuda-runtime-cu12`` transitives — come from it
295+
# with matched toolchains.
296+
#
297+
# Stage 2: install the remaining requirements (with torch +
298+
# torchaudio specs filtered out so uv can't re-resolve them
299+
# against PyPI) + the IPC serialization deps via default
300+
# PyPI. uv sees torch + torchaudio already installed and
301+
# satisfying any pin in ``requirements``, so it doesn't
302+
# re-resolve them. The PyTorch index governs only the two
303+
# packages it's designed for, and stale wheels on the CUDA
304+
# index for utilities like setuptools or pyarrow stay out
305+
# of the picture.
306+
try:
307+
subprocess.run(
308+
[
309+
uv,
310+
"pip",
311+
"install",
312+
"--index-url",
313+
torch_index.url,
314+
"--python",
315+
venv_python(venv_dir),
316+
*_torch_install_specs(requirements),
317+
],
318+
check=True,
319+
capture_output=True,
320+
text=True,
321+
)
322+
except subprocess.CalledProcessError as exc:
323+
# Wipe the half-built venv before raising so the next run starts clean.
324+
shutil.rmtree(venv_dir, ignore_errors=True)
325+
failing = _classify_uv_failure(exc.stderr or "")
326+
if failing is not None:
327+
# Compat-error path: the wrapped exception's message already
328+
# carries the diagnostic fields (host CUDA, attempted index,
329+
# failing packages, recommended action). Logging the full uv
330+
# stderr would just duplicate that.
331+
logger.debug("Wheel not found installing torch in venv '%s': %s", name, exc.stderr)
332+
assert host_cuda is not None
333+
raise SenselabCudaCompatibilityError(
334+
host_cuda=host_cuda,
335+
attempted_index=torch_index,
336+
failing_packages=failing,
337+
) from exc
338+
# Pass-through path: log the stderr so the user can see what
339+
# really went wrong (network, permission, syntax, ...).
340+
logger.error("Failed to install torch in venv '%s': %s", name, exc.stderr)
341+
raise
342+
343+
# Stage 2: backend requirements (minus any torch / torchaudio
344+
# specs — those are already installed and listing them here
345+
# without an index flag could let uv consider replacing the
346+
# matched wheels with PyPI's tagless versions) plus the IPC
347+
# serialization deps (safetensors + numpy). ``torchaudio``
348+
# was installed in Stage 1; both safetensors and numpy are
349+
# pure-Python and pull cleanly from PyPI everywhere.
350+
stage_two_reqs = [r for r in requirements if _spec_pkg_name(r) not in _TORCH_PKG_NAMES] + [
351+
"safetensors",
352+
"numpy",
353+
]
354+
else:
355+
# Torch-free path: single install pass straight from default
356+
# PyPI. No probe, no CUDA-index routing, no IPC ``torchaudio``
357+
# append — callers that don't need torch don't pay for it.
358+
stage_two_reqs = [*requirements, "safetensors", "numpy"]
359+
262360
try:
263361
subprocess.run(
264362
[
265363
uv,
266364
"pip",
267365
"install",
268-
"--index-url",
269-
torch_index.url,
270-
"--extra-index-url",
271-
"https://pypi.org/simple",
272366
"--python",
273367
venv_python(venv_dir),
274-
*all_reqs,
368+
*stage_two_reqs,
275369
],
276370
check=True,
277371
capture_output=True,
278372
text=True,
279373
)
280374
except subprocess.CalledProcessError as exc:
281-
# Wipe the half-built venv before raising so the next run starts clean.
282375
shutil.rmtree(venv_dir, ignore_errors=True)
283-
failing = _classify_uv_failure(exc.stderr or "")
284-
if failing is not None:
285-
# Compat-error path: the wrapped exception's message already
286-
# carries the diagnostic fields (host CUDA, attempted index,
287-
# failing packages, recommended action). Logging the full uv
288-
# stderr would just duplicate that.
289-
logger.debug("Wheel not found installing in venv '%s': %s", name, exc.stderr)
290-
raise SenselabCudaCompatibilityError(
291-
host_cuda=host_cuda,
292-
attempted_index=torch_index,
293-
failing_packages=failing,
294-
) from exc
295-
# Pass-through path: log the stderr so the user can see what
296-
# really went wrong (network, permission, syntax, ...).
297376
logger.error("Failed to install in venv '%s': %s", name, exc.stderr)
298377
raise
299378

300-
marker.write_text(
301-
json.dumps(
302-
{
303-
"requirements": sorted(requirements),
304-
"python_version": py_ver,
305-
"torch_index": {
306-
"tag": torch_index.tag,
307-
"url": torch_index.url,
308-
"source": torch_index.source,
309-
},
310-
}
311-
)
312-
)
379+
marker_data: dict[str, object] = {
380+
"requirements": sorted(requirements),
381+
"python_version": py_ver,
382+
}
383+
if torch_index is not None:
384+
# Only stamp the index field on torch-using venvs; the marker
385+
# comparison treats its absence as "no torch routing in use"
386+
# so a future ``requires_torch=True`` call against the same
387+
# name correctly invalidates and rebuilds.
388+
marker_data["torch_index"] = {
389+
"tag": torch_index.tag,
390+
"url": torch_index.url,
391+
"source": torch_index.source,
392+
}
393+
marker.write_text(json.dumps(marker_data))
313394
logger.info("Venv '%s' ready at %s", name, venv_dir)
314395
return venv_dir
315396

@@ -353,6 +434,60 @@ def _classify_uv_failure(stderr: str) -> Optional[list[str]]:
353434
return out
354435

355436

437+
# Packages whose install must be routed through the chosen CUDA-tagged
438+
# PyTorch wheel index. We don't include downstream torch-ecosystem names
439+
# like ``torchvision``/``torchtext`` because senselab's subprocess venvs
440+
# don't currently use them; add here if that changes.
441+
_TORCH_PKG_NAMES = frozenset({"torch", "torchaudio"})
442+
443+
# Capture the package name at the start of a uv pip install spec, stopping
444+
# at the first character that isn't part of a PEP 508 distribution name —
445+
# extras start with ``[``, versions with one of ``<>=!~``, URL/git refs
446+
# with whitespace or ``@``. Matches ``torch``, ``torch>=2.8,<2.9``,
447+
# ``torch[gpu]==2.8``, and ``nemo_toolkit[asr,tts] @ git+https://...``.
448+
_PKG_NAME_RE = re.compile(r"^\s*([A-Za-z0-9._-]+)")
449+
450+
451+
def _spec_pkg_name(spec: str) -> str:
452+
"""Return the lowercased package name from a uv pip install spec.
453+
454+
Returns ``""`` for specs that don't begin with a recognizable package
455+
name (e.g. a stray empty string). The match is loose by design — we
456+
only need it to identify torch + torchaudio entries inside
457+
``requirements`` lists.
458+
"""
459+
m = _PKG_NAME_RE.match(spec)
460+
return m.group(1).lower() if m else ""
461+
462+
463+
def _torch_install_specs(requirements: list[str]) -> list[str]:
464+
"""Return Stage-1 install args: every ``torch`` / ``torchaudio`` spec.
465+
466+
Forwards EVERY torch / torchaudio entry in ``requirements`` verbatim —
467+
so a backend pinning ``["torch>=2.8", "torch<2.9"]`` as two separate
468+
constraints gets both passed to uv, which combines them at resolve
469+
time. A single combined spec like ``"torch>=2.8,<2.9"`` still flows
470+
through unchanged. Either form is supported; uv complains if the
471+
constraints are contradictory. Falls back to bare ``torch`` /
472+
``torchaudio`` for any package not pinned in ``requirements``, so
473+
transitive resolution can't sneak them in from PyPI later.
474+
475+
Used by ``ensure_venv`` to route both packages through a single index
476+
so their CUDA toolchain tags can't drift apart.
477+
"""
478+
out: list[str] = []
479+
found: set[str] = set()
480+
for spec in requirements:
481+
name = _spec_pkg_name(spec)
482+
if name in _TORCH_PKG_NAMES:
483+
out.append(spec)
484+
found.add(name)
485+
for name in ("torch", "torchaudio"):
486+
if name not in found:
487+
out.append(name)
488+
return out
489+
490+
356491
def venv_python(venv_dir: Path) -> str:
357492
"""Return the path to the Python interpreter inside a venv.
358493

0 commit comments

Comments
 (0)