Skip to content

Commit 331c168

Browse files
Merge upstream/main into report-signal-terminated-workers
Both sides added a bullet to the GB10 tuning list and git could not tell they were about different things. #1715 documents that FastVideo now disables the offload modes once a worker binds its device; this branch documents that earlyoom prefers Python and that a worker's SIGTERM traceback shows where it was interrupted, not why it was chosen. Neither replaces the other, so both are kept, offload first.
2 parents eeb3a28 + e9bbaca commit 331c168

12 files changed

Lines changed: 482 additions & 67 deletions

File tree

.buildkite/scripts/unit_test.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ exec pytest \
88
./fastvideo/tests/workflow/ \
99
./fastvideo/tests/entrypoints/ \
1010
./fastvideo/tests/loader/ \
11+
./fastvideo/tests/pipelines/ \
12+
./fastvideo/tests/platforms/ \
1113
./fastvideo/tests/train/ \
1214
./fastvideo/tests/stages/ \
1315
./fastvideo/tests/ops/ \

docs/getting_started/installation/spark_performance.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,11 @@ is power-cycled. To avoid it:
156156

157157
- **Builds** (flash-attn, kernel): `nice -n 19`, `MAX_JOBS=2`, `nohup`. Never a
158158
bare foreground high-parallelism build.
159-
- Leave `*_cpu_offload` at the example defaults — "CPU" offload is the *same*
160-
unified RAM on the GB10, so the win is tiling + sane resolution, not offloading.
159+
- FastVideo automatically disables DiT layerwise/CPU offload and encoder/VAE CPU
160+
offload after each worker binds its GB10 device. Do not force those modes back
161+
on: "CPU" offload uses the same unified RAM. Multi-GPU FSDP sharding remains
162+
available because it partitions weights without parking them in a separate
163+
host pool.
161164
- Some Spark images run `earlyoom` with a preference for terminating Python
162165
processes under memory pressure. A worker's SIGTERM log and traceback show
163166
where it was interrupted, not why it was selected; confirm the cause in the

docs/inference/offloading.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ vae_cpu_offload: bool = True
1414
pin_cpu_memory: bool = True
1515
```
1616

17+
On unified-memory accelerators such as NVIDIA GB10 and Apple silicon, FastVideo
18+
detects the selected device inside each worker and disables all five host-offload
19+
modes before loading modules. Host and accelerator allocations share one physical
20+
pool there, so offload adds transfers and duplicate residency instead of freeing
21+
memory. CUDA FSDP sharding remains enabled when requested; MPS continues to
22+
disable FSDP. `pin_cpu_memory` is not an offload mode and is left unchanged.
23+
1724
## Behavior Explanation
1825

1926
!!! note

fastvideo/fastvideo_args.py

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@
2525

2626
logger = init_logger(__name__)
2727

28-
# Offload flags that trade device memory for host memory. Keeping the policy
29-
# centralized lets components share the same worker-local device decision.
30-
UNIFIED_MEMORY_OFFLOAD_FLAGS = ("text_encoder_cpu_offload", )
28+
# Offload flags that trade device memory for host memory. All of them are a loss
29+
# on a device where the two are the same physical pool. Keeping the policy
30+
# centralized lets every loader and stage share one worker-local decision.
31+
UNIFIED_MEMORY_OFFLOAD_FLAGS = (
32+
"dit_layerwise_offload",
33+
"dit_cpu_offload",
34+
"text_encoder_cpu_offload",
35+
"image_encoder_cpu_offload",
36+
"vae_cpu_offload",
37+
)
3138

3239

3340
class ExecutionMode(str, Enum):
@@ -854,20 +861,6 @@ def from_kwargs(cls, **kwargs: Any) -> "FastVideoArgs":
854861

855862
def check_fastvideo_args(self) -> None:
856863
"""Validate inference arguments for consistency"""
857-
from fastvideo.platforms import current_platform
858-
859-
if current_platform.is_mps():
860-
self.use_fsdp_inference = False
861-
self.dit_layerwise_offload = False
862-
863-
if self.dit_layerwise_offload:
864-
if self.use_fsdp_inference:
865-
logger.warning("dit_layerwise_offload is enabled, automatically disabling use_fsdp_inference.")
866-
self.use_fsdp_inference = False
867-
if self.dit_cpu_offload:
868-
logger.warning("dit_layerwise_offload is enabled, automatically disabling dit_cpu_offload.")
869-
self.dit_cpu_offload = False
870-
871864
# Validate mode and inference_mode consistency
872865
assert isinstance(self.mode, ExecutionMode), f"Mode must be an ExecutionMode enum, got {type(self.mode)}"
873866
assert self.mode in ExecutionMode.choices(), f"Invalid execution mode: {self.mode}"
@@ -884,6 +877,14 @@ def check_fastvideo_args(self) -> None:
884877
logger.warning("Mode is '%s' but inference_mode is False. Setting inference_mode to True.", self.mode)
885878
self.inference_mode = True
886879

880+
# Inference policy must wait until a worker owns and binds its device:
881+
# a unified-memory device disables layerwise offload before conflicts
882+
# are resolved, preserving an explicit FSDP request. Training does not
883+
# pass through the inference worker boundary, so retain its historical
884+
# constructor-time normalization.
885+
if not self.inference_mode:
886+
self._resolve_device_offload_conflicts()
887+
887888
if not self.inference_mode:
888889
assert self.hsdp_replicate_dim != -1, "hsdp_replicate_dim must be set for training"
889890
assert self.hsdp_shard_dim != -1, "hsdp_shard_dim must be set for training"
@@ -918,6 +919,28 @@ def check_fastvideo_args(self) -> None:
918919
self.pipeline_config.vae_config.load_encoder = True
919920
self.preprocess_config.check_preprocess_config()
920921

922+
def _resolve_device_offload_conflicts(self) -> None:
923+
"""Resolve offload modes after device-local policy has been applied."""
924+
from fastvideo.platforms import current_platform
925+
926+
if current_platform.is_mps():
927+
self.use_fsdp_inference = False
928+
self.dit_layerwise_offload = False
929+
930+
if self.dit_layerwise_offload:
931+
if self.use_fsdp_inference:
932+
logger.warning("dit_layerwise_offload is enabled, automatically disabling use_fsdp_inference.")
933+
self.use_fsdp_inference = False
934+
if self.dit_cpu_offload:
935+
logger.warning("dit_layerwise_offload is enabled, automatically disabling dit_cpu_offload.")
936+
self.dit_cpu_offload = False
937+
938+
def finalize_device_offload_policy(self, device_id: int = 0) -> bool:
939+
"""Apply device-local memory policy, then resolve incompatible modes."""
940+
has_unified_memory = self.disable_offload_on_unified_memory(device_id)
941+
self._resolve_device_offload_conflicts()
942+
return has_unified_memory
943+
921944
def disable_offload_on_unified_memory(self, device_id: int = 0, *, offload_flag: str | None = None) -> bool:
922945
"""Disable host offload after a worker has selected its device.
923946
@@ -931,20 +954,29 @@ def disable_offload_on_unified_memory(self, device_id: int = 0, *, offload_flag:
931954
"""
932955
from fastvideo.platforms import current_platform
933956

934-
if not current_platform.has_unified_memory(device_id):
957+
cached_device_id = getattr(self, "_unified_memory_device_id", None)
958+
cached_result = getattr(self, "_unified_memory_result", None)
959+
if cached_device_id != device_id or cached_result is None:
960+
cached_result = current_platform.has_unified_memory(device_id)
961+
self._unified_memory_device_id = device_id
962+
self._unified_memory_result = cached_result
963+
964+
if not cached_result:
935965
return False
936966

937-
try:
938-
device_name = current_platform.get_device_name(device_id)
939-
except Exception:
940-
# Device naming is diagnostic only. NVML can be unavailable on an
941-
# integrated GPU (for example Jetson), and its physical-ordinal
942-
# lookup cannot interpret CUDA_VISIBLE_DEVICES UUID/MIG selectors.
943-
# Neither case should undo an authoritative driver classification.
944-
device_name = current_platform.device_name
945-
946-
for flag in UNIFIED_MEMORY_OFFLOAD_FLAGS:
947-
if getattr(self, flag):
967+
enabled_flags = [flag for flag in UNIFIED_MEMORY_OFFLOAD_FLAGS if getattr(self, flag)]
968+
if enabled_flags:
969+
try:
970+
device_name = current_platform.get_device_name(device_id)
971+
except Exception:
972+
# Device naming is diagnostic only. NVML can be unavailable on
973+
# an integrated GPU (for example Jetson), and its physical-
974+
# ordinal lookup cannot interpret CUDA_VISIBLE_DEVICES UUID/MIG
975+
# selectors. Neither case should undo an authoritative driver
976+
# classification.
977+
device_name = current_platform.device_name
978+
979+
for flag in enabled_flags:
948980
logger.info(
949981
"Disabling %s: %s has unified memory, so moving weights to the host duplicates "
950982
"them rather than freeing device memory.", flag, device_name)

fastvideo/models/loader/fsdp_load.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,14 @@ def load_model_from_full_model_state_dict(
545545
sharded_sd = {}
546546
custom_param_sd, reverse_param_names_mapping = hf_to_custom_state_dict(full_sd_iterator,
547547
param_names_mapping) # type: ignore
548-
for target_param_name, full_tensor in custom_param_sd.items():
548+
# Drain rather than iterate. Production safetensors values may retain
549+
# memory-mapped shard storage, while mapped or merged parameters can own
550+
# ordinary allocations. Keeping the dict retains all of that source
551+
# storage until loading finishes; popping releases each reference as soon
552+
# as its conversion completes and lowers the host/unified-memory working
553+
# set.
554+
for target_param_name in list(custom_param_sd):
555+
full_tensor = custom_param_sd.pop(target_param_name)
549556
meta_sharded_param = meta_sd.get(target_param_name)
550557
if meta_sharded_param is None:
551558
# Some checkpoints include extra entries that are not part of the

fastvideo/pipelines/composed_pipeline_base.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
import torch
1414

1515
from fastvideo.configs.pipelines import PipelineConfig
16-
from fastvideo.distributed import (maybe_init_distributed_environment_and_model_parallel, get_world_group)
16+
from fastvideo.distributed import (
17+
get_local_torch_device,
18+
get_world_group,
19+
maybe_init_distributed_environment_and_model_parallel,
20+
)
1721
from fastvideo.distributed.communication_op import (warmup_sequence_parallel_communication)
1822
from fastvideo.fastvideo_args import FastVideoArgs, TrainingArgs
1923
from fastvideo.hooks.activation_trace import attach_activation_trace, detach_activation_trace
@@ -90,6 +94,14 @@ def __init__(self,
9094

9195
maybe_init_distributed_environment_and_model_parallel(fastvideo_args.tp_size, fastvideo_args.sp_size)
9296

97+
# VideoGenerator applies this in each Worker before building the
98+
# pipeline. Keep direct from_pretrained/build_pipeline callers aligned,
99+
# but only after distributed setup has selected this process's device.
100+
if fastvideo_args.inference_mode:
101+
local_device = get_local_torch_device()
102+
device_id = local_device.index if local_device.index is not None else 0
103+
fastvideo_args.finalize_device_offload_policy(device_id)
104+
93105
# Torch profiler. Enabled and configured through env vars:
94106
# FASTVIDEO_TORCH_PROFILER_DIR=/path/to/save/trace
95107
trace_dir = envs.FASTVIDEO_TORCH_PROFILER_DIR
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""The loader must not hold the whole checkpoint alive while it copies it.
3+
4+
``hf_to_custom_state_dict`` drains the weight iterator into one dict before a
5+
single parameter is placed. Production safetensors values may retain
6+
memory-mapped shard storage, and mapped or merged parameters can own ordinary
7+
allocations. Keeping every source reference until loading finishes raises the
8+
host or unified-memory working set enough to kill a large-model load.
9+
10+
Checking after the call proves nothing, because the dict is a local and dies
11+
with the frame. So these tests keep a reference to that dict from the outside
12+
and assert it comes back empty: if the loader iterated instead of draining,
13+
every source tensor would still be reachable through the reference we hold.
14+
"""
15+
from __future__ import annotations
16+
17+
import gc
18+
import weakref
19+
20+
import pytest
21+
import torch
22+
from torch import nn
23+
24+
from fastvideo.models.loader import fsdp_load
25+
from fastvideo.models.loader.fsdp_load import load_model_from_full_model_state_dict
26+
27+
PARAM_NAMES = ("a.weight", "b.weight", "c.weight")
28+
29+
30+
class _TinyModel(nn.Module):
31+
"""Plain module, no device mesh, so the loader takes its unsharded path."""
32+
33+
def __init__(self) -> None:
34+
super().__init__()
35+
self.a = nn.Linear(8, 8, bias=False)
36+
self.b = nn.Linear(8, 8, bias=False)
37+
self.c = nn.Linear(8, 8, bias=False)
38+
39+
40+
def _identity_mapping(name: str) -> tuple[str, None, None]:
41+
return name, None, None
42+
43+
44+
def _source_tensors(scale: bool = False) -> dict[str, torch.Tensor]:
45+
# FP32 on purpose: the loader casts to param_dtype, and a cast is what makes
46+
# the copy a real copy. Handing it tensors that already match would let
47+
# `.to()` return the same object, and the model would then legitimately keep
48+
# the source alive for reasons that have nothing to do with this fix.
49+
return {
50+
name: torch.ones(8, 8, dtype=torch.float32) * (index + 1 if scale else 1)
51+
for index, name in enumerate(PARAM_NAMES)
52+
}
53+
54+
55+
def _capture_state_dict(monkeypatch) -> dict:
56+
"""Hold the loader's internal dict from outside so we can inspect it after."""
57+
captured: dict = {}
58+
real = fsdp_load.hf_to_custom_state_dict
59+
60+
def spy(*args, **kwargs):
61+
custom_param_sd, reverse = real(*args, **kwargs)
62+
captured["sd"] = custom_param_sd
63+
return custom_param_sd, reverse
64+
65+
monkeypatch.setattr(fsdp_load, "hf_to_custom_state_dict", spy)
66+
return captured
67+
68+
69+
def test_the_state_dict_is_drained_not_iterated(monkeypatch) -> None:
70+
captured = _capture_state_dict(monkeypatch)
71+
72+
load_model_from_full_model_state_dict(
73+
_TinyModel(),
74+
iter(list(_source_tensors().items())),
75+
torch.device("cpu"),
76+
torch.bfloat16,
77+
strict=False,
78+
param_names_mapping=_identity_mapping,
79+
training_mode=False,
80+
)
81+
82+
assert captured["sd"] == {}, ("the loader finished with the checkpoint still in hand; on a real model that is the "
83+
"whole file held resident for the length of the copy")
84+
85+
86+
@pytest.mark.parametrize(
87+
("skipped_name", "strict"),
88+
(("metadata._extra_state", True), ("unexpected.weight", False)),
89+
)
90+
def test_skipped_source_entry_is_popped_before_continue(monkeypatch, skipped_name: str, strict: bool) -> None:
91+
"""Both skip branches must drop their source before advancing the loop."""
92+
captured = _capture_state_dict(monkeypatch)
93+
warning_names = []
94+
95+
def assert_popped_before_warning(_message, warned_name) -> None:
96+
assert warned_name not in captured["sd"]
97+
warning_names.append(warned_name)
98+
99+
monkeypatch.setattr(fsdp_load.logger, "warning", assert_popped_before_warning)
100+
sources = {**_source_tensors(), skipped_name: torch.ones(8, 8)}
101+
102+
load_model_from_full_model_state_dict(
103+
_TinyModel(),
104+
iter(sources.items()),
105+
torch.device("cpu"),
106+
torch.bfloat16,
107+
strict=strict,
108+
param_names_mapping=_identity_mapping,
109+
training_mode=False,
110+
)
111+
112+
assert warning_names == [skipped_name]
113+
assert captured["sd"] == {}
114+
115+
116+
def test_source_tensors_become_collectable(monkeypatch) -> None:
117+
"""The reason the drain matters: the tensors have to actually go."""
118+
captured = _capture_state_dict(monkeypatch)
119+
sources = _source_tensors()
120+
refs = {name: weakref.ref(tensor) for name, tensor in sources.items()}
121+
122+
# Mirror safetensors_weights_iterator, which drops each tensor as it yields.
123+
def iterator():
124+
while sources:
125+
yield sources.popitem()
126+
127+
load_model_from_full_model_state_dict(
128+
_TinyModel(),
129+
iterator(),
130+
torch.device("cpu"),
131+
torch.bfloat16,
132+
strict=False,
133+
param_names_mapping=_identity_mapping,
134+
training_mode=False,
135+
)
136+
137+
# captured["sd"] is still in scope here on purpose: it is the reference that
138+
# would keep them alive if the loader had not popped.
139+
gc.collect()
140+
alive = sorted(name for name, ref in refs.items() if ref() is not None)
141+
assert not alive, f"still reachable through the loader's state dict: {alive}"
142+
143+
144+
def test_weights_still_land_in_the_model() -> None:
145+
"""Releasing early must not cost correctness."""
146+
sources = _source_tensors(scale=True)
147+
expected = {name: tensor[0, 0].item() for name, tensor in sources.items()}
148+
149+
model = _TinyModel()
150+
load_model_from_full_model_state_dict(
151+
model,
152+
iter(list(sources.items())),
153+
torch.device("cpu"),
154+
torch.bfloat16,
155+
strict=False,
156+
param_names_mapping=_identity_mapping,
157+
training_mode=False,
158+
)
159+
160+
loaded = dict(model.named_parameters())
161+
for name, value in expected.items():
162+
assert loaded[name].dtype == torch.bfloat16
163+
assert loaded[name][0, 0].item() == value

fastvideo/tests/loader/test_text_encoder_unified_memory_offload.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# SPDX-License-Identifier: Apache-2.0
2-
"""Regression tests for text-encoder placement on unified memory."""
2+
"""Regression tests for encoder placement on unified memory."""
33
from __future__ import annotations
44

55
from types import SimpleNamespace
@@ -84,7 +84,7 @@ def test_discrete_memory_preserves_explicit_cpu_target(monkeypatch, tmp_path) ->
8484
assert _PassthroughEncoder.loaded_device == torch.device("cpu")
8585

8686

87-
def test_text_policy_does_not_change_inherited_image_encoder_path(monkeypatch, tmp_path) -> None:
87+
def test_image_encoder_explicit_offload_resets_cpu_target(monkeypatch, tmp_path) -> None:
8888
probe = Mock(return_value=True)
8989
monkeypatch.setattr("fastvideo.models.loader.component_loader.get_local_torch_device",
9090
lambda: torch.device("cuda:4"))
@@ -109,7 +109,7 @@ def test_text_policy_does_not_change_inherited_image_encoder_path(monkeypatch, t
109109
offload_flag="image_encoder_cpu_offload",
110110
)
111111

112-
assert _PassthroughEncoder.loaded_device == torch.device("cpu")
112+
assert _PassthroughEncoder.loaded_device == torch.device("cuda:4")
113113
assert args.text_encoder_cpu_offload is False
114-
assert args.image_encoder_cpu_offload is True
114+
assert args.image_encoder_cpu_offload is False
115115
probe.assert_called_once_with(4)

0 commit comments

Comments
 (0)