-
Notifications
You must be signed in to change notification settings - Fork 18
Add residual top-up extraction to PPR forward push #702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
38be668
d482f0c
531f290
ee6027f
8cb1565
a33f080
4a8d631
5ace07f
2bcb990
e06eaf3
ae89d73
c21ace6
788f813
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| #include <torch/torch.h> | ||
|
|
||
| #include <algorithm> | ||
| #include <climits> | ||
| #include <cstdint> | ||
| #include <optional> | ||
|
|
@@ -148,9 +149,10 @@ std::optional<std::unordered_map<int32_t, torch::Tensor>> PPRForwardPush::drainQ | |
|
|
||
| void PPRForwardPush::pushResiduals( | ||
| const std::unordered_map<int32_t, std::tuple<torch::Tensor, torch::Tensor, torch::Tensor>>& fetchedByEtypeId) { | ||
| // Step 1: Unpack the input map into a C++ map keyed by packKey(nodeId, edgeTypeId) | ||
| // for fast lookup during the residual-push loop below. | ||
| std::unordered_map<uint64_t, std::vector<int32_t>> fetched; | ||
| // Step 1: Persist fetched neighbor lists in the per-state cache. drainQueue() | ||
| // consults this cache before requesting future lookups, so storing every | ||
| // fetched row here avoids re-fetching a (node, edge type) pair if it re-enters | ||
| // the frontier later in the same PPR channel. | ||
| for (const auto& [edgeTypeId, neighborTensors] : fetchedByEtypeId) { | ||
| const auto& nodeIdsTensor = std::get<0>(neighborTensors); | ||
| const auto& flatNeighborIdsTensor = std::get<1>(neighborTensors); | ||
|
|
@@ -172,7 +174,10 @@ void PPRForwardPush::pushResiduals( | |
| for (int64_t neighborIdx = 0; neighborIdx < count; ++neighborIdx) { | ||
| neighborIds[neighborIdx] = static_cast<int32_t>(flatNeighborIdsAccessor[offset + neighborIdx]); | ||
| } | ||
| fetched[packKey(nodeId, edgeTypeId)] = std::move(neighborIds); | ||
| uint64_t cacheKey = packKey(nodeId, edgeTypeId); | ||
| if (_neighborCache.find(cacheKey) == _neighborCache.end()) { | ||
| _neighborCache.emplace(cacheKey, std::move(neighborIds)); | ||
| } | ||
| offset += count; | ||
| } | ||
| } | ||
|
|
@@ -197,21 +202,16 @@ void PPRForwardPush::pushResiduals( | |
| srcNodeTypeState.pprScores[sourceNodeId] += sourceResidual; | ||
| srcNodeTypeState.residuals[sourceNodeId] = 0.0; | ||
|
|
||
| // b. Count total fetched/cached neighbors across all edge types for | ||
| // b. Count total cached neighbors across all edge types for | ||
| // this source node. We normalise by the number of neighbors we | ||
| // actually retrieved, not the true degree, so residual is fully | ||
| // distributed among known neighbors rather than leaking to unfetched | ||
| // ones (which matters when num_neighbors_per_hop < true_degree). | ||
| int32_t totalFetched = 0; | ||
| int32_t totalCachedNeighbors = 0; | ||
| for (int32_t edgeTypeId : _nodeTypeToEdgeTypeIds[nodeTypeId]) { | ||
| auto fetchedEntry = fetched.find(packKey(sourceNodeId, edgeTypeId)); | ||
| if (fetchedEntry != fetched.end()) { | ||
| totalFetched += static_cast<int32_t>(fetchedEntry->second.size()); | ||
| } else { | ||
| auto cachedEntry = _neighborCache.find(packKey(sourceNodeId, edgeTypeId)); | ||
| if (cachedEntry != _neighborCache.end()) { | ||
| totalFetched += static_cast<int32_t>(cachedEntry->second.size()); | ||
| } | ||
| auto cachedEntry = _neighborCache.find(packKey(sourceNodeId, edgeTypeId)); | ||
| if (cachedEntry != _neighborCache.end()) { | ||
| totalCachedNeighbors += static_cast<int32_t>(cachedEntry->second.size()); | ||
| } | ||
| } | ||
| // Two cases reach here: | ||
|
|
@@ -221,32 +221,23 @@ void PPRForwardPush::pushResiduals( | |
| // This overstates src and understates its neighbors. This is expected | ||
| // behavior when max_fetch_iterations is set, which intentionally trades | ||
| // theoretical PPR correctness for better throughput. | ||
| if (totalFetched == 0) { | ||
| if (totalCachedNeighbors == 0) { | ||
| continue; | ||
| } | ||
|
|
||
| double residualPerNeighbor = (1.0 - _alpha) * sourceResidual / static_cast<double>(totalFetched); | ||
| double residualPerNeighbor = | ||
| (1.0 - _alpha) * sourceResidual / static_cast<double>(totalCachedNeighbors); | ||
|
|
||
| for (int32_t edgeTypeId : _nodeTypeToEdgeTypeIds[nodeTypeId]) { | ||
| // Invariant: fetched and _neighborCache are mutually exclusive for | ||
| // any given (node, etype) key within one iteration. drainQueue() | ||
| // only requests a fetch for nodes absent from _neighborCache, so a | ||
| // key is in at most one of the two. | ||
| // | ||
| // Neighbor list for this (src, edgeTypeId) pair, borrowed from whichever | ||
| // map holds it. reference_wrapper is used because std::optional cannot | ||
| // hold a reference directly, and we want to avoid copying the vector — | ||
| // the data already exists in fetched or _neighborCache and both outlive | ||
| // this loop body. Access via neighborList->get(). | ||
| // the data already exists in _neighborCache and outlives this loop body. | ||
| // Access via neighborList->get(). | ||
| std::optional<std::reference_wrapper<const std::vector<int32_t>>> neighborList; | ||
| auto fetchedEntry = fetched.find(packKey(sourceNodeId, edgeTypeId)); | ||
| if (fetchedEntry != fetched.end()) { | ||
| neighborList = std::cref(fetchedEntry->second); | ||
| } else { | ||
| auto cachedEntry = _neighborCache.find(packKey(sourceNodeId, edgeTypeId)); | ||
| if (cachedEntry != _neighborCache.end()) { | ||
| neighborList = std::cref(cachedEntry->second); | ||
| } | ||
| auto cachedEntry = _neighborCache.find(packKey(sourceNodeId, edgeTypeId)); | ||
| if (cachedEntry != _neighborCache.end()) { | ||
| neighborList = std::cref(cachedEntry->second); | ||
| } | ||
| if (!neighborList || neighborList->get().empty()) { | ||
| continue; | ||
|
|
@@ -267,18 +258,6 @@ void PPRForwardPush::pushResiduals( | |
| dstNodeTypeState.residuals[neighborNodeId] >= threshold) { | ||
| dstNodeTypeState.queue.insert(neighborNodeId); | ||
| ++_numNodesInQueue; | ||
|
|
||
| // Promote neighbor lists to the persistent cache: this node will | ||
| // be processed next iteration, so caching avoids a re-fetch. | ||
| for (int32_t neighborEdgeTypeId : _nodeTypeToEdgeTypeIds[dstNodeTypeId]) { | ||
| uint64_t packedKey = packKey(neighborNodeId, neighborEdgeTypeId); | ||
| if (_neighborCache.find(packedKey) == _neighborCache.end()) { | ||
| auto fetchedNeighborEntry = fetched.find(packedKey); | ||
| if (fetchedNeighborEntry != fetched.end()) { | ||
| _neighborCache[packedKey] = fetchedNeighborEntry->second; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -287,8 +266,10 @@ void PPRForwardPush::pushResiduals( | |
| } | ||
| } | ||
|
|
||
| std::unordered_map<int32_t, std::tuple<torch::Tensor, torch::Tensor, torch::Tensor>> PPRForwardPush::extractTopK( | ||
| int32_t maxPprNodes) { | ||
| std::unordered_map<int32_t, std::tuple<torch::Tensor, torch::Tensor, torch::Tensor>> PPRForwardPush:: | ||
| extractTopKWithResidualTopUp(int32_t maxPPRNodes, bool enableResidualTopUp) { | ||
| TORCH_CHECK(maxPPRNodes >= 0, "maxPPRNodes must be non-negative, got ", maxPPRNodes, "."); | ||
|
|
||
| std::unordered_map<int32_t, std::tuple<torch::Tensor, torch::Tensor, torch::Tensor>> result; | ||
| // Emit an entry for every node type, even if unreachable in this batch (empty tensors, | ||
| // all-zero valid_counts). This keeps the output shape consistent across batches so | ||
|
|
@@ -299,21 +280,98 @@ std::unordered_map<int32_t, std::tuple<torch::Tensor, torch::Tensor, torch::Tens | |
| std::vector<int64_t> validCounts; | ||
|
|
||
| for (int32_t seedIdx = 0; seedIdx < _batchSize; ++seedIdx) { | ||
| const auto& scores = _state[seedIdx][nodeTypeId].pprScores; | ||
| int32_t topK = std::min(maxPprNodes, static_cast<int32_t>(scores.size())); | ||
| const auto& nodeTypeState = _state[seedIdx][nodeTypeId]; | ||
| const auto& scores = nodeTypeState.pprScores; | ||
| const auto higherScore = [](const auto& a, const auto& b) { | ||
| return a.second > b.second; | ||
| }; | ||
|
|
||
| const int32_t topK = std::min(maxPPRNodes, static_cast<int32_t>(scores.size())); | ||
| const int32_t residualTopUpBudget = enableResidualTopUp ? maxPPRNodes - topK : 0; | ||
| std::vector<std::pair<int32_t, double>> selectedPairs; | ||
| selectedPairs.reserve(static_cast<size_t>(topK + residualTopUpBudget)); | ||
| std::unordered_set<int32_t> selectedPPRNodeIds; | ||
| if (topK > 0) { | ||
| if (residualTopUpBudget > 0) { | ||
| selectedPPRNodeIds.reserve(static_cast<size_t>(topK)); | ||
| } | ||
| std::vector<std::pair<int32_t, double>> scorePairs(scores.begin(), scores.end()); | ||
| std::partial_sort(scorePairs.begin(), | ||
| scorePairs.begin() + topK, | ||
| scorePairs.end(), | ||
| [](const auto& a, const auto& b) { return a.second > b.second; }); | ||
| // Selection is intentionally two-phase: finalized nodes are selected | ||
| // first by raw PPR score, and residual candidates only compete for | ||
| // the remaining budget. | ||
| if (enableResidualTopUp) { | ||
| // The final emitted order is sorted by ppr_score + residual | ||
| // after top-up candidates are selected, so this pass only | ||
| // needs to partition out the raw-PPR top K. | ||
| if (topK < static_cast<int32_t>(scorePairs.size())) { | ||
| std::nth_element(scorePairs.begin(), | ||
| scorePairs.begin() + topK, | ||
| scorePairs.end(), | ||
| higherScore); | ||
| } | ||
| } else { | ||
| std::partial_sort(scorePairs.begin(), | ||
| scorePairs.begin() + topK, | ||
| scorePairs.end(), | ||
| higherScore); | ||
| } | ||
|
|
||
| for (int32_t rankIdx = 0; rankIdx < topK; ++rankIdx) { | ||
|
Comment on lines
+297
to
319
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Robot review:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is intended. Residual top-up is a fill-only mechanism: finalized PPR nodes are selected first by raw PPR score, and residual candidates only compete for the remaining budget. After that, the selected rows are emitted/sorted by ppr_score + residual when top-up is enabled. I added source comments to make explicit that this is a two-phase policy and not a global top-k over the emitted score when maxTotalNodes is tight. |
||
| flatIds.push_back(static_cast<int64_t>(scorePairs[rankIdx].first)); | ||
| flatWeights.push_back(scorePairs[rankIdx].second); | ||
| int32_t nodeId = scorePairs[rankIdx].first; | ||
| double outputScore = scorePairs[rankIdx].second; | ||
| if (enableResidualTopUp) { | ||
| auto residualIter = nodeTypeState.residuals.find(nodeId); | ||
| if (residualIter != nodeTypeState.residuals.end()) { | ||
| outputScore += residualIter->second; | ||
| } | ||
| } | ||
| selectedPairs.emplace_back(nodeId, outputScore); | ||
| if (residualTopUpBudget > 0) { | ||
| selectedPPRNodeIds.insert(nodeId); | ||
| } | ||
| } | ||
| } | ||
| validCounts.push_back(static_cast<int64_t>(topK)); | ||
|
|
||
| if (residualTopUpBudget > 0) { | ||
| std::vector<std::pair<int32_t, double>> residualPairs; | ||
| residualPairs.reserve(nodeTypeState.residuals.size()); | ||
| for (const auto& [nodeId, residual] : nodeTypeState.residuals) { | ||
| if (residual <= 0.0 || selectedPPRNodeIds.find(nodeId) != selectedPPRNodeIds.end()) { | ||
| continue; | ||
| } | ||
|
|
||
| auto scoreIter = scores.find(nodeId); | ||
| double pprScore = (scoreIter != scores.end()) ? scoreIter->second : 0.0; | ||
| double outputScore = pprScore + residual; | ||
| residualPairs.emplace_back(nodeId, outputScore); | ||
| } | ||
|
|
||
| const int32_t residualTopK = | ||
| std::min(residualTopUpBudget, static_cast<int32_t>(residualPairs.size())); | ||
| if (residualTopK > 0) { | ||
| // Residual candidates only need selection here; selected | ||
| // finalized and residual rows are sorted together below. | ||
| if (residualTopK < static_cast<int32_t>(residualPairs.size())) { | ||
| std::nth_element(residualPairs.begin(), | ||
| residualPairs.begin() + residualTopK, | ||
| residualPairs.end(), | ||
| higherScore); | ||
| } | ||
|
|
||
| for (int32_t rankIdx = 0; rankIdx < residualTopK; ++rankIdx) { | ||
| selectedPairs.emplace_back(residualPairs[rankIdx].first, residualPairs[rankIdx].second); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (enableResidualTopUp && selectedPairs.size() > 1) { | ||
| std::sort(selectedPairs.begin(), selectedPairs.end(), higherScore); | ||
| } | ||
| for (const auto& [nodeId, score] : selectedPairs) { | ||
| flatIds.push_back(static_cast<int64_t>(nodeId)); | ||
| flatWeights.push_back(score); | ||
| } | ||
| validCounts.push_back(static_cast<int64_t>(selectedPairs.size())); | ||
| } | ||
|
|
||
| result[nodeTypeId] = {torch::tensor(flatIds, torch::kLong), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
btw why do we include this?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
std::min, std::sort, and std::partial_sort require this. These functions worked before because we had the include in the .h file, but it's better for algorithm imports to live in the .cpp file, so we moved it from .h to here