Skip to content

Commit 53ccb68

Browse files
Add Multi-GPU Support (#62)
* Add torchrun DDP support for trainer pipelines * support dual parallel modes with torchrun and device schedulers * rename parallel_training key and remove legacy parallel wrappers * add user guide for parallel training modes * rename guide to multi-gpu training and clarify two modes * add strict local rank to visible GPU validation for ddp * ud * Update multi-gpu-training.md * ud * ud * ud * fix * ud * ud * udate docs * Update training-parallelization.md * ud * ud * ud * Update iac.py * clear redundant detach
1 parent f45f366 commit 53ccb68

21 files changed

Lines changed: 1158 additions & 299 deletions

comlrl/schedulers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .device_scheduler import DeviceScheduler
2+
3+
__all__ = ["DeviceScheduler"]
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import annotations
2+
3+
from typing import Iterable, List, Optional, Sequence, Tuple, Union
4+
5+
import torch
6+
7+
DeviceSpec = Union[str, Sequence[str]]
8+
9+
10+
class DeviceScheduler:
11+
@staticmethod
12+
def assign_devices(
13+
num_agents: int,
14+
agent_devices: Optional[DeviceSpec],
15+
critic_devices: Optional[DeviceSpec],
16+
*,
17+
use_separate_critic: bool,
18+
) -> Tuple[List[torch.device], List[torch.device]]:
19+
agent_list = DeviceScheduler.resolve_devices(
20+
agent_devices, num_agents, kind="agent_devices"
21+
)
22+
if use_separate_critic:
23+
critic_spec = (
24+
critic_devices if critic_devices is not None else agent_devices
25+
)
26+
critic_list = DeviceScheduler.resolve_devices(
27+
critic_spec, num_agents, kind="critic_devices"
28+
)
29+
else:
30+
critic_list = list(agent_list)
31+
return agent_list, critic_list
32+
33+
@staticmethod
34+
def assign_shared_critic_device(
35+
agent_devices: Sequence[torch.device],
36+
critic_devices: Optional[DeviceSpec],
37+
) -> torch.device:
38+
if critic_devices is None:
39+
return agent_devices[0]
40+
return DeviceScheduler.resolve_devices(
41+
critic_devices, 1, kind="critic_devices"
42+
)[0]
43+
44+
@staticmethod
45+
def resolve_devices(
46+
spec: Optional[DeviceSpec],
47+
num_devices: int,
48+
*,
49+
kind: str = "devices",
50+
) -> List[torch.device]:
51+
if num_devices < 1:
52+
raise ValueError(f"{kind} count must be >= 1.")
53+
54+
if spec is None or (isinstance(spec, str) and spec.lower() == "auto"):
55+
return DeviceScheduler._auto_devices(num_devices)
56+
57+
if isinstance(spec, str):
58+
return [torch.device(spec)] * num_devices
59+
60+
if isinstance(spec, Sequence):
61+
if len(spec) == 0:
62+
raise ValueError(f"{kind} must be a non-empty list or 'auto'.")
63+
if len(spec) == 1:
64+
return [torch.device(spec[0])] * num_devices
65+
if len(spec) != num_devices:
66+
raise ValueError(
67+
f"{kind} length ({len(spec)}) must be 1 or {num_devices}."
68+
)
69+
return [torch.device(s) for s in spec]
70+
71+
raise ValueError(f"Unsupported {kind} spec: {spec!r}.")
72+
73+
@staticmethod
74+
def resolve_single_device(*specs: Optional[DeviceSpec]) -> torch.device:
75+
for spec in specs:
76+
if spec is None:
77+
continue
78+
if isinstance(spec, str):
79+
if spec.lower() == "auto":
80+
return DeviceScheduler._auto_devices(1)[0]
81+
return torch.device(spec)
82+
if isinstance(spec, Sequence):
83+
if len(spec) == 0:
84+
raise ValueError("Device spec list must be non-empty.")
85+
first = spec[0]
86+
if isinstance(first, str) and first.lower() == "auto":
87+
return DeviceScheduler._auto_devices(1)[0]
88+
return torch.device(first)
89+
raise ValueError(f"Unsupported device spec: {spec!r}.")
90+
return DeviceScheduler._auto_devices(1)[0]
91+
92+
@staticmethod
93+
def devices_disjoint(device_groups: Iterable[Sequence[torch.device]]) -> bool:
94+
seen = set()
95+
for group in device_groups:
96+
for device in group:
97+
key = DeviceScheduler._device_key(device)
98+
if key in seen:
99+
return False
100+
seen.add(key)
101+
return True
102+
103+
@staticmethod
104+
def _device_key(device: torch.device) -> str:
105+
if device.type == "cuda":
106+
return f"cuda:{device.index}"
107+
return device.type
108+
109+
@staticmethod
110+
def _auto_devices(num_devices: int) -> List[torch.device]:
111+
if not torch.cuda.is_available():
112+
return [torch.device("cpu")] * num_devices
113+
114+
count = int(torch.cuda.device_count())
115+
if count < 1:
116+
return [torch.device("cpu")] * num_devices
117+
if count < num_devices:
118+
return [torch.device("cuda:0")] * num_devices
119+
120+
indices = list(range(count))
121+
return [torch.device(f"cuda:{idx}") for idx in indices[:num_devices]]

comlrl/trainers/actor_critic/ac_base.py

Lines changed: 120 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import defaultdict
22
from typing import Any, Dict, List, Optional
3+
from concurrent.futures import ThreadPoolExecutor, as_completed
34

45
import torch
56
import wandb
@@ -10,6 +11,51 @@
1011
class ActorCriticTrainerBase:
1112
"""Shared training utilities for actor-critic style trainers."""
1213

14+
def _parallel_agent_mode_enabled(self) -> bool:
15+
if str(getattr(self, "parallel_training", "")).lower() != "mp":
16+
return False
17+
num_agents = int(getattr(getattr(self, "args", None), "num_agents", 0) or 0)
18+
if num_agents <= 1:
19+
return False
20+
devices = getattr(self, "agent_devices", None)
21+
if not devices:
22+
return True
23+
unique = {str(device) for device in devices}
24+
return len(unique) > 1
25+
26+
def _run_agent_tasks(
27+
self,
28+
fn,
29+
*,
30+
agent_indices: Optional[List[int]] = None,
31+
parallel: Optional[bool] = None,
32+
) -> List[Any]:
33+
num_agents = int(getattr(getattr(self, "args", None), "num_agents", 0) or 0)
34+
indices = (
35+
list(agent_indices)
36+
if agent_indices is not None
37+
else list(range(max(num_agents, 0)))
38+
)
39+
if not indices:
40+
return []
41+
42+
use_parallel = (
43+
self._parallel_agent_mode_enabled() if parallel is None else bool(parallel)
44+
)
45+
if not use_parallel or len(indices) <= 1:
46+
return [fn(agent_idx) for agent_idx in indices]
47+
48+
results: Dict[int, Any] = {}
49+
max_workers = len(indices)
50+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
51+
futures = {
52+
executor.submit(fn, agent_idx): agent_idx for agent_idx in indices
53+
}
54+
for future in as_completed(futures):
55+
agent_idx = futures[future]
56+
results[agent_idx] = future.result()
57+
return [results[agent_idx] for agent_idx in indices]
58+
1359
def _filter_model_kwargs(self, cfg: Optional[Dict[str, Any]]) -> Dict[str, Any]:
1460
torch_dtype = None
1561
if isinstance(cfg, dict):
@@ -64,16 +110,18 @@ def _encode_prompt(
64110
prompt: str,
65111
agent_idx: Optional[int] = None,
66112
tokenizer: Optional[Any] = None,
113+
device: Optional[torch.device] = None,
67114
) -> Dict[str, torch.Tensor]:
68115
tokenizer = tokenizer or self._get_tokenizer(agent_idx)
69116
encoded = tokenizer(
70117
prompt,
71118
return_tensors="pt",
72119
truncation=True,
73120
)
121+
target_device = device or self.device
74122
return {
75-
"input_ids": encoded["input_ids"].to(self.device),
76-
"attention_mask": encoded["attention_mask"].to(self.device),
123+
"input_ids": encoded["input_ids"].to(target_device),
124+
"attention_mask": encoded["attention_mask"].to(target_device),
77125
}
78126

79127
def _prepare_advantages(self, rollouts: List[Any]) -> None:
@@ -139,7 +187,9 @@ def _summarize_rollout_metrics(self, rollouts: List[Any]) -> Dict[str, float]:
139187
return metrics
140188

141189
def _iter_dataloader(self, dataloader, epoch: int, total_epochs: int):
142-
if getattr(self, "verbose", True):
190+
dist_env = getattr(self, "dist_env", None)
191+
is_main = bool(getattr(dist_env, "is_main", True))
192+
if getattr(self, "verbose", True) and is_main:
143193
return enumerate(
144194
tqdm(
145195
dataloader,
@@ -165,7 +215,12 @@ def _on_epoch_end(
165215
epoch_metrics: Dict[str, List[float]],
166216
) -> None:
167217
summary = self._summarize_epoch_metrics(epoch_metrics)
168-
if summary and getattr(self, "verbose", True):
218+
dist_env = getattr(self, "dist_env", None)
219+
if (
220+
summary
221+
and getattr(self, "verbose", True)
222+
and getattr(dist_env, "is_main", True)
223+
):
169224
print(f"Epoch {epoch + 1}/{total_epochs} metrics: {summary}")
170225

171226
def _tag_metrics(
@@ -197,10 +252,9 @@ def _process_buffer(
197252
self,
198253
agent_idx: int,
199254
buffer: List[Any],
200-
epoch_metrics: Dict[str, List[float]],
201-
) -> None:
255+
) -> Dict[str, Any]:
202256
if not buffer:
203-
return
257+
return {"metric_values": {}, "log_metrics": {}}
204258

205259
has_turn_idx = any(
206260
"turn_idx" in (getattr(s, "metadata", {}) or {}) for s in buffer
@@ -213,35 +267,74 @@ def _process_buffer(
213267
buffer.clear()
214268

215269
combined_log: Dict[str, float] = {}
270+
metric_values: Dict[str, List[float]] = {}
216271
for t_idx in sorted(turn_groups.keys()):
217272
samples = turn_groups[t_idx]
218273
metrics = self._update(agent_idx, samples)
219274
tagged = self._tag_metrics(metrics, agent_idx, turn_idx=t_idx)
220275
combined_log.update(tagged)
221276
for key, value in tagged.items():
222-
epoch_metrics[key].append(value)
277+
metric_values.setdefault(key, []).append(value)
278+
return {"metric_values": metric_values, "log_metrics": combined_log}
279+
280+
def _drain_ready_agent_buffers(
281+
self,
282+
ready_agents: List[int],
283+
epoch_metrics: Dict[str, List[float]],
284+
) -> None:
285+
if not ready_agents:
286+
return
287+
288+
unique_ready = sorted({int(idx) for idx in ready_agents})
289+
run_parallel = bool(
290+
getattr(
291+
self,
292+
"_parallel_update_enabled",
293+
self._parallel_agent_mode_enabled(),
294+
)
295+
)
296+
297+
def _process(agent_idx: int) -> Dict[str, Any]:
298+
return self._process_buffer(agent_idx, self.rollout_buffers[agent_idx])
299+
300+
results = self._run_agent_tasks(
301+
_process,
302+
agent_indices=unique_ready,
303+
parallel=run_parallel,
304+
)
305+
306+
combined_log: Dict[str, float] = {}
307+
for result in results:
308+
metric_values = result.get("metric_values", {})
309+
for key, values in metric_values.items():
310+
for value in values:
311+
epoch_metrics[key].append(value)
312+
combined_log.update(result.get("log_metrics", {}))
223313

224314
if combined_log and self._should_log_train():
225315
self._log_metrics(combined_log)
226316

227317
def _run_batch(self, batch, epoch_metrics: Dict[str, List[float]]) -> None:
228318
for item in batch:
229319
rollouts = self._collect_rollouts(item)
320+
ready_agents: List[int] = []
230321
for sample in rollouts:
231322
agent_idx = sample.agent_idx
232323
buffer = self.rollout_buffers[agent_idx]
233324
buffer.append(sample)
234325
if len(buffer) >= self.args.rollout_buffer_size:
235-
self._process_buffer(agent_idx, buffer, epoch_metrics)
326+
ready_agents.append(agent_idx)
327+
if ready_agents:
328+
self._drain_ready_agent_buffers(ready_agents, epoch_metrics)
236329
if self.args.num_agents > 0:
237330
# Count joint-action reward evaluations (one per agent group).
238331
self.env_step += len(rollouts) // self.args.num_agents
239332

240333
def _flush_buffers(self, epoch_metrics: Dict[str, List[float]]) -> None:
241-
for agent_idx, buffer in enumerate(self.rollout_buffers):
242-
if not buffer:
243-
continue
244-
self._process_buffer(agent_idx, buffer, epoch_metrics)
334+
ready_agents = [
335+
agent_idx for agent_idx, buffer in enumerate(self.rollout_buffers) if buffer
336+
]
337+
self._drain_ready_agent_buffers(ready_agents, epoch_metrics)
245338

246339
def get_train_dataloader(self) -> DataLoader:
247340
if self.train_dataset is None:
@@ -275,18 +368,22 @@ def evaluate(self) -> Dict[str, float]:
275368
turn_groups: Dict[int, List[Any]] = {}
276369
seen = 0
277370

278-
with torch.no_grad():
279-
for batch in dataloader:
280-
for item in batch:
281-
rollouts = self._collect_rollouts(item)
282-
for sample in rollouts:
283-
t_idx = int(sample.metadata.get("turn_idx", 0))
284-
turn_groups.setdefault(t_idx, []).append(sample)
285-
seen += 1
371+
self._in_eval = True
372+
try:
373+
with torch.no_grad():
374+
for batch in dataloader:
375+
for item in batch:
376+
rollouts = self._collect_rollouts(item)
377+
for sample in rollouts:
378+
t_idx = int(sample.metadata.get("turn_idx", 0))
379+
turn_groups.setdefault(t_idx, []).append(sample)
380+
seen += 1
381+
if seen >= num_samples:
382+
break
286383
if seen >= num_samples:
287384
break
288-
if seen >= num_samples:
289-
break
385+
finally:
386+
self._in_eval = False
290387

291388
eval_log: Dict[str, float] = {}
292389
for turn_idx, samples in sorted(turn_groups.items()):

0 commit comments

Comments
 (0)