Skip to content

Commit 9a3bf5d

Browse files
jiashuyclaude
andauthored
Feat/dynamicemb retain evicted keys (#443)
* feat(dynamicemb): retain evicted keys at last tier + pop_evicted_keys API Add an opt-in retain_evicted_keys table option so the last-tier storage records the keys it evicts (instead of silently dropping them), plus a pop_evicted_keys API to read them back per table, unique and incrementally. C++: insert_body gains a CollectEvicted template sink that compacts each Evict victim's (key, table_id); wired through a new AoT table_insert_collect_kernel and a dyn_emb_insert_collect_entry LruLfu cubin entry (reusing EvictParams, no ABI change), exposed as table_insert_collect_evicted. Covers all score policies. Only InsertResult::Evict is collected, not Busy. Python: retain_evicted_keys config (in get_grouped_key so retain-differing tables don't share storage); the last-tier state accumulates evicted (key, table_id) chunks, de-duplicated only on pop; DynamicEmbStorage / HybridStorage pop_evicted_keys; module- and model-level pop_evicted_keys with optional pg all_gather (else per-rank local, disjoint). Design doc under docs/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(dynamicemb): retain_evicted_keys suite + fix HBM-direct forward retain gap Non-caching training forward inserts through _prefetch_hbm_direct_path, which calls key_index_map.insert() directly (bypassing _insert_key_values), so its last-tier eviction was never retained. Add the collect_evicted branch there too. (Prefetched keys are ref-counter protected, so eviction only materializes across forward+backward steps.) New self-contained suite under test/unit_tests/retain_evicted_keys/ (12 cases): table (whole-set oracle for insert(collect_evicted=True) on LruLfu cubin + AoT, table_id routing, no-eviction, determinism raise); storage (DynamicEmbStorage end-to-end collect->pop unique->gone->incremental, retain=False empty, dedup/table_id-filter/clear-isolation unit); module (HBM-direct training retains = the gap above, disabled omitted, table_names filter); distributed model (row-wise sharded: pg=None disjoint per rank / pg union). Wired into unit_test.sh (fwd_bwd group). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(dynamicemb): translate retain_evicted_keys_design.md to English Also sync a few stale spots to the as-built code: retain buffers are tensor chunk lists (not ExtendableBuffer), pop returns host tensors, tests live under test/unit_tests/retain_evicted_keys/ (distributed test included), and the API lives in incremental_dump.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dynamicemb): return DeltaDumpResult map from incremental_dump Refactor incremental_dump's return from (ret_tensors, ret_scores) to Dict[collection_path, DeltaDumpResult], packing per-collection column-aligned lists (table_names/keys/values/evicted_keys/meta) with a slot_index that pins each key to its physical row for a future precise replay_increment. - slot_index encoding: single-tier normal = key_slot (== value row); NO_EVICTION = (key_slot << 32) | value_row; HybridStorage = bit63 tier | bits0-62 key_slot. - HybridStorage + NO_EVICTION now raises (non-caching partial-HBM storage cannot host the NO_EVICTION auto-increment score policy). - pop_evicted_keys returns a host tensor (CUDA-accelerated internally). - incremental_dump accepts an optional pg; keys/values/evicted/slot_index are all_gathered within it (dist_type "continuous" unsupported -> raise). - meta.world_size records the table's ROW_WISE shard fan-out (global dist.get_world_size(), which equals input_dist's pg.size()). - Tests: DeltaDumpResult adaptations, HybridStorage tier slot_index test, skip NO_EVICTION+partial-HBM combo; docs: DeltaDumpResult design + DynamicEmb_APIs updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dynamicemb): make hybrid tier slot_index test world_size-agnostic test_hybrid_incremental_dump_slot_index_tier inserted a fixed 256 keys and asserted both tiers contribute dumped keys. But the HBM tier's per-rank capacity is global_capacity / world_size, so on a single GPU (world_size 1) the HBM tier holds all 256 keys, nothing is evicted, and the host tier stays empty -> the "host tier must contribute dumped keys" assert fails. The test only passed at world_size >= 2. Derive the insert count from the actual HBM capacity instead: n = hbm_cap + min(hbm_cap, host_cap). This fills the HBM tier and overflows enough to guarantee a spill into the host tier on any world_size. Verified passing on both nproc=1 and nproc=2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style(dynamicemb): apply black formatting via pre-commit Run black 23.9.1 (pre-commit) over the dynamicemb files this branch adds or modifies -- pure formatting, no logic changes. They were committed earlier without running black (local dev had no pre-commit hook). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(dynamicemb): fix stale need_incremental_dump refs + guard score_function closures Small PR#437-debt cleanups (these files landed via #437; touched here because this branch builds directly on them): - score.cuh: the LruLfu comment said tables are "created with need_incremental_dump=True", but that parameter no longer exists -- they are created with a compound (TIMESTAMP, LFU) score_strategy. - lru_lfu_score_strategy_design.md: replace all 13 stale need_incremental_dump references (a removed parameter, incl. a wrong get_score_policy signature) with the actual "(TIMESTAMP, LFU) / existing incremental-dump LruLfu" terms. - score_jit.py: _remap_score_function recompiles against fn.__globals__ only, silently dropping closure captures, so a factory/closure score_function later failed with an opaque numba NameError. Reject co_freevars up front with a clear ValueError. Verified on EOS: closure raises, plain fns unaffected, test_lru_lfu 19 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dynamicemb): keep pop_evicted_keys empty/non-empty dtype consistent _pop_state_evicted_keys' empty guard hardcoded torch.int64, but the non-empty path returns torch.cat(chunks) whose dtype is key_index_map.key_type (== the table's index_type, which can be int32/uint32). For int32-key tables the two paths returned different dtypes, so a caller concatenating or comparing across empty and non-empty pops would hit a RuntimeError. Return state.key_index_map.key_type from the empty guard -- the same dtype the non-empty path carries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(dynamicemb): note incremental_dump does not support continuous dist_type incremental_dump's slot_index targets precise replay_increment, which reconstructs each key's owning rank via (key or hash(key)) % world_size -- only defined for roundrobin / hash_roundrobin. A table sharded with dist_type="continuous" uses a range-based key->rank mapping this path does not implement and raises NotImplementedError. Document the limitation in the incremental_dump API section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(dynamicemb): replace retain_evicted_keys bool with EvictedItemMode enum Replace the boolean DynamicEmbTableOptions.retain_evicted_keys with an extensible enum EvictedItemMode { DISCARD (default), RETAIN_KEY }, leaving room for future modes (e.g. RETAIN_VALUE) without another API change. Unreleased in this branch, so no bool alias -- all usages updated: config (enum + field + get_grouped_key), state field + branches (key_value_table, batched_dynamicemb_{tables,function}), scored_hashtable message, incremental_dump docstrings, __init__ export, the three retain tests, and the API + design docs. Also finalizes the empty-pop dtype test from the earlier dtype fix (d53eb76): parametrized int64/uint64, empty-only -- a uint64 table's non-empty pop hits torch's unimplemented UInt64 GPU indexing, tracked separately. Verified on EOS: retain suite 8 + 5 + 1 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3118b5f commit 9a3bf5d

30 files changed

Lines changed: 2619 additions & 130 deletions

corelib/dynamicemb/DynamicEmb_APIs.md

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This document consists of two parts, one is the introduction to the API, which c
1717
- [DynamicEmbDump](#dynamicembdump)
1818
- [DynamicEmbLoad](#dynamicembload)
1919
- [incremental_dump](#incremental_dump)
20+
- [pop_evicted_keys](#pop_evicted_keys)
2021
- [get_score](#get_score)
2122
- [set_score](#set_score)
2223
- [Counter](#counter)
@@ -615,7 +616,15 @@ Fields declared first (through `device_id`) are **planner/runtime-heavy**: `Dyna
615616
admission_counter : Optional[Counter], optional
616617
Counter for tracking the number of keys that have been admitted to the embedding table.
617618
If provided, the counter will be used to track the number of keys that have been admitted to the embedding table.
618-
Default is None (no counter is used).
619+
Default is None (no counter is used).
620+
evicted_item_mode : EvictedItemMode, optional
621+
How the *last-tier* storage handles an item it evicts. ``DISCARD``
622+
(default) drops evicted keys with zero overhead. ``RETAIN_KEY`` retains
623+
the keys it evicts so they can be read back with ``pop_evicted_keys``.
624+
Only the final tier that truly discards a key records it -- intermediate
625+
cache / HBM tiers spill their evictions to the next tier and are not
626+
recorded. Records the (key, table_id) only, no value/score. Default
627+
``EvictedItemMode.DISCARD``.
619628
620629
Notes
621630
-----
@@ -654,6 +663,7 @@ Fields declared first (through `device_id`) are **planner/runtime-heavy**: `Dyna
654663
index_type: Optional[torch.dtype] = None
655664
admit_strategy: Optional[AdmissionStrategy] = None
656665
admission_counter: Optional[Counter] = None
666+
evicted_item_mode: EvictedItemMode = EvictedItemMode.DISCARD
657667

658668
```
659669

@@ -747,6 +757,8 @@ The meaning of the threshold depends on the table's `score_strategy`:
747757
- Strategies that carry a device-timestamp column — the single `TIMESTAMP` strategy, or a compound strategy that includes `TIMESTAMP` such as `(TIMESTAMP, LFU)` — produce a **time-based** incremental dump: only items whose last-access timestamp crosses the threshold (i.e. items that were touched/changed since the reference time) are dumped. Use the value returned by `get_score` as the reference threshold.
748758
- Strategies without a timestamp column (e.g. `STEP`, single `LFU`, `NO_EVICTION`) instead threshold on the **absolute score value**: items whose score is not less than the threshold are dumped. This is not a time-based increment.
749759

760+
> **Limitation — `dist_type`:** `incremental_dump` supports only `roundrobin` and `hash_roundrobin` sharding. A table sharded with `dist_type="continuous"` raises `NotImplementedError`. The returned `slot_index` is meant for precise `replay_increment`, which reconstructs each key's owning rank from the key via `(key or hash(key)) % world_size`; `continuous` uses a different, range-based key→rank mapping that this path does not implement. Use `roundrobin` or `hash_roundrobin` if you need incremental dump.
761+
750762
```python
751763
#How to import
752764
from dynamicemb.incremental_dump import incremental_dump
@@ -756,39 +768,87 @@ The meaning of the threshold depends on the table's `score_strategy`:
756768
model: torch.nn.Module,
757769
score_threshold: Union[int, Dict[str, Dict[str, int]]],
758770
pg: Optional[dist.ProcessGroup] = None,
759-
) -> Union[
760-
Tuple[
761-
Dict[str, Dict[str, Tuple[torch.Tensor, torch.Tensor]]],
762-
Dict[str, Dict[str, int]],
763-
],
764-
None,
765-
]:
771+
) -> Dict[str, "DeltaDumpResult"]:
766772
"""Dump the model's embedding tables incrementally based on the score threshold. The index-embedding pair whose score is not less than the threshold will be returned.
767773

768774
Args:
769775
model(nn.Module):The model containing dynamic embedding tables.
770-
score_threshold(Uinon[int, Dict[str, Dict[str, int]]]):
776+
score_threshold(Union[int, Dict[str, Dict[str, int]]]):
771777
int: All embedding table's score threshold will be this integer. It will dump matched results for all tables in the model.
772778
Dict[str, Dict[str, int]]: the first `str` is the name of embedding collection in the model. 'str' in Dict[str, int] is the name of dynamic embedding table, and `int` in Dict[str, int] is the table's score threshold. It will dump for only tables whose names present in this Dict.
773-
pg(Optional[dist.ProcessGroup]): optional. The process group used to control the communication scope in the dump. Defaults to None.
779+
pg(Optional[dist.ProcessGroup]): optional. The process group used to control the communication scope in the dump (the all_gather of keys/values/slot_index). Defaults to None.
774780

775781
Returns
776782
-------
777-
Tuple:
778-
Dict[str, Dict[str, Tuple[torch.Tensor, torch.Tensor]]]:
779-
The first 'str' is the name of embedding collection.
780-
The second 'str' is the name of embedding table.
781-
The first tensor in the Tuple is matched keys on hosts.
782-
The second tensor in the Tuple is matched values on hosts.
783-
Dict[str, Dict[str, int]]:
784-
The first 'str' is the name of embedding collection.
785-
The second 'str' is the name of embedding table.
786-
`int` is the current score after finishing the dumping process, which will be used as the score for the next forward pass, and can also be used as the input of the next incremental_dump. If input score_threshold is `int`, the Dict will contain all dynamic embedding tables' current score, otherwise only dumped tables' current score will be returned.
783+
Dict[str, DeltaDumpResult]:
784+
``{collection_path: DeltaDumpResult}`` -- one ``DeltaDumpResult`` per
785+
embedding collection. Each ``DeltaDumpResult`` holds column-aligned
786+
per-table lists (element ``i`` refers to ``table_names[i]``):
787+
788+
- ``table_names: List[str]`` -- the dumped table names.
789+
- ``keys: List[torch.Tensor]`` -- per-table matched keys on host.
790+
- ``values: List[torch.Tensor]`` -- per-table matched values on host.
791+
- ``evicted_keys: List[Optional[torch.Tensor]]`` -- per-table retained
792+
evicted keys on host for tables with ``evicted_item_mode=RETAIN_KEY``,
793+
else ``None``. Returning them drains that table's retained-evicted
794+
buffer (each evicted key reported once across successive calls).
795+
- ``meta: List[Dict[str, Any]]`` -- per-table metadata, a flat dict:
796+
- ``"current_score": int`` -- the table's current score after this
797+
dump; usable as the next forward's score and as the next
798+
``incremental_dump`` threshold.
799+
- ``"slot_index": torch.Tensor`` -- int64 host tensor aligned with
800+
``keys``; the storage slot each dumped key occupies (for
801+
``replay_increment``).
802+
- ``"current_capacity": int`` -- the table's current capacity.
803+
- ``"world_size": int`` -- ranks the table was sharded across.
804+
- ``"table_options": DynamicEmbTableOptions`` -- the table config.
787805
"""
788806
```
789807

790808
More usage please see [test](https://github.com/NVIDIA/recsys-examples/blob/main/corelib/dynamicemb/test/unit_tests/incremental_dump/test_distributed_dynamicemb.py)
791809

810+
## pop_evicted_keys
811+
812+
**Background**
813+
When a table's last-tier storage is full, evicting a key drops it from the system entirely. With `evicted_item_mode=RETAIN_KEY` (see [DynamicEmbTableOptions](#dynamicembtableoptions)), the last tier instead retains the keys it evicts so they can be read back -- e.g. to feed a downstream key-value store, a cold-tier archive, or an offline pipeline.
814+
815+
**Behavior**
816+
Returns, per table, the keys evicted since the previous call, deduplicated within a table. This is a read-and-clear (incremental) operation: each evicted key is reported exactly once across successive calls, and returning a table's keys drains its retained-evicted buffer on this rank. Tables without `evicted_item_mode=RETAIN_KEY` are omitted from the result.
817+
818+
```python
819+
#How to import
820+
from dynamicemb import pop_evicted_keys
821+
822+
#API arguments
823+
def pop_evicted_keys(
824+
model: torch.nn.Module,
825+
table_names: Optional[Dict[str, List[str]]] = None,
826+
pg: Optional[dist.ProcessGroup] = None,
827+
) -> Dict[str, Dict[str, torch.Tensor]]:
828+
"""Return (and clear) the keys evicted and retained by last-tier storage, per table.
829+
830+
Only tables created with evicted_item_mode=RETAIN_KEY are included; all other tables are omitted.
831+
832+
Args:
833+
model(nn.Module): the model containing dynamic embedding tables.
834+
table_names(Optional[Dict[str, List[str]]]): optional filter, keyed by
835+
embedding-collection path -> [table_name, ...]. None pops every
836+
retain-enabled table in the model.
837+
pg(Optional[dist.ProcessGroup]): optional. None returns each rank's LOCAL
838+
evicted keys (row-wise sharded, hence disjoint across ranks; zero
839+
communication). When given, keys are all_gathered within pg so every
840+
rank in the group receives the group-wide union. Clearing always
841+
affects only this rank's buffer, regardless of pg.
842+
843+
Returns
844+
-------
845+
Dict[str, Dict[str, torch.Tensor]]:
846+
{collection_path: {table_name: keys}} where keys is a 1-D int64 host
847+
tensor of table-unique evicted keys. Empty dict if the model has no
848+
retain-enabled dynamic embedding tables.
849+
"""
850+
```
851+
792852
## get_score
793853

794854
dynamicemb also provides a `get_score` interface whose returns are the current scores which will be used in the next forward pass.

0 commit comments

Comments
 (0)