Skip to content

Perf/fused sparse cache prefetch#430

Open
shijieliu wants to merge 1 commit into
NVIDIA:mainfrom
shijieliu:perf/fused-sparse-cache-prefetch
Open

Perf/fused sparse cache prefetch#430
shijieliu wants to merge 1 commit into
NVIDIA:mainfrom
shijieliu:perf/fused-sparse-cache-prefetch

Conversation

@shijieliu

Copy link
Copy Markdown
Collaborator

Description

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

@shijieliu shijieliu force-pushed the perf/fused-sparse-cache-prefetch branch from 2a52ef4 to 1d0816a Compare July 1, 2026 06:19
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the multi-step cache prefetch pipeline (lookup → compact → storage-find → insert-and-evict) with a fused two-kernel design: a cooperative find_or_insert CUDA kernel that handles lookup/allocation/reference-pinning in one pass, followed by a pipelined exchange_cache_storage_values kernel that moves embedding values directly between cache and storage rows without staging through host or intermediate tensors.

  • A new table_find_or_insert CUDA operation uses a cooperative grid to enforce hit-before-eviction ordering across all threads, and table_reclaim_by_slot / table_update_score_by_slot operations enable post-exchange slot invalidation and score updates.
  • The Python admission pipeline is refactored to operate in the original input layout (sparse masks) rather than compacted views, and the DynamicEmbStorage.exchange() method is added to implement the new cache↔storage value-exchange contract.
  • The _ref_counter array indexing in table_insert_kernel and table_insert_and_evict_kernel is fixed: the old offset (bucket_id - bkt_begin) * bucket.capacity() was relative to the table start and wrote to wrong counter positions; it is corrected to the global bucket_id * bucket.capacity().

Confidence Score: 3/5

The fused find_or_insert + exchange kernel chain is a substantial rework of the cache prefetch hot path. The most concerning point is that the new exchange kernel uses __trap() to assert that every provisioned cache slot is non-negative; if find_or_insert fails to allocate a slot (all bucket slots in a probing chain are pinned by outstanding batches), the GPU context will terminate with an illegal instruction and no recoverable diagnostic.

The PR introduces a hard-crash risk in the exchange kernel via __trap() on negative cache slots, combined with the .item() hot-path synchronization and the always-true non_admitted_mask condition across multiple changed files, warranting extra scrutiny before merging to production.

corelib/dynamicemb/src/dynamic_emb_op.cu (exchange kernel __trap), corelib/dynamicemb/src/table_operation/kernels.cuh (unlocked score update), corelib/dynamicemb/dynamicemb/batched_dynamicemb_function.py (non_admitted_mask condition), corelib/dynamicemb/dynamicemb/key_value_table.py (.item() in hot path)

Important Files Changed

Filename Overview
corelib/dynamicemb/dynamicemb/batched_dynamicemb_function.py Major refactor of _prefetch_cache_path from multi-step to fused find_or_insert+exchange pattern; contains a logic bug where non_admitted_mask.numel() > 0 is always true, causing spurious initializer calls. The outstanding_keys_ref rollback on capacity error and slot_indices/update_slot_indices aliasing are new behavioral changes that need careful review.
corelib/dynamicemb/src/dynamic_emb_op.cu Adds the exchange_cache_storage_values kernel with a pipelined warp-level async-copy design; uses __trap() on invalid metadata which can crash the GPU if find_or_insert returns -1 for any key (full cache / all slots pinned).
corelib/dynamicemb/src/table_operation/kernels.cuh Adds FindOrInsert cooperative-grid kernel mode and new reclaim/update-score-by-slot kernels; the counter_offset fix from (bucket_id-bkt_begin) to bucket_id is a valid bugfix; table_update_score_by_slot_kernel lacks locking, creating a potential torn-read race on concurrent lookup.
corelib/dynamicemb/src/table_operation/insert_and_evict.cu Adds cooperative launch path for find_or_insert and table_find_or_insert_max_cooperative_blocks occupancy query; overflow eviction score capture and table_key_slot assignment are fixed for all InsertResult variants. Logic looks consistent with kernel traits.
corelib/dynamicemb/dynamicemb/scored_hashtable.py Adds find_or_insert, reclaim_by_slot, update_score_by_slot methods to LinearBucketTable; cooperative block occupancy is cached process-wide in _FIND_OR_INSERT_MAX_COOPERATIVE_BLOCKS; deterministic fallback for find_or_insert uses wave-based serialization. Looks well-structured.
corelib/dynamicemb/dynamicemb/key_value_table.py Adds Cache.find_or_insert/reclaim/update_scores and Storage.exchange to DynamicEmbCache/DynamicEmbStorage; .item() calls in find_or_insert with metrics enabled cause device synchronization in the prefetch hot path. DynamicEmbTableState moved to types.py.
corelib/dynamicemb/dynamicemb/types.py Moves DynamicEmbTableState to break circular imports; adds CacheFindOrInsertResult, CacheExchangeRequest, CacheExchangeResult dataclasses and new abstract methods on Cache/Counter/AdmissionStrategy. Clean refactor.
corelib/dynamicemb/dynamicemb/embedding_admission.py Updates MultiTableKVCounter.add/erase and FrequencyAdmissionStrategy.admit to accept full-length founds mask; caches the non-admit initializer instance. Changes are consistent with the new sparse-layout API.
corelib/dynamicemb/dynamicemb/initializer.py Adds initialize_flat to all initializer subclasses and extends initialize_with_generator to accept a boolean mask tensor. Typo fix (stide→stride). Consistent with new flat-storage initialization API.
corelib/dynamicemb/test/test_batched_dynamic_embedding_tables_v2.py Adds tests for find_or_insert, reclaim_by_slot, update_score_by_slot, and the new fused exchange path. Test coverage for the new cooperative kernel and admission integration paths.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant PY as Python (prefetch)
    participant Cache as DynamicEmbCache
    participant FoI as find_or_insert kernel (cooperative)
    participant Storage as DynamicEmbStorage
    participant Exch as exchange_cache_storage_values kernel
    participant Init as initialize_flat kernel
    participant Reclaim as reclaim_by_slot kernel

    PY->>Cache: find_or_insert(keys, table_ids, scores)
    Cache->>FoI: Phase 1 - lookup all keys, pin hits via ref-counter
    FoI-->>FoI: cg::this_grid().sync()
    FoI->>FoI: Phase 2 - insert misses, evict LRU, write evicted_mask
    FoI-->>Cache: indices, founds, evicted_keys/indices/scores/tids, evicted_mask
    Cache-->>PY: CacheFindOrInsertResult

    PY->>Storage: exchange(CacheExchangeRequest)
    Storage->>Storage: lookup(cache_misses) - acquire storage slots
    Storage->>Storage: "insert(evicted_keys, publish_and_acquire=True)"
    Storage->>Exch: exchange_cache_storage_values(cache-storage rows)
    Exch-->>Storage: async value copy complete
    Storage-->>PY: CacheExchangeResult(founds, storage_founds, storage_scores)

    PY->>PY: admission counter.add / strategy.admit (sparse, skip founds)
    PY->>Init: initialize_flat(admitted_mask) - new keys only
    PY->>Reclaim: "reclaim(non_admitted_mask) - free rejected slots, set indices=-1"
    PY-->>PY: return (slot_indices, slot_indices, non_admitted_mask)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant PY as Python (prefetch)
    participant Cache as DynamicEmbCache
    participant FoI as find_or_insert kernel (cooperative)
    participant Storage as DynamicEmbStorage
    participant Exch as exchange_cache_storage_values kernel
    participant Init as initialize_flat kernel
    participant Reclaim as reclaim_by_slot kernel

    PY->>Cache: find_or_insert(keys, table_ids, scores)
    Cache->>FoI: Phase 1 - lookup all keys, pin hits via ref-counter
    FoI-->>FoI: cg::this_grid().sync()
    FoI->>FoI: Phase 2 - insert misses, evict LRU, write evicted_mask
    FoI-->>Cache: indices, founds, evicted_keys/indices/scores/tids, evicted_mask
    Cache-->>PY: CacheFindOrInsertResult

    PY->>Storage: exchange(CacheExchangeRequest)
    Storage->>Storage: lookup(cache_misses) - acquire storage slots
    Storage->>Storage: "insert(evicted_keys, publish_and_acquire=True)"
    Storage->>Exch: exchange_cache_storage_values(cache-storage rows)
    Exch-->>Storage: async value copy complete
    Storage-->>PY: CacheExchangeResult(founds, storage_founds, storage_scores)

    PY->>PY: admission counter.add / strategy.admit (sparse, skip founds)
    PY->>Init: initialize_flat(admitted_mask) - new keys only
    PY->>Reclaim: "reclaim(non_admitted_mask) - free rejected slots, set indices=-1"
    PY-->>PY: return (slot_indices, slot_indices, non_admitted_mask)
Loading

Reviews (1): Last reviewed commit: "perf(dynamicemb): fuse sparse cache-stor..." | Re-trigger Greptile

non_admitted_in_values[missing_indices] = non_admitted_mask
initialized_non_admitted = False
if non_admitted_indices.numel() > 0:
if non_admitted_mask.numel() > 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The condition non_admitted_mask.numel() > 0 is always True when any missing keys exist. The intent is to skip the non-admit initializer when no keys were actually rejected, which requires .any() instead.

Suggested change
if non_admitted_mask.numel() > 0:
if non_admitted_mask.any():

Comment on lines +843 to +848
const ExchangeMetadata &metadata, int64_t tile_offset, ValueT *smem) {
constexpr uint32_t kActiveFlag =
Direction == kExchangeCacheToStorage
? kExchangeMetadataCacheToStorage
: kExchangeMetadataStorageToCache;
const bool active = (metadata.flags & kActiveFlag) != 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 __trap() on negative cache row crashes the GPU with no diagnostic

cache_row is cache_slots[input_id], which is the slot_indices tensor produced by find_or_insert. If find_or_insert fails to allocate a slot for any key (e.g., all bucket slots in the relevant probing chain are pinned via positive ref-counts from outstanding batches), slot_indices[i] will be -1. When that value reaches this assertion the kernel issues an illegal instruction (__trap()), which terminates the entire CUDA context with no recoverable diagnostic. The CPU-side outstanding_keys > cache_capacity guard reduces the risk but is a per-batch count, not a per-slot guarantee — with eviction disabled or counters skewed, the check can pass while individual slots still fail to insert. Consider replacing the __trap() with a soft error path (e.g., writing a sentinel to an output error flag tensor checked on the CPU after the kernel) so that a genuinely full cache produces an actionable error rather than a GPU crash.

Comment on lines +697 to 703
final_evict_score = *ovf_bucket.scores(local);
*ovf_bucket.scores(local) = ScoreType();
final_evict_index = ovf_iter;
} else if (ovf_result == InsertResult::Insert) {
Iter local = ovf_iter - ovf_output_offsets[t_id];
table_key_slot = ovf_bucket.keys(local);
}
score = Policy::update(ovf_bucket.scores(local), score);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Score updated without holding the bucket lock

table_update_score_by_slot_kernel writes input_scores[i] directly to *bucket.scores(slot % bucket_capacity) (and the overflow equivalent) without acquiring the per-slot lock. Although the slot's reference counter is positive (the caller owns an acquired reference), concurrent table_lookup_kernel threads that happen to be reading the same slot's score for a non-Const policy path will also hold the lock (via try_lock) while reading, creating a window where a torn 8-byte score read is possible. The score write should acquire the lock with bucket.try_lock before writing and release it afterward, consistent with every other in-place score update in this file.

Comment on lines +1453 to +1458
def reclaim(
self,
keys: torch.Tensor,
table_ids: torch.Tensor,
slot_indices: torch.Tensor,
mask: torch.Tensor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Device-synchronizing .item() calls inside the hot prefetch path

DynamicEmbCache.find_or_insert calls int(founds.sum().item()) and int(evicted_mask.sum().item()) whenever self._record_cache_metrics is True. Both .item() calls issue an implicit cudaDeviceSynchronize (or a stream-synchronization event), stalling the CPU until all GPU work up to and including the find_or_insert kernel completes. Because find_or_insert is now in the critical prefetch path (it runs before the storage exchange), this synchronization can negate a significant part of the latency reduction the fused kernel is designed to provide. Consider accumulating metrics asynchronously (e.g., writing them to a pinned/host-visible tensor and reading them only when the user explicitly queries the metrics, after the batch has been consumed by forward).

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant