Skip to content

Commit 09612ef

Browse files
committed
Add offline time-window indices for Yambda
Build stable indices and offsets during cache preparation when enabled. Reuse read-only slices for streaming windows and offsets for sample counts, with serialized construction across ranks.
1 parent b8b3adc commit 09612ef

5 files changed

Lines changed: 199 additions & 0 deletions

File tree

‎recommendation/README.MD‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,30 @@ env overrides:
383383
| `EVAL_EVERY_DATA_PCT` | `streaming_train_eval_loop.eval_every_data_pct` | 0.0 | eval cadence by fraction of train data (full ref / RCP: 0.001) |
384384
| `MIN_HISTORY` | `get_dataset.min_history` | 4086 | anchor-eligibility floor (0 = ~all users incl. cold-start) |
385385

386+
#### Offline time-window indices
387+
388+
To bucketize anchors by time window, set this in
389+
[`yambda_5b.gin`](generative_recommenders/dlrm_v4/train/gin/yambda_5b.gin):
390+
391+
```gin
392+
get_dataset.use_offline_ts_indices = True
393+
```
394+
395+
The default is `False`. When enabled, `ts_indices_*.npy` and `ts_offsets_*.npy`
396+
are **built automatically on the first run** during dataset initialization,
397+
alongside the positions cache. This also works with an existing cache folder
398+
that lacks these files. Later runs reuse them.
399+
400+
For read-only data mounts, build them beforehand from an existing cache:
401+
402+
```bash
403+
python3 -m generative_recommenders.dlrm_v4.datasets.offline_ts_indices \
404+
--cache-dir "$DLRM_DATA_PATH/processed_5b/hstu_cache_L4086" \
405+
--history-length 4086 --min-history 4086 --window-seconds 86400
406+
```
407+
408+
Use the same history and window settings as training.
409+
386410
### Test data order
387411

388412
The evaluation window is traversed once per evaluation, in the same in-window
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
7+
"""Bucketize Yambda anchors by time window and save TS indices and offsets."""
8+
9+
import argparse
10+
import fcntl
11+
import os
12+
import tempfile
13+
from pathlib import Path
14+
15+
import numpy as np
16+
17+
18+
def build_offline_ts_indices(
19+
cache_dir, history_length, min_history=None, window_seconds=86400,
20+
chunk_size=1_000_000,
21+
):
22+
"""Bucketize anchors by time window, preserving their order within each bucket."""
23+
if history_length <= 0 or window_seconds <= 0 or chunk_size <= 0:
24+
raise ValueError("history_length, window_seconds and chunk_size must be positive")
25+
if min_history is not None and min_history < 0:
26+
raise ValueError("min_history must be nonnegative")
27+
cache_dir = Path(cache_dir)
28+
tag = f"L{history_length}"
29+
if min_history is not None and min_history != history_length:
30+
tag += f"_m{min_history}"
31+
# Keep derived files with their source cache so separate caches cannot collide.
32+
indices_path = cache_dir / f"ts_indices_{tag}_W{window_seconds}.npy"
33+
offsets_path = cache_dir / f"ts_offsets_{tag}_W{window_seconds}.npy"
34+
# Complete caches bypass the build lock, as do training-time index reads.
35+
if indices_path.exists() and offsets_path.exists():
36+
return indices_path, offsets_path
37+
38+
# Each rank calls this during dataset initialization. Reuse the positions
39+
# lock so only one rank builds missing output, avoiding duplicate work and
40+
# concurrent writes to the same files without adding a separate TS lock.
41+
with open(cache_dir / "_positions_lock", "a") as lock:
42+
fcntl.flock(lock, fcntl.LOCK_EX)
43+
# Another rank may have finished building while this rank waited.
44+
if indices_path.exists() and offsets_path.exists():
45+
return indices_path, offsets_path
46+
positions = np.load(cache_dir / f"positions_{tag}.npy", mmap_mode="r")
47+
anchor_path = cache_dir / f"anchor_ts_{tag}.npy"
48+
has_anchor_ts = anchor_path.exists()
49+
timestamps = np.load(
50+
anchor_path if has_anchor_ts else cache_dir / "flat_timestamps.npy", mmap_mode="r"
51+
)
52+
if any(array.ndim != 1 or array.dtype != np.int64 for array in (positions, timestamps)):
53+
raise ValueError("positions and timestamps must be one-dimensional int64 arrays")
54+
count = len(positions)
55+
if has_anchor_ts and len(timestamps) != count:
56+
raise ValueError("Anchor timestamps and positions have different lengths")
57+
58+
def timestamp_chunk(start):
59+
stop = min(start + chunk_size, count)
60+
return timestamps[start:stop] if has_anchor_ts else timestamps[positions[start:stop]]
61+
62+
t_min, t_max = 0, -1
63+
for start in range(0, count, chunk_size):
64+
chunk = timestamp_chunk(start)
65+
t_min = int(chunk.min()) if start == 0 else min(t_min, int(chunk.min()))
66+
t_max = int(chunk.max()) if start == 0 else max(t_max, int(chunk.max()))
67+
if t_max - t_min > np.iinfo(np.int64).max:
68+
raise ValueError("Timestamp span exceeds int64 capacity")
69+
windows = (t_max - t_min) // window_seconds + 1 if count else 0
70+
counts = np.zeros(windows, dtype=np.int64)
71+
for start in range(0, count, chunk_size):
72+
buckets = (timestamp_chunk(start) - t_min) // window_seconds
73+
counts += np.bincount(buckets, minlength=windows)
74+
offsets = np.concatenate(([0], np.cumsum(counts)))
75+
cursors = offsets[:-1].copy()
76+
with tempfile.TemporaryDirectory(prefix=".ts-indices-", dir=cache_dir) as tmp:
77+
staging = Path(tmp)
78+
indices = np.lib.format.open_memmap(
79+
staging / indices_path.name, mode="w+", dtype=np.int64, shape=(count,)
80+
)
81+
try:
82+
for start in range(0, count, chunk_size):
83+
buckets = (timestamp_chunk(start) - t_min) // window_seconds
84+
order = np.argsort(buckets, kind="stable")
85+
sizes = np.bincount(buckets, minlength=windows)
86+
begin = 0
87+
for bucket in np.flatnonzero(sizes):
88+
end = begin + int(sizes[bucket])
89+
cursor = int(cursors[bucket])
90+
indices[cursor:cursor + end - begin] = order[begin:end] + start
91+
cursors[bucket] += end - begin
92+
begin = end
93+
indices.flush()
94+
finally:
95+
indices._mmap.close()
96+
np.save(staging / offsets_path.name, offsets)
97+
# Publish offsets last so an interrupted build cannot appear complete.
98+
offsets_path.unlink(missing_ok=True)
99+
os.replace(staging / indices_path.name, indices_path)
100+
os.replace(staging / offsets_path.name, offsets_path)
101+
return indices_path, offsets_path
102+
103+
104+
if __name__ == "__main__":
105+
parser = argparse.ArgumentParser(description=__doc__)
106+
parser.add_argument("--cache-dir", required=True)
107+
parser.add_argument("--history-length", type=int, required=True)
108+
parser.add_argument("--min-history", type=int)
109+
parser.add_argument("--window-seconds", type=int, default=86400)
110+
parser.add_argument("--chunk-size", type=int, default=1_000_000)
111+
print(*build_offline_ts_indices(**vars(parser.parse_args())), sep="\n")

‎recommendation/generative_recommenders/dlrm_v4/datasets/yambda.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ def __init__(
219219
streaming_shuffle_seed: int = 0,
220220
train_split_percentage: float = 1.0,
221221
split_salt: int = 0,
222+
use_offline_ts_indices: bool = False,
222223
*args,
223224
**kwargs,
224225
) -> None:
@@ -283,6 +284,9 @@ def __init__(
283284
self._active: Optional[np.ndarray] = None
284285
self.is_eval: bool = False
285286
self._anchor_ts: Optional[np.ndarray] = None
287+
self._use_offline_ts_indices = use_offline_ts_indices
288+
self._ts_indices: Optional[np.ndarray] = None
289+
self._ts_offsets: Optional[np.ndarray] = None
286290
self._t_min: Optional[int] = None
287291
self._t_max: Optional[int] = None
288292
self._cache_dir: Optional[str] = cache_dir
@@ -315,6 +319,15 @@ def __init__(
315319
self._ensure_positions_built(
316320
cache_dir, self._positions_name, self._min_history
317321
)
322+
if self._use_offline_ts_indices:
323+
from generative_recommenders.dlrm_v4.datasets.offline_ts_indices import (
324+
build_offline_ts_indices,
325+
)
326+
327+
build_offline_ts_indices(
328+
cache_dir, history_length, self._min_history,
329+
window_seconds=self._streaming_window_seconds,
330+
)
318331
self._positions: np.ndarray = _load_npy_readonly(
319332
os.path.join(cache_dir, self._positions_name)
320333
)
@@ -596,6 +609,25 @@ def _ensure_streaming_index(self) -> None:
596609
Multi-rank safe via an exclusive file lock + atomic rename; all ranks
597610
then mmap the result read-only (shared physical pages, ~0 anon).
598611
"""
612+
if self._use_offline_ts_indices:
613+
if self._ts_indices is None:
614+
directory = Path(self._cache_dir)
615+
tag = self._positions_name[len("positions_"):-4]
616+
tag += f"_W{self._streaming_window_seconds}"
617+
indices = _load_npy_readonly(directory / f"ts_indices_{tag}.npy")
618+
offsets = _load_npy_readonly(directory / f"ts_offsets_{tag}.npy")
619+
if (
620+
indices.dtype != np.int64 or indices.ndim != 1
621+
or len(indices) != len(self._positions)
622+
or offsets.dtype != np.int64 or offsets.ndim != 1
623+
or len(offsets) == 0 or offsets[0] != 0
624+
or offsets[-1] != len(indices)
625+
or np.any(offsets[1:] < offsets[:-1])
626+
):
627+
raise ValueError("Invalid offline TS indices or offsets")
628+
self._ts_offsets = offsets
629+
self._ts_indices = indices
630+
return
599631
if self._anchor_ts is not None:
600632
return
601633
import fcntl
@@ -626,9 +658,21 @@ def _ensure_streaming_index(self) -> None:
626658
self._t_min = int(self._anchor_ts.min())
627659
self._t_max = int(self._anchor_ts.max())
628660

661+
def _offline_window_bounds(self, start_ts: int, num_ts: int = 1) -> Tuple[int, int]:
662+
windows = len(self._ts_offsets) - 1
663+
start = min(windows, max(0, start_ts))
664+
end = min(windows, max(0, start_ts + max(0, num_ts)))
665+
return int(self._ts_offsets[start]), int(self._ts_offsets[end])
666+
667+
def _offline_window_indices(self, start_ts: int, num_ts: int = 1) -> np.ndarray:
668+
lo, hi = self._offline_window_bounds(start_ts, num_ts)
669+
return self._ts_indices[lo:hi]
670+
629671
def num_windows(self) -> int:
630672
"""Number of fixed-duration windows spanning [t_min, t_max]."""
631673
self._ensure_streaming_index()
674+
if self._ts_offsets is not None:
675+
return len(self._ts_offsets) - 1
632676
assert self._t_min is not None and self._t_max is not None
633677
span = self._t_max - self._t_min + 1
634678
w = self._streaming_window_seconds
@@ -653,6 +697,13 @@ def window_indices(
653697
O(N) mask here keeps only one ~26 GB array resident and is robust.
654698
"""
655699
self._ensure_streaming_index()
700+
if self._ts_indices is not None:
701+
idx = self._offline_window_indices(ts)
702+
do_sort = self._streaming_sort_within_window if sort_by_time is None else sort_by_time
703+
if do_sort and idx.size:
704+
timestamps = self.store.flat_timestamps[self._positions[idx]]
705+
idx = idx[np.argsort(timestamps, kind="stable")]
706+
return idx
656707
assert self._anchor_ts is not None and self._t_min is not None
657708
w = self._streaming_window_seconds
658709
lo = self._t_min + ts * w
@@ -778,6 +829,15 @@ def total_train_anchors(self, start_ts: int, num_ts: int) -> int:
778829
by a fraction of a window).
779830
"""
780831
self._ensure_streaming_index()
832+
if self._ts_indices is not None:
833+
lo, hi = self._offline_window_bounds(start_ts, num_ts)
834+
if self._train_split_percentage >= 1.0:
835+
return hi - lo
836+
indices = self._ts_indices[lo:hi]
837+
return sum(
838+
int(np.count_nonzero(~self._eval_anchor_mask(indices[start:start + 1_000_000])))
839+
for start in range(0, len(indices), 1_000_000)
840+
)
781841
assert self._anchor_ts is not None and self._t_min is not None
782842
if num_ts <= 0:
783843
return 0

‎recommendation/generative_recommenders/dlrm_v4/train/gin/yambda_5b.gin‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,8 @@ nl/env_int.default = 3
528528
# to the dataset's available window count at runtime; override via $NUM_TRAIN_TS.
529529
get_dataset.streaming_window_seconds = 86400
530530
get_dataset.streaming_sort_within_window = False
531+
# Bucketize anchors by time window on first use and reuse the cached indices.
532+
get_dataset.use_offline_ts_indices = False
531533
# In-window shuffle to break user-major batching (consecutive sliding-window
532534
# anchors otherwise come from the same few users -> few unique embedding reads).
533535
# Diversity dial in [0,1] -- the AGREED, config-invariant benchmark knob. Maps to

‎recommendation/generative_recommenders/dlrm_v4/utils.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,6 +1634,7 @@ def get_dataset(
16341634
streaming_shuffle_seed: int = 0,
16351635
train_split_percentage: float = 1.0,
16361636
split_salt: int = 0,
1637+
use_offline_ts_indices: bool = False,
16371638
):
16381639
"""
16391640
Get dataset class and configuration by name.
@@ -1777,6 +1778,7 @@ def get_dataset(
17771778
# streaming-train-eval; ignored by the default train-eval path).
17781779
"streaming_window_seconds": streaming_window_seconds,
17791780
"streaming_sort_within_window": streaming_sort_within_window,
1781+
"use_offline_ts_indices": use_offline_ts_indices,
17801782
# In-window shuffle diversity dial in [0,1]: K=round(frac*N) within-
17811783
# segment shuffle. 0=off/user-major, 1=full. Config-invariant and
17821784
# deterministic by (seed, ts).

0 commit comments

Comments
 (0)