Skip to content

Commit c4b683f

Browse files
jeffkbkimfacebook-github-bot
authored andcommitted
Skip shape exchange for fixed metric states (#4535)
Summary: Reduce distributed RecMetric synchronization overhead when metric state shapes are fixed across ranks. Metrics with variable state shapes and custom synchronization retain their existing behavior. Differential Revision: D115732360
1 parent 4dfb3f1 commit c4b683f

3 files changed

Lines changed: 243 additions & 6 deletions

File tree

torchrec/metrics/rec_metric.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
import abc
1111
import inspect
1212
import itertools
13+
import logging
1314
import math
1415
import weakref
1516
from collections import defaultdict, deque
1617
from dataclasses import dataclass
1718
from enum import Enum
19+
from functools import partial
1820
from typing import (
1921
Any,
2022
Callable,
@@ -38,6 +40,7 @@
3840
import torch.nn as nn
3941
from torch.profiler import record_function
4042
from torchmetrics import Metric
43+
from torchmetrics.utilities.distributed import gather_all_tensors
4144

4245
try:
4346
from torchrec.distributed.logging_handlers import (
@@ -72,6 +75,8 @@ def n_batch_log_event(*args: object, **kwargs: object) -> None:
7275

7376
RecModelOutput = Union[torch.Tensor, Dict[str, torch.Tensor]]
7477

78+
logger: logging.Logger = logging.getLogger(__name__)
79+
7580

7681
@dataclass(frozen=True)
7782
class MetricComputationReport:
@@ -87,6 +92,8 @@ class MetricComputationReport:
8792
]
8893

8994
MAX_BUFFER_COUNT = 1000
95+
_FIXED_SHAPE_SYNC_KILLSWITCH = "pytorch/torchrec:disable_fixed_shape_metric_sync"
96+
_FIXED_SHAPE_GATHER = partial(gather_all_tensors, assume_same_shape=True)
9097
_WINDOW_BUFFER_REGISTRY: weakref.WeakValueDictionary[int, Any] = (
9198
torch.__dict__.setdefault(
9299
"_torchrec_window_buffer_registry", weakref.WeakValueDictionary()
@@ -112,6 +119,19 @@ def _window_buffer_aggregate_state(
112119
)
113120

114121

122+
def _supports_fixed_shape_sync(dist_sync_fn: Callable) -> bool:
123+
try:
124+
return "assume_same_shape" in inspect.signature(dist_sync_fn).parameters
125+
except (TypeError, ValueError):
126+
return False
127+
128+
129+
def _fixed_shape_sync_enabled() -> bool:
130+
return not torch._utils_internal.justknobs_check(
131+
_FIXED_SHAPE_SYNC_KILLSWITCH, default=False
132+
)
133+
134+
115135
class RecMetricException(Exception):
116136
pass
117137

@@ -229,6 +249,52 @@ def __init__(
229249
persistent=True,
230250
)
231251
self._compute_mode: RecComputeMode = compute_mode
252+
self._logged_dist_sync_paths: Set[str] = set()
253+
254+
def _state_shapes_match_defaults(self) -> bool:
255+
defaults: Optional[Mapping[str, Any]] = getattr(self, "_defaults", None)
256+
if not defaults:
257+
return False
258+
for name, default in defaults.items():
259+
state = getattr(self, name)
260+
if not isinstance(default, torch.Tensor) or not isinstance(
261+
state, torch.Tensor
262+
):
263+
return False
264+
if state.shape != default.shape or state.dtype != default.dtype:
265+
return False
266+
return True
267+
268+
def _sync_dist(
269+
self,
270+
dist_sync_fn: Callable = gather_all_tensors,
271+
process_group: Optional[Any] = None,
272+
) -> None:
273+
uses_default_sync = dist_sync_fn is gather_all_tensors
274+
uses_fixed_shape_sync = (
275+
uses_default_sync
276+
and _supports_fixed_shape_sync(dist_sync_fn)
277+
and self._state_shapes_match_defaults()
278+
and _fixed_shape_sync_enabled()
279+
)
280+
if uses_fixed_shape_sync:
281+
dist_sync_fn = _FIXED_SHAPE_GATHER
282+
sync_path = "fixed_shape"
283+
elif uses_default_sync:
284+
sync_path = "variable_shape"
285+
else:
286+
sync_path = "custom"
287+
if sync_path not in self._logged_dist_sync_paths:
288+
if self._my_rank == 0:
289+
logger.info(
290+
"RecMetric distributed sync path: metric=%s path=%s states=%d",
291+
type(self).__name__,
292+
sync_path,
293+
len(getattr(self, "_defaults", {})),
294+
)
295+
self._logged_dist_sync_paths.add(sync_path)
296+
297+
super()._sync_dist(dist_sync_fn, process_group)
232298

233299
@staticmethod
234300
def get_window_state_name(state_name: str) -> str:

torchrec/metrics/tests/test_auc.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# pyre-strict
99

1010
import unittest
11-
from typing import Dict, Iterable, List, Optional, Type, Union
11+
from typing import cast, Dict, Iterable, List, Optional, Type, Union
1212
from unittest.mock import patch
1313

1414
import torch
@@ -18,6 +18,7 @@
1818
from torchrec.metrics.rec_metric import (
1919
RecComputeMode,
2020
RecMetric,
21+
RecMetricComputation,
2122
RecMetricException,
2223
RecTaskInfo,
2324
)
@@ -133,9 +134,10 @@ def test_auc_compile(self) -> None:
133134
# module attributes as dynamic to isolate the AUC list-length recompiles,
134135
# then assert the recompile limit is never hit so this stays a regression
135136
# guard.
136-
with patch.object(
137-
torch._dynamo.config, "allow_unspec_int_on_nn_module", True
138-
), patch.object(torch._dynamo.config, "fail_on_recompile_limit_hit", True):
137+
with (
138+
patch.object(torch._dynamo.config, "allow_unspec_int_on_nn_module", True),
139+
patch.object(torch._dynamo.config, "fail_on_recompile_limit_hit", True),
140+
):
139141
for _ in range(10):
140142
compiled_update(
141143
predictions={DefaultTaskInfo.name: model_output["predictions"][0]},
@@ -167,6 +169,24 @@ class AUCMetricTest(unittest.TestCase):
167169
clazz: Type[RecMetric] = AUCMetric
168170
task_name: str = "auc"
169171

172+
def test_variable_shape_sync_path(self) -> None:
173+
auc = AUCMetric(
174+
world_size=1,
175+
my_rank=0,
176+
batch_size=1,
177+
tasks=[DefaultTaskInfo],
178+
)
179+
computation = cast(RecMetricComputation, auc._metrics_computations[0])
180+
181+
with (
182+
patch.object(torch.distributed, "get_world_size", return_value=1),
183+
patch.object(torch.distributed, "barrier") as barrier,
184+
patch.object(torch.distributed, "all_gather"),
185+
):
186+
computation.sync(distributed_available=lambda: True)
187+
188+
barrier.assert_called()
189+
170190
def test_unfused_auc(self) -> None:
171191
rec_metric_value_test_launcher(
172192
target_clazz=AUCMetric,

torchrec/metrics/tests/test_ne.py

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,19 @@
99

1010
import unittest
1111
from functools import partial, update_wrapper
12-
from typing import Callable, Dict, Optional, Type
12+
from typing import Any, Callable, cast, Dict, List, Optional, Type
13+
from unittest.mock import patch
1314

1415
import torch
16+
import torchrec.metrics.rec_metric as rec_metric
17+
from torchrec.metrics.metrics_config import DefaultTaskInfo
1518
from torchrec.metrics.ne import (
1619
compute_cross_entropy,
1720
compute_logloss,
1821
compute_ne,
1922
NEMetric,
2023
)
21-
from torchrec.metrics.rec_metric import RecComputeMode, RecMetric
24+
from torchrec.metrics.rec_metric import RecComputeMode, RecMetric, RecMetricComputation
2225
from torchrec.metrics.test_utils import (
2326
metric_test_helper,
2427
rec_metric_gpu_sync_test_launcher,
@@ -115,6 +118,154 @@ class NEMetricTest(unittest.TestCase):
115118
target_compute_mode: RecComputeMode = RecComputeMode.UNFUSED_TASKS_COMPUTATION
116119
task_name: str = "ne"
117120

121+
@staticmethod
122+
def _set_distinct_sync_states(
123+
computation: RecMetricComputation,
124+
) -> Dict[str, torch.Tensor]:
125+
defaults = cast(Dict[str, Any], cast(Any, computation)._defaults)
126+
expected: Dict[str, torch.Tensor] = {}
127+
for value, name in enumerate(defaults, start=1):
128+
state = cast(torch.Tensor, getattr(computation, name))
129+
state.fill_(value)
130+
expected[name] = state.clone()
131+
return expected
132+
133+
def test_fixed_shape_sync_path(self) -> None:
134+
ne = NEMetric(
135+
world_size=1,
136+
my_rank=0,
137+
batch_size=1,
138+
tasks=[DefaultTaskInfo],
139+
)
140+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
141+
142+
with (
143+
patch.object(
144+
torch._utils_internal, "justknobs_check", return_value=False
145+
) as justknobs_check,
146+
patch.object(torch.distributed, "get_world_size", return_value=1),
147+
patch.object(torch.distributed, "barrier") as barrier,
148+
patch.object(torch.distributed, "all_gather") as all_gather,
149+
):
150+
computation.sync(distributed_available=lambda: True)
151+
152+
barrier.assert_not_called()
153+
self.assertGreater(all_gather.call_count, 0)
154+
justknobs_check.assert_called_once_with(
155+
"pytorch/torchrec:disable_fixed_shape_metric_sync", default=False
156+
)
157+
158+
def test_fixed_shape_sync_killswitch_uses_variable_shape_path(self) -> None:
159+
ne = NEMetric(
160+
world_size=1,
161+
my_rank=0,
162+
batch_size=1,
163+
tasks=[DefaultTaskInfo],
164+
)
165+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
166+
167+
with (
168+
patch.object(torch._utils_internal, "justknobs_check", return_value=True),
169+
patch.object(torch.distributed, "get_world_size", return_value=1),
170+
patch.object(torch.distributed, "barrier") as barrier,
171+
patch.object(torch.distributed, "all_gather"),
172+
):
173+
computation.sync(distributed_available=lambda: True)
174+
175+
barrier.assert_called()
176+
177+
def test_runtime_shape_change_uses_variable_shape_path(self) -> None:
178+
ne = NEMetric(
179+
world_size=1,
180+
my_rank=0,
181+
batch_size=1,
182+
tasks=[DefaultTaskInfo],
183+
)
184+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
185+
cast(Any, computation).cross_entropy_sum = torch.zeros(2, dtype=torch.double)
186+
187+
with (
188+
patch.object(torch._utils_internal, "justknobs_check", return_value=True),
189+
patch.object(torch.distributed, "get_world_size", return_value=1),
190+
patch.object(torch.distributed, "barrier") as barrier,
191+
patch.object(torch.distributed, "all_gather"),
192+
):
193+
computation.sync(distributed_available=lambda: True)
194+
195+
barrier.assert_called()
196+
197+
def test_missing_state_defaults_disables_fixed_shape_sync(self) -> None:
198+
ne = NEMetric(
199+
world_size=1,
200+
my_rank=0,
201+
batch_size=1,
202+
tasks=[DefaultTaskInfo],
203+
)
204+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
205+
defaults = cast(Any, computation)._defaults
206+
delattr(computation, "_defaults")
207+
try:
208+
self.assertFalse(computation._state_shapes_match_defaults())
209+
finally:
210+
cast(Any, computation)._defaults = defaults
211+
212+
def test_upstream_gather_without_fixed_shape_support_is_unchanged(self) -> None:
213+
ne = NEMetric(
214+
world_size=1,
215+
my_rank=0,
216+
batch_size=1,
217+
tasks=[DefaultTaskInfo],
218+
)
219+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
220+
expected = self._set_distinct_sync_states(computation)
221+
synced_tensors: List[torch.Tensor] = []
222+
223+
def upstream_gather(
224+
tensor: torch.Tensor, group: Optional[Any] = None
225+
) -> List[torch.Tensor]:
226+
synced_tensors.append(tensor)
227+
return [tensor]
228+
229+
with patch.object(rec_metric, "gather_all_tensors", upstream_gather):
230+
computation.sync(
231+
dist_sync_fn=upstream_gather,
232+
distributed_available=lambda: True,
233+
)
234+
235+
self.assertEqual(len(synced_tensors), len(expected))
236+
for actual, expected_tensor in zip(synced_tensors, expected.values()):
237+
torch.testing.assert_close(actual, expected_tensor)
238+
for name, expected_tensor in expected.items():
239+
torch.testing.assert_close(getattr(computation, name), expected_tensor)
240+
241+
def test_custom_sync_path_is_unchanged(self) -> None:
242+
ne = NEMetric(
243+
world_size=1,
244+
my_rank=0,
245+
batch_size=1,
246+
tasks=[DefaultTaskInfo],
247+
)
248+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
249+
expected = self._set_distinct_sync_states(computation)
250+
synced_tensors: List[torch.Tensor] = []
251+
252+
def custom_sync(
253+
tensor: torch.Tensor, group: Optional[Any] = None
254+
) -> List[torch.Tensor]:
255+
synced_tensors.append(tensor)
256+
return [tensor]
257+
258+
computation.sync(
259+
dist_sync_fn=custom_sync,
260+
distributed_available=lambda: True,
261+
)
262+
263+
self.assertEqual(len(synced_tensors), len(expected))
264+
for actual, expected_tensor in zip(synced_tensors, expected.values()):
265+
torch.testing.assert_close(actual, expected_tensor)
266+
for name, expected_tensor in expected.items():
267+
torch.testing.assert_close(getattr(computation, name), expected_tensor)
268+
118269
def test_ne_unfused(self) -> None:
119270
rec_metric_value_test_launcher(
120271
target_clazz=NEMetric,

0 commit comments

Comments
 (0)