Skip to content

Commit 3ac3a76

Browse files
yingufanfacebook-github-bot
authored andcommitted
Fix per-feature boundary lost by ZCH index dedup (#4525)
Summary: `fbgemm.jagged_unique_indices` dedups a whole hash range at once, and `_create_dedup_indices` puts every feature of a table into one range, so the op returns the group's total unique count spread evenly over the group's (feature, batch) slots rather than the real per-feature counts. A table binding a single feature is unaffected. A table binding two features of very unequal length gets ids relabeled across the boundary. Managed collision modules read the feature an id arrives under as a control signal, so this is a correctness bug rather than just wrong bookkeeping. On IG Reels ESR the `media_embbedding_cache` HASH_ZCH table binds a ~33 long write feature and a ~1280 long `_readonly` history feature; both come back as ~656, so roughly 620 history ids per step are relabeled as candidates and inserted past the read-only gate. The table reaches 100% occupancy within ~100 steps and, with eviction disabled, only collides from then on. Re-attribute every surviving row to the first feature it appeared in and regroup the values so each feature is contiguous again. Rows shared by two features still collapse into one, so the dedup saving is kept. Gated on a table actually binding more than one feature, so the common path is untouched. Only per-feature totals are made exact: the layout within a feature stays approximate, as it already was, because reverse_indices undoes it downstream. Also declares the `reverse_indices` field that `_dedup_indices` already appends to. `mc_modules` shadows `EmbeddingCollectionContext` with a local dataclass that lacks it, so a standalone sharded `ManagedCollisionCollection` with dedup on raised `AttributeError`; only the `ManagedCollisionEmbeddingCollection` path worked, since that context derives from the `embedding.py` class instead. Differential Revision: D115457342
1 parent 70dfd94 commit 3ac3a76

2 files changed

Lines changed: 308 additions & 1 deletion

File tree

torchrec/distributed/mc_modules.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class EmbeddingCollectionContext(Multistreamable):
9595
Union[InferSequenceShardingContext, SequenceShardingContext]
9696
]
9797
input_features: List[KeyedJaggedTensor] = field(default_factory=list)
98+
reverse_indices: List[torch.Tensor] = field(default_factory=list)
9899
# VBE-Attributes for EBC
99100
inverse_indices: Optional[Tuple[List[str], torch.Tensor]] = None
100101
variable_batch_per_feature: bool = False
@@ -106,6 +107,8 @@ def record_stream(self, stream: torch.Stream) -> None:
106107
for f in self.input_features:
107108
# pyrefly: ignore[bad-argument-type]
108109
f.record_stream(stream)
110+
for r in self.reverse_indices:
111+
r.record_stream(stream)
109112
if self.inverse_indices is not None:
110113
self.inverse_indices[1].record_stream(stream)
111114

@@ -205,6 +208,80 @@ def create_mc_sharding(
205208
raise ValueError(f"Sharding not supported {sharding_type}")
206209

207210

211+
def _restore_dedup_feature_boundary(
212+
input_lengths: torch.Tensor,
213+
num_features: int,
214+
unique_indices: torch.Tensor,
215+
reverse_indices: torch.Tensor,
216+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
217+
"""
218+
Recovers the per-feature boundary that ``fbgemm.jagged_unique_indices`` drops.
219+
220+
Every feature of a table is given the same hash range by
221+
``_create_dedup_indices``, so the op dedups all of a table's features as one
222+
group. It has no way to tell which feature a surviving row came from, and
223+
returns the group's total unique count spread evenly over the group's
224+
(feature, batch) slots. A table binding a single feature is unaffected, but for
225+
a table binding two features of very unequal length the even split silently
226+
relabels ids as belonging to the other feature: a 33-long and a 1280-long
227+
feature both come back as ~656.
228+
229+
Managed collision modules read the feature an id arrives under as a control
230+
signal - read-only gating and per-feature eviction both key off it - so the
231+
relabeled ids get written to tables they should only have been read from.
232+
233+
Every surviving row is re-attributed to the first feature it appeared in and the
234+
values regrouped so each feature is contiguous again. Rows shared by two
235+
features still collapse into one, so the dedup saving is kept.
236+
237+
Returns the corrected ``(lengths, unique_indices, reverse_indices)``.
238+
"""
239+
device = unique_indices.device
240+
num_unique = unique_indices.numel()
241+
# lengths are int64 to match what the op returns
242+
if num_unique == 0:
243+
return (
244+
torch.zeros(input_lengths.numel(), dtype=torch.int64, device=device),
245+
unique_indices,
246+
reverse_indices,
247+
)
248+
249+
stride = input_lengths.numel() // num_features
250+
# KJT lengths are feature major, so segment -> feature is a plain repeat.
251+
# output_size is known, and passing it keeps repeat_interleave from syncing.
252+
feature_per_value = torch.repeat_interleave(
253+
torch.arange(num_features, device=device).repeat_interleave(stride),
254+
input_lengths.long(),
255+
output_size=reverse_indices.numel(),
256+
)
257+
# include_self=False so the initial value is ignored wherever a row was actually
258+
# referenced. Every unique row has at least one source position, but falling back
259+
# to feature 0 keeps an unreferenced row in range for the scatter_add_ below
260+
# rather than letting it index out of bounds.
261+
feature_per_unique = torch.zeros(
262+
num_unique, dtype=torch.int64, device=device
263+
).scatter_reduce_(
264+
0, reverse_indices, feature_per_value, reduce="amin", include_self=False
265+
)
266+
267+
permute = torch.argsort(feature_per_unique, stable=True)
268+
inverse_permute = torch.empty_like(permute)
269+
inverse_permute[permute] = torch.arange(num_unique, device=device)
270+
271+
# scatter_add rather than bincount, which would force a device to host sync
272+
counts = torch.zeros(num_features, dtype=torch.int64, device=device).scatter_add_(
273+
0, feature_per_unique, torch.ones_like(feature_per_unique)
274+
)
275+
# spread each feature's count over its own batch slots, the same convention the
276+
# op already applies within a single feature. Only the per-feature totals carry
277+
# meaning: the layout inside a feature is undone by reverse_indices downstream.
278+
lengths = counts.div(stride, rounding_mode="floor").repeat_interleave(stride) + (
279+
torch.arange(stride, device=device).repeat(num_features)
280+
< (counts % stride).repeat_interleave(stride)
281+
)
282+
return lengths, unique_indices[permute], inverse_permute[reverse_indices]
283+
284+
208285
class ShardedManagedCollisionCollection(
209286
ShardedModule[
210287
KJTList,
@@ -648,6 +725,12 @@ def _create_dedup_indices(self) -> None:
648725
)[-1]
649726
<= torch.iinfo(torch.int64).max
650727
), "EC Dedup requires the mc collection to have a cumuluative 'hash_input_size' kwarg to be less than max int64. Please reduce values of individual tables to meet this constraint (ie. 2**54 is typically a good value)."
728+
# per sharding group, true when any of its tables binds more than one feature
729+
# and so loses its feature boundary, see _restore_dedup_feature_boundary
730+
self._dedup_restore_feature_boundary: List[bool] = [
731+
any(feature_count > 1 for feature_count in feature_splits)
732+
for feature_splits in self._sharding_per_table_feature_splits
733+
]
651734
for i, (feature_splits, input_splits) in enumerate(
652735
zip(
653736
self._sharding_per_table_feature_splits,
@@ -688,6 +771,11 @@ def _dedup_indices(
688771
features_by_sharding = []
689772

690773
for i, kjt in enumerate(features):
774+
# jagged_unique_indices derives the batch size as total_B / num_features,
775+
# and the per-feature regrouping below relies on the same uniform stride
776+
assert (
777+
not kjt.variable_stride_per_key()
778+
), "EC Dedup does not support variable stride per key"
691779
hash_offsets = self.get_buffer(f"_dedup_hash_offsets_{i}")
692780
feature_offsets = self.get_buffer(f"_dedup_feature_offsets_{i}")
693781
(
@@ -701,6 +789,13 @@ def _dedup_indices(
701789
kjt.offsets().to(torch.int64),
702790
kjt.values().to(torch.int64),
703791
)
792+
if self._dedup_restore_feature_boundary[i]:
793+
lengths, unique_indices, reverse_indices = (
794+
_restore_dedup_feature_boundary(
795+
kjt.lengths(), len(kjt.keys()), unique_indices, reverse_indices
796+
)
797+
)
798+
offsets = torch.ops.fbgemm.asynchronous_complete_cumsum(lengths)
704799
# Gather weights for unique indices if present.
705800
# Since unique_indices[reverse_indices[i]] == indices[i],
706801
# we can directly assign: dedup_weights[reverse_indices[i]] = weights[i]
@@ -723,7 +818,6 @@ def _dedup_indices(
723818
)
724819

725820
ctx.input_features.append(kjt)
726-
# pyrefly: ignore[missing-attribute]
727821
ctx.reverse_indices.append(reverse_indices)
728822
features_by_sharding.append(dedup_features)
729823
return features_by_sharding
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
# All rights reserved.
4+
#
5+
# This source code is licensed under the BSD-style license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
8+
# pyre-strict
9+
10+
import unittest
11+
from typing import cast, Dict, List, Tuple
12+
13+
import torch
14+
from torchrec.distributed.mc_modules import (
15+
_restore_dedup_feature_boundary,
16+
ManagedCollisionCollectionContext,
17+
ShardedManagedCollisionCollection,
18+
)
19+
from torchrec.sparse.jagged_tensor import KeyedJaggedTensor
20+
21+
_HASH_SIZE = 100_000
22+
_STRIDE = 2 # batch size
23+
24+
25+
def _dedup(
26+
values: torch.Tensor, lengths: torch.Tensor, num_features: int
27+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
28+
"""
29+
Runs the dedup op with the buffers ``_create_dedup_indices`` builds for a single
30+
table binding ``num_features`` features: one shared hash range, one feature group.
31+
"""
32+
device = values.device
33+
hash_offsets = torch.tensor(
34+
[0] * num_features + [_HASH_SIZE], dtype=torch.int64, device=device
35+
)
36+
feature_offsets = torch.tensor(
37+
[0] * num_features + [num_features], dtype=torch.int64, device=device
38+
)
39+
lengths_, _, unique_indices, reverse_indices = (
40+
torch.ops.fbgemm.jagged_unique_indices(
41+
hash_offsets,
42+
feature_offsets,
43+
torch.ops.fbgemm.asynchronous_complete_cumsum(lengths),
44+
values,
45+
)
46+
)
47+
return lengths_, unique_indices, reverse_indices
48+
49+
50+
def _per_feature(lengths: torch.Tensor, num_features: int) -> List[int]:
51+
return lengths.view(num_features, -1).sum(dim=1).tolist()
52+
53+
54+
def _lengths(counts: List[int], device: torch.device) -> torch.Tensor:
55+
"""Per-feature totals -> a feature major KJT lengths tensor of stride _STRIDE."""
56+
return torch.tensor(
57+
[
58+
count // _STRIDE + (1 if b < count % _STRIDE else 0)
59+
for count in counts
60+
for b in range(_STRIDE)
61+
],
62+
dtype=torch.int64,
63+
device=device,
64+
)
65+
66+
67+
class _DedupOnlyCollection:
68+
"""
69+
Carries just the state ``ShardedManagedCollisionCollection._dedup_indices`` reads
70+
off ``self``, so the method can be exercised without standing up a sharded module.
71+
"""
72+
73+
def __init__(self, num_features: int, device: torch.device) -> None:
74+
self._buffers: Dict[str, torch.Tensor] = {
75+
"_dedup_hash_offsets_0": torch.tensor(
76+
[0] * num_features + [_HASH_SIZE], dtype=torch.int64, device=device
77+
),
78+
"_dedup_feature_offsets_0": torch.tensor(
79+
[0] * num_features + [num_features], dtype=torch.int64, device=device
80+
),
81+
}
82+
self._dedup_restore_feature_boundary: List[bool] = [num_features > 1]
83+
84+
def get_buffer(self, name: str) -> torch.Tensor:
85+
return self._buffers[name]
86+
87+
88+
@unittest.skipIf(
89+
not torch.cuda.is_available(), "fbgemm.jagged_unique_indices is CUDA only"
90+
)
91+
class McDedupFeatureBoundaryTest(unittest.TestCase):
92+
"""
93+
A managed collision table that binds more than one feature gives all of its
94+
features one shared hash range, so ``fbgemm.jagged_unique_indices`` dedups them as
95+
a single group and cannot say which feature a surviving row came from. These tests
96+
cover the boundary that ``_restore_dedup_feature_boundary`` puts back.
97+
98+
Shapes mirror the IG Reels ESR memory layer, where a HASH_ZCH table binds a short
99+
write feature of candidate ids and a long read-only feature of user history.
100+
"""
101+
102+
def setUp(self) -> None:
103+
self.device = torch.device("cuda")
104+
torch.manual_seed(0)
105+
self.ids: torch.Tensor = torch.randperm(_HASH_SIZE, device=self.device)
106+
107+
def test_dedup_op_loses_feature_boundary(self) -> None:
108+
"""
109+
Pins the upstream behavior being corrected: the op returns the group's unique
110+
count spread evenly over the group's slots rather than the real per-feature
111+
counts. If this fails, the op has learned per-feature attribution and
112+
``_restore_dedup_feature_boundary`` can be dropped.
113+
"""
114+
write_len, read_len = 33, 1280
115+
values = self.ids[: write_len + read_len]
116+
lengths, unique_indices, _ = _dedup(
117+
values, _lengths([write_len, read_len], self.device), 2
118+
)
119+
120+
self.assertEqual(unique_indices.numel(), write_len + read_len)
121+
self.assertEqual(_per_feature(lengths, 2), [657, 656])
122+
123+
def test_restore_feature_boundary(self) -> None:
124+
write_len, read_len = 33, 1280
125+
values = self.ids[: write_len + read_len]
126+
input_lengths = _lengths([write_len, read_len], self.device)
127+
_, unique_indices, reverse_indices = _dedup(values, input_lengths, 2)
128+
129+
lengths, unique_indices, reverse_indices = _restore_dedup_feature_boundary(
130+
input_lengths, 2, unique_indices, reverse_indices
131+
)
132+
133+
self.assertEqual(_per_feature(lengths, 2), [write_len, read_len])
134+
# the write feature's segment holds exactly the ids it sent, nothing borrowed
135+
# from the read-only feature
136+
torch.testing.assert_close(
137+
unique_indices[:write_len].sort().values, values[:write_len].sort().values
138+
)
139+
torch.testing.assert_close(unique_indices[reverse_indices], values)
140+
141+
# the 2D weights of the write path are gathered through reverse_indices, so
142+
# they have to survive the regrouping too
143+
weights = torch.rand(values.numel(), 4, device=self.device)
144+
dedup_weights = torch.empty(
145+
unique_indices.numel(), 4, dtype=weights.dtype, device=self.device
146+
)
147+
dedup_weights[reverse_indices] = weights
148+
torch.testing.assert_close(dedup_weights[reverse_indices], weights)
149+
150+
def test_restore_feature_boundary_with_duplicate_and_shared_ids(self) -> None:
151+
"""
152+
Dedup still collapses repeats, including ids sent by both features. A shared id
153+
is attributed to the first feature that carried it, which is the write feature,
154+
so a candidate that is also in history is still inserted rather than gated.
155+
"""
156+
write_len, hist_len, shared_len = 33, 600, 5
157+
write_ids = self.ids[:write_len]
158+
hist_ids = self.ids[write_len : write_len + hist_len]
159+
read_values = torch.cat([hist_ids, hist_ids, write_ids[:shared_len]])
160+
values = torch.cat([write_ids, read_values])
161+
input_lengths = _lengths([write_len, read_values.numel()], self.device)
162+
_, unique_indices, reverse_indices = _dedup(values, input_lengths, 2)
163+
164+
lengths, unique_indices, reverse_indices = _restore_dedup_feature_boundary(
165+
input_lengths, 2, unique_indices, reverse_indices
166+
)
167+
168+
self.assertEqual(unique_indices.numel(), write_len + hist_len)
169+
self.assertEqual(_per_feature(lengths, 2), [write_len, hist_len])
170+
torch.testing.assert_close(
171+
unique_indices[:write_len].sort().values, write_ids.sort().values
172+
)
173+
torch.testing.assert_close(unique_indices[reverse_indices], values)
174+
175+
def test_dedup_indices_keeps_feature_boundary(self) -> None:
176+
"""End to end through ``_dedup_indices``, covering the wiring and the gate."""
177+
write_len, read_len = 33, 1280
178+
kjt = KeyedJaggedTensor(
179+
keys=["media_embbedding_cache", "merged_events_item_id_cache_readonly"],
180+
values=self.ids[: write_len + read_len],
181+
lengths=_lengths([write_len, read_len], self.device),
182+
)
183+
ctx = ManagedCollisionCollectionContext(sharding_contexts=[])
184+
185+
dedup_kjt = ShardedManagedCollisionCollection._dedup_indices(
186+
cast(
187+
ShardedManagedCollisionCollection, _DedupOnlyCollection(2, self.device)
188+
),
189+
ctx,
190+
[kjt],
191+
)[0]
192+
193+
self.assertEqual(dedup_kjt.length_per_key(), [write_len, read_len])
194+
torch.testing.assert_close(
195+
dedup_kjt["media_embbedding_cache"].values().sort().values,
196+
kjt["media_embbedding_cache"].values().sort().values,
197+
)
198+
torch.testing.assert_close(
199+
dedup_kjt.values()[ctx.reverse_indices[0]], kjt.values()
200+
)
201+
202+
def test_restore_feature_boundary_with_three_features(self) -> None:
203+
counts = [11, 517, 42]
204+
values = self.ids[: sum(counts)]
205+
input_lengths = _lengths(counts, self.device)
206+
_, unique_indices, reverse_indices = _dedup(values, input_lengths, 3)
207+
208+
lengths, unique_indices, reverse_indices = _restore_dedup_feature_boundary(
209+
input_lengths, 3, unique_indices, reverse_indices
210+
)
211+
212+
self.assertEqual(_per_feature(lengths, 3), counts)
213+
torch.testing.assert_close(unique_indices[reverse_indices], values)

0 commit comments

Comments
 (0)