Skip to content

Commit 306be19

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 de38faa commit 306be19

3 files changed

Lines changed: 234 additions & 6 deletions

File tree

torchrec/metrics/rec_metric.py

Lines changed: 62 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_JK = "pytorch/torchrec:enable_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,13 @@ 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+
115129
class RecMetricException(Exception):
116130
pass
117131

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

233295
@staticmethod
234296
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: 148 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,149 @@ 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(torch._utils_internal, "justknobs_check", return_value=True),
144+
patch.object(torch.distributed, "get_world_size", return_value=1),
145+
patch.object(torch.distributed, "barrier") as barrier,
146+
patch.object(torch.distributed, "all_gather") as all_gather,
147+
):
148+
computation.sync(distributed_available=lambda: True)
149+
150+
barrier.assert_not_called()
151+
self.assertGreater(all_gather.call_count, 0)
152+
153+
def test_fixed_shape_sync_killswitch_uses_variable_shape_path(self) -> None:
154+
ne = NEMetric(
155+
world_size=1,
156+
my_rank=0,
157+
batch_size=1,
158+
tasks=[DefaultTaskInfo],
159+
)
160+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
161+
162+
with (
163+
patch.object(torch._utils_internal, "justknobs_check", return_value=False),
164+
patch.object(torch.distributed, "get_world_size", return_value=1),
165+
patch.object(torch.distributed, "barrier") as barrier,
166+
patch.object(torch.distributed, "all_gather"),
167+
):
168+
computation.sync(distributed_available=lambda: True)
169+
170+
barrier.assert_called()
171+
172+
def test_runtime_shape_change_uses_variable_shape_path(self) -> None:
173+
ne = NEMetric(
174+
world_size=1,
175+
my_rank=0,
176+
batch_size=1,
177+
tasks=[DefaultTaskInfo],
178+
)
179+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
180+
cast(Any, computation).cross_entropy_sum = torch.zeros(2, dtype=torch.double)
181+
182+
with (
183+
patch.object(torch._utils_internal, "justknobs_check", return_value=True),
184+
patch.object(torch.distributed, "get_world_size", return_value=1),
185+
patch.object(torch.distributed, "barrier") as barrier,
186+
patch.object(torch.distributed, "all_gather"),
187+
):
188+
computation.sync(distributed_available=lambda: True)
189+
190+
barrier.assert_called()
191+
192+
def test_missing_state_defaults_disables_fixed_shape_sync(self) -> None:
193+
ne = NEMetric(
194+
world_size=1,
195+
my_rank=0,
196+
batch_size=1,
197+
tasks=[DefaultTaskInfo],
198+
)
199+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
200+
defaults = cast(Any, computation)._defaults
201+
delattr(computation, "_defaults")
202+
try:
203+
self.assertFalse(computation._state_shapes_match_defaults())
204+
finally:
205+
cast(Any, computation)._defaults = defaults
206+
207+
def test_upstream_gather_without_fixed_shape_support_is_unchanged(self) -> None:
208+
ne = NEMetric(
209+
world_size=1,
210+
my_rank=0,
211+
batch_size=1,
212+
tasks=[DefaultTaskInfo],
213+
)
214+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
215+
expected = self._set_distinct_sync_states(computation)
216+
synced_tensors: List[torch.Tensor] = []
217+
218+
def upstream_gather(
219+
tensor: torch.Tensor, group: Optional[Any] = None
220+
) -> List[torch.Tensor]:
221+
synced_tensors.append(tensor)
222+
return [tensor]
223+
224+
with patch.object(rec_metric, "gather_all_tensors", upstream_gather):
225+
computation.sync(
226+
dist_sync_fn=upstream_gather,
227+
distributed_available=lambda: True,
228+
)
229+
230+
self.assertEqual(len(synced_tensors), len(expected))
231+
for actual, expected_tensor in zip(synced_tensors, expected.values()):
232+
torch.testing.assert_close(actual, expected_tensor)
233+
for name, expected_tensor in expected.items():
234+
torch.testing.assert_close(getattr(computation, name), expected_tensor)
235+
236+
def test_custom_sync_path_is_unchanged(self) -> None:
237+
ne = NEMetric(
238+
world_size=1,
239+
my_rank=0,
240+
batch_size=1,
241+
tasks=[DefaultTaskInfo],
242+
)
243+
computation = cast(RecMetricComputation, ne._metrics_computations[0])
244+
expected = self._set_distinct_sync_states(computation)
245+
synced_tensors: List[torch.Tensor] = []
246+
247+
def custom_sync(
248+
tensor: torch.Tensor, group: Optional[Any] = None
249+
) -> List[torch.Tensor]:
250+
synced_tensors.append(tensor)
251+
return [tensor]
252+
253+
computation.sync(
254+
dist_sync_fn=custom_sync,
255+
distributed_available=lambda: True,
256+
)
257+
258+
self.assertEqual(len(synced_tensors), len(expected))
259+
for actual, expected_tensor in zip(synced_tensors, expected.values()):
260+
torch.testing.assert_close(actual, expected_tensor)
261+
for name, expected_tensor in expected.items():
262+
torch.testing.assert_close(getattr(computation, name), expected_tensor)
263+
118264
def test_ne_unfused(self) -> None:
119265
rec_metric_value_test_launcher(
120266
target_clazz=NEMetric,

0 commit comments

Comments
 (0)