-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat: multi-GPU component placement (DiT / 5Hz LM on separate GPUs) for Gradio + CLI #1269
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Doud-FR
wants to merge
5
commits into
ace-step:main
Choose a base branch
from
Doud-FR:feat/multi-gpu-component-placement
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
1d5d108
feat(multi-gpu): route DiT and 5Hz LM to separate GPUs (Gradio + CLI)…
Doud-FR 047315d
fix(multi-gpu): derive multi-GPU state from device map, validate over…
Doud-FR 4ae6eae
fix(multi-gpu): reuse resolved device map for LM offload decision (av…
Doud-FR d04360f
fix(multi-gpu): guard non-CUDA device selection, validate env overrid…
Doud-FR 0b46765
fix(multi-gpu): query bfloat16 support on the mapped CUDA device index
Doud-FR File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| """Utilities for dynamic multi-device component mapping. | ||
|
|
||
| Based on PR #1149 (imsarang) — extended with explicit environment-variable | ||
| overrides (ACESTEP_DIT_DEVICE / ACESTEP_VAE_DEVICE / ACESTEP_LM_DEVICE) so that | ||
| users of asymmetric multi-GPU rigs can pin each component deterministically | ||
| (e.g. DiT on the fast card, LM on the second card). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from dataclasses import dataclass | ||
| from typing import Optional | ||
|
|
||
| from loguru import logger # type: ignore[reportMissingImports] | ||
|
|
||
| try: | ||
| import torch # type: ignore[reportMissingImports] | ||
| except ImportError: # pragma: no cover - fallback for minimal environments | ||
| torch = None # type: ignore[assignment] | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class ComponentDeviceMap: | ||
| """Normalized component-to-device mapping.""" | ||
|
|
||
| dit: Optional[str] = None | ||
| vae: Optional[str] = None | ||
| lm: Optional[str] = None | ||
|
|
||
|
|
||
| def _rank_cuda_devices_by_free_vram() -> list[int]: | ||
| """Return CUDA device indices sorted by descending free VRAM.""" | ||
| if torch is None or not torch.cuda.is_available(): | ||
| return [] | ||
|
|
||
| device_count = torch.cuda.device_count() | ||
| free_by_device: list[tuple[int, int]] = [] | ||
| for idx in range(device_count): | ||
| free_bytes = 0 | ||
| try: | ||
| free_bytes = int(torch.cuda.mem_get_info(idx)[0]) | ||
| except RuntimeError as exc: | ||
| logger.debug( | ||
| "[device_mapping] mem_get_info({}) failed ({}); retrying with device context.", | ||
| idx, | ||
| exc, | ||
| ) | ||
| try: | ||
| with torch.cuda.device(idx): | ||
| free_bytes = int(torch.cuda.mem_get_info()[0]) | ||
| except RuntimeError as exc2: | ||
| logger.warning( | ||
| "[device_mapping] Unable to query free VRAM for cuda:{} ({}); ranking it last.", | ||
| idx, | ||
| exc2, | ||
| ) | ||
| free_bytes = 0 | ||
| free_by_device.append((idx, free_bytes)) | ||
| free_by_device.sort(key=lambda pair: pair[1], reverse=True) | ||
| return [idx for idx, _free in free_by_device] | ||
|
|
||
|
|
||
| def _env_device_overrides() -> tuple[Optional[str], Optional[str], Optional[str]]: | ||
| """Read explicit per-component device overrides from the environment.""" | ||
| def _norm(val: Optional[str]) -> Optional[str]: | ||
| if val is None: | ||
| return None | ||
| val = val.strip() | ||
| return val or None | ||
|
|
||
| return ( | ||
| _norm(os.getenv("ACESTEP_DIT_DEVICE")), | ||
| _norm(os.getenv("ACESTEP_VAE_DEVICE")), | ||
| _norm(os.getenv("ACESTEP_LM_DEVICE")), | ||
| ) | ||
|
|
||
|
|
||
| def resolve_component_device_map() -> ComponentDeviceMap: | ||
| """Auto-populate component mapping from available CUDA devices. | ||
|
|
||
| Explicit env overrides (ACESTEP_DIT_DEVICE / ACESTEP_VAE_DEVICE / | ||
| ACESTEP_LM_DEVICE) take precedence over the free-VRAM auto-ranking, allowing | ||
| deterministic pinning on asymmetric multi-GPU setups. | ||
| """ | ||
| env_dit, env_vae, env_lm = _env_device_overrides() | ||
|
|
||
| if torch is None or not torch.cuda.is_available(): | ||
| if env_dit or env_vae or env_lm: | ||
| return ComponentDeviceMap(dit=env_dit, vae=env_vae, lm=env_lm) | ||
| return ComponentDeviceMap() | ||
|
|
||
| ranked = _rank_cuda_devices_by_free_vram() | ||
| if not ranked: | ||
| if env_dit or env_vae or env_lm: | ||
| return ComponentDeviceMap(dit=env_dit, vae=env_vae, lm=env_lm) | ||
| return ComponentDeviceMap() | ||
|
|
||
| dit_idx = ranked[0] | ||
| vae_idx = ranked[1] if len(ranked) > 1 else ranked[0] | ||
| lm_idx = ranked[2] if len(ranked) > 2 else ranked[-1] | ||
|
|
||
| return ComponentDeviceMap( | ||
| dit=env_dit or f"cuda:{dit_idx}", | ||
| vae=env_vae or f"cuda:{vae_idx}", | ||
| lm=env_lm or f"cuda:{lm_idx}", | ||
| ) | ||
|
|
||
|
|
||
| def format_component_gpu_hint_text( | ||
| *, | ||
| default_device: str = "auto", | ||
| label: str = "Component GPU hint", | ||
| ) -> str: | ||
| """Format component placement hint for UI display. | ||
|
|
||
| Returns an empty string when all components resolve to the same final device, | ||
| which avoids noisy UI on non-CUDA and single-device hosts. | ||
| """ | ||
| mapping = resolve_component_device_map() | ||
| resolved_devices = [ | ||
| mapping.dit or default_device, | ||
| mapping.vae or default_device, | ||
| mapping.lm or default_device, | ||
| ] | ||
| if len(set(resolved_devices)) == 1: | ||
| return "" | ||
|
|
||
| return ( | ||
| f"{label}: " | ||
| f"DiT={resolved_devices[0]}, " | ||
| f"VAE={resolved_devices[1]}, " | ||
| f"LM={resolved_devices[2]}" | ||
| ) | ||
|
|
||
|
|
||
| def validate_component_device_map(mapping: ComponentDeviceMap) -> None: | ||
| """Validate that mapped CUDA indices exist on the current host. | ||
|
|
||
| Args: | ||
| mapping: Component-to-device mapping to validate. | ||
|
|
||
| Raises: | ||
| ValueError: If any ``cuda:<idx>`` entry references an index out of range | ||
| for visible CUDA devices. | ||
| """ | ||
| cuda_count = torch.cuda.device_count() if torch is not None and torch.cuda.is_available() else 0 | ||
| for component, device in ( | ||
| ("dit", mapping.dit), | ||
| ("vae", mapping.vae), | ||
| ("lm", mapping.lm), | ||
| ): | ||
| if not device or not device.startswith("cuda:"): | ||
| continue | ||
| try: | ||
| idx = int(device.split(":", 1)[1]) | ||
| except (TypeError, ValueError) as exc: | ||
| raise ValueError( | ||
| f"Invalid {component} device '{device}': expected 'cuda:<int>' with " | ||
| f"0 <= idx < {cuda_count}." | ||
| ) from exc | ||
| if idx < 0 or idx >= cuda_count: | ||
| raise ValueError( | ||
| f"Invalid {component} device '{device}': only {cuda_count} CUDA device(s) available." | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| """Unit tests for multi-GPU component device mapping.""" | ||
|
|
||
| import os | ||
| import unittest | ||
| from unittest import mock | ||
|
|
||
| from acestep.core.generation import device_mapping as dm | ||
| from acestep.core.generation.device_mapping import ComponentDeviceMap | ||
|
|
||
| _ENV_KEYS = ("ACESTEP_DIT_DEVICE", "ACESTEP_VAE_DEVICE", "ACESTEP_LM_DEVICE") | ||
|
|
||
|
|
||
| class _FakeCuda: | ||
| def __init__(self, count, free_by_idx): | ||
| self._count = count | ||
| self._free = free_by_idx | ||
|
|
||
| def is_available(self): | ||
| return True | ||
|
|
||
| def device_count(self): | ||
| return self._count | ||
|
|
||
| def mem_get_info(self, idx=0): | ||
| return (self._free[idx], 16 * 1024 ** 3) | ||
|
|
||
|
|
||
| class _FakeTorch: | ||
| def __init__(self, count, free_by_idx): | ||
| self.cuda = _FakeCuda(count, free_by_idx) | ||
|
|
||
|
|
||
| def _clear_env(): | ||
| for k in _ENV_KEYS: | ||
| os.environ.pop(k, None) | ||
|
|
||
|
|
||
| class DeviceMappingTest(unittest.TestCase): | ||
| def setUp(self): | ||
| _clear_env() | ||
|
|
||
| def tearDown(self): | ||
| _clear_env() | ||
|
|
||
| def test_env_overrides_take_precedence_over_ranking(self): | ||
| fake = _FakeTorch(2, {0: 5 * 1024 ** 3, 1: 10 * 1024 ** 3}) | ||
| with mock.patch.object(dm, "torch", fake): | ||
| os.environ["ACESTEP_DIT_DEVICE"] = "cuda:0" | ||
| os.environ["ACESTEP_VAE_DEVICE"] = "cuda:0" | ||
| os.environ["ACESTEP_LM_DEVICE"] = "cuda:1" | ||
| m = dm.resolve_component_device_map() | ||
| self.assertEqual(m.dit, "cuda:0") | ||
| self.assertEqual(m.vae, "cuda:0") | ||
| self.assertEqual(m.lm, "cuda:1") | ||
|
|
||
| def test_auto_ranking_by_free_vram(self): | ||
| # idx 1 has more free VRAM -> DiT; idx 0 -> VAE and LM (2 GPUs). | ||
| fake = _FakeTorch(2, {0: 5 * 1024 ** 3, 1: 10 * 1024 ** 3}) | ||
| with mock.patch.object(dm, "torch", fake): | ||
| m = dm.resolve_component_device_map() | ||
| self.assertEqual(m.dit, "cuda:1") | ||
| self.assertEqual(m.vae, "cuda:0") | ||
| self.assertEqual(m.lm, "cuda:0") | ||
|
|
||
| def test_single_gpu_maps_all_to_same_device(self): | ||
| fake = _FakeTorch(1, {0: 8 * 1024 ** 3}) | ||
| with mock.patch.object(dm, "torch", fake): | ||
| m = dm.resolve_component_device_map() | ||
| self.assertEqual(m.dit, "cuda:0") | ||
| self.assertEqual(m.vae, "cuda:0") | ||
| self.assertEqual(m.lm, "cuda:0") | ||
|
|
||
| def test_no_cuda_returns_empty_map_without_env(self): | ||
| with mock.patch.object(dm, "torch", None): | ||
| m = dm.resolve_component_device_map() | ||
| self.assertEqual(m, ComponentDeviceMap()) | ||
|
|
||
| def test_validate_raises_on_out_of_range_index(self): | ||
| fake = _FakeTorch(2, {0: 1, 1: 1}) | ||
| with mock.patch.object(dm, "torch", fake): | ||
| with self.assertRaises(ValueError): | ||
| dm.validate_component_device_map(ComponentDeviceMap(dit="cuda:5")) | ||
|
|
||
| def test_validate_passes_on_valid_indices(self): | ||
| fake = _FakeTorch(2, {0: 1, 1: 1}) | ||
| with mock.patch.object(dm, "torch", fake): | ||
| dm.validate_component_device_map( | ||
| ComponentDeviceMap(dit="cuda:0", vae="cuda:0", lm="cuda:1") | ||
| ) | ||
|
|
||
| def test_hint_is_empty_when_single_device(self): | ||
| fake = _FakeTorch(1, {0: 8 * 1024 ** 3}) | ||
| with mock.patch.object(dm, "torch", fake): | ||
| self.assertEqual(dm.format_component_gpu_hint_text(), "") | ||
|
|
||
| def test_hint_non_empty_when_components_differ(self): | ||
| fake = _FakeTorch(2, {0: 5 * 1024 ** 3, 1: 10 * 1024 ** 3}) | ||
| with mock.patch.object(dm, "torch", fake): | ||
| hint = dm.format_component_gpu_hint_text(label="GPU map") | ||
| self.assertIn("DiT=cuda:1", hint) | ||
| self.assertIn("LM=cuda:0", hint) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.