Skip to content

diskgraph: record sampled edges through per-seed maps - #101

Merged
Vedaant-J merged 9 commits into
mainfrom
vedaant/dg-per-seed-recording
Oct 28, 2026
Merged

Vedaant-J merged 9 commits into
mainfrom
vedaant/dg-per-seed-recording

Conversation

@Vedaant-J

@Vedaant-J Vedaant-J commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR makes multi-hop DiskGraph sampling 1.3–2× faster per thread by changing how each layer records sampled edges. The output is identical to the current path.

Problem. Deep sampling plans spend most of their time after the edges are read:

  • Why does 5-hop sampling on a 1B-row graph run at ~330 seeds/s per thread when reading the adjacency takes a fifth of the time?
  • Why do 8 sampler threads give only ~1.3× one thread at 5–6 hops (about 1.9 cores busy)?

Cause. Each layer runs in two phases. The map phase reads adjacency in parallel, one shard per task. The commit phase then runs serially: for every sampled edge, record_edge looks up (seed, node) in one index_map per node type, which holds every node sampled so far for every seed in the request. At b=512 and 5 hops that map reaches millions of entries, so nearly every lookup misses cache. Profiling 5-hop sampling on one thread: commit (record_edge and its hash-map lookups, inserts, rehashes and page faults) took about 70% of the CPU and the parallel map phase about 16%.

Change. commit_edges replaces the per-edge loop for the standalone (non-fused) path:

  1. Bucket the layer's edges by seed (a counting sort), carrying each edge's node key and position.
  2. For each seed, deduplicate against that seed's own small map (seed_maps[slot], about 10k nodes at 6 hops, which stays in cache). Each edge resolves to an existing local id, a new node, or a repeat of an earlier edge in this layer.
  3. Walk the edges in their original order: assign local ids to new nodes, then emit every edge. Ids, edge order, frontiers and stats are exactly what record_edge produced.
  4. Write the new ids back into the per-seed maps.

The per-shard map-phase edge buffers are also kept in SamplingState and reused across node types, layers and requests instead of being reallocated. DiskGraphEngine::legacy_recording = true selects the previous path; the fused path is unchanged.

Back-compat

Situation Behaviour
Standalone sampling (fused=false) Same local ids, edges, frontiers and stats as the previous path (tested, 2–6 hops).
Fused feature-fetching sampling Unchanged (previous path).
legacy_recording = true Previous path.
More than 256 node types Falls back to the previous path (the per-seed key packs the type into 8 bits).
Pooled state between requests Per-seed maps are cleared and kept only while their summed capacity fits the existing RESET_THRESHOLD (262k entries, about 4 MiB); the rest are freed. Edge buffers and commit scratch are cleared and shrunk to RESET_THRESHOLD, as index_map already is. One heavy request does not pin its peak memory in the pool.
More than 2^31 nodes of one type in one request Returns an error (the commit tags pending entries with bit 31); the previous path allowed up to 2^32.
TypeState::index_map Holds only the seeds on this path; nothing outside the recording step reads it.

Measured results

Indexes: synthetic relational graphs with 7 node types (2 entity tables and 5 event tables), 106.5M rows (5.3 GB index, 5 shards per type) and 1.01B rows (50 GB, 16 shards per type). Workload: temporal most-recent sampling with fanouts 24, 8, 6, 8, 6, 8 (first h used), 5,000 seeds sharing one anchor time, b=512 per request, warm page cache. Measured with a kumo-stress-tests ctxbench-style driver that calls DiskGraphEngine::sample in-process (and fetch_features_for_type for the feature-fetch columns). Each cell is the better of two interleaved rounds. Machine: 64 vCPU, 249 GiB RAM, shared with other jobs, so expect about ±10%.

Sampling seeds/s, main → this PR:

graph threads 2 hops 3 hops 4 hops 5 hops 6 hops
100M 1 36,496 → 31,250 7,899 → 9,960 2,836 → 3,213 331 → 578 154 → 226
100M 8 51,546 → 53,191 9,804 → 12,987 4,444 → 5,359 421 → 704 235 → 397
1B 1 30,675 → 27,473 6,821 → 8,913 2,900 → 3,183 327 → 554 162 → 236
1B 8 48,544 → 48,077 9,634 → 13,587 4,651 → 5,525 402 → 716 233 → 407

50B results for the full stack are in #102.

Training path (sample_runtime_batch, link prediction)

The table above uses pooled DiskGraphEngine::sample. Training calls sample_runtime_batch, which builds a fresh SamplingState per batch, so no maps or buffers carry over between batches. This run goes through that call on the 1B index with a two-tower link-prediction stage table (200k rows of customer → merchant purchases, entity = customers, target = merchants, one engine negative per row), so each batch seeds three groups: 512 customers, 512 positive merchants and 512 negative merchants. Same fanouts, temporal most-recent; features skipped through the feature projection unless noted. Per-batch time, better of three interleaved rounds of 4 batches, same machine (±10%). legacy_recording = true is the previous path in the same binary; both paths returned identical node counts in every cell.

threads 2 hops 3 hops 4 hops 5 hops 6 hops
2 89 → 84 ms (1.05×) 745 → 549 ms (1.36×) 1.72 → 1.32 s (1.30×) 7.23 → 4.36 s (1.66×) 16.4 → 10.3 s (1.59×)
8 73 → 67 ms (1.08×) 651 → 459 ms (1.42×) 1.47 → 1.05 s (1.40×) 6.63 → 3.73 s (1.78×) 14.5 → 8.19 s (1.77×)

At 2 hops this PR alone is 10–14% slower at 1 thread (31,250 vs 36,496 on 100M; 27,473 vs 30,675 on 1B): the commit phase is small there, so the extra bucketing pass is not paid back. #102 recovers it (0.96–0.99× of main). Peak RSS rises by 0.0–0.3 GB.

Known limitations

  • The commit phase is still serial; with 8 threads it is now most of the time at 5–6 hops. The next PR in the stack parallelizes the per-seed step.
  • Gains come from deduplication within each seed, so they shrink when each seed's neighbourhood has few repeated nodes.

Testing Done

  • New per_seed_recording_matches_legacy_recording: a random 41-node graph with repeated neighbours, temporal and uniform modes, both edge directions, repeated seeds, 1–700 seeds per request at 4 threads, and pooled state reused across requests; edges, nodes and stats equal the previous path.
  • New per_seed_recording_matches_legacy_recording_across_seed_types: two node types (A→B, B→A) with seeds of both types at overlapping indices, 3/40/700 seeds, 1 and 4 threads; covers the per-type slot offsets and the type bits of the per-seed key.
  • New tests/per_seed_recording_real_graph.rs (#[ignore], runs with DISKGRAPH_VERIFY_INDEX=<index>): 6 batches of 512 seeds, alternating a shared and per-seed anchors, 2–6 hops; checks this path equals the previous one and that the previous one is identical across fresh states. Passed on both indexes above (up to 14.4M edges per hop count).
  • New reset_caps_edge_buffers_and_commit_scratch: after a request that grew the edge buffers, commit scratch and per-seed maps past RESET_THRESHOLD, the next acquired state holds at most that capacity in each.
  • cargo test -p diskgraph-engine, -p diskgraph-ingest --test seed_table_ingest --test resolution_runtime_batch and -p diskgraph-pybind pass; cargo clippy --workspace --all-targets -- -D warnings is clean.

Checklist

  • No breaking changes.
  • Unit tests added.

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Oct 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 16465c8 and 0d8c832.

Files selected for processing: 3
  • packages/diskgraph/diskgraph-engine/src/engine.rs
  • packages/diskgraph/diskgraph-engine/src/state.rs
  • packages/diskgraph/diskgraph-engine/src/tests_unit.rs

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Walkthrough

DiskGraphEngine adds a legacy_recording flag and selects per-seed recording for eligible non-fused requests with at most 256 node types. SamplingState stores per-seed maps, commit scratch, and reusable raw edge buffers. The batch path commits edges and shares edge emission with sequential recording. Unit and ignored real-graph tests compare per-seed results with legacy results.

Sequence Diagram(s)

sequenceDiagram
  participant DiskGraphEngine
  participant SamplingState
  participant commit_edges
  participant emit_edge
  DiskGraphEngine->>SamplingState: reuse per-shard raw_edges buffers
  DiskGraphEngine->>commit_edges: pass collected edge buffers
  commit_edges->>SamplingState: update node state, frontier, and seed maps
  commit_edges->>emit_edge: emit each committed edge
Loading

Priority: Low

Merge Risk: Minimal · up to 0d8c8

The previously identified pooled-memory risk has been addressed for the demonstrated shard counts. No remaining issue warrants delaying the merge after normal checks.

Pre-merge checks: Passed 2 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Change Map Design Review Warning The prior compatibility defect remains. The PR adds node_key(type_id, global_id) as (global_id << 8) | type_id with only a debug_assert!(global_id < 1 << 56) (engine.rs:472-476). In release bu… Use a collision-free key that retains the full u64 ID and type, or disable per-seed recording when any reachable ID is at least 2^56. Document the compatibility condition in the change map and add a release-compatible regression test wi…
Passed Passed checks: 2 passed
Check name Status Explanation
Pr Size Passed The pull request changes 4 files. The limit is 40 files, so the PR size check passes.
Public Resource Access Passed The pull request adds no URL, repository location, or package source location for an external resource. The new test documentation uses local Cargo identifiers and a generic <index dir> environment-…
Full details: Public Resource Access

Explanation

The pull request adds no URL, repository location, or package source location for an external resource. The new test documentation uses local Cargo identifiers and a generic &lt;index dir&gt; environment-variable placeholder, not a public resource reference.

Full details: Change Map Design Review

Explanation

The prior compatibility defect remains. The PR adds node_key(type_id, global_id) as (global_id &lt;&lt; 8) | type_id with only a debug_assert!(global_id &lt; 1 &lt;&lt; 56) (engine.rs:472-476). In release builds, valid u64 IDs such as 0 and 2^56 collide. The new path is enabled for every non-fused engine with at most 256 node types (engine.rs:866-868), while ShardHandle ranges and sampled edge IDs remain u64; no reachable-ID fallback exists. The added equivalence tests use only IDs below 41. No PR conversation comment containing the required change-map marker was available, so its design rationale and verification claims cannot be confirmed. This is still a changed-code behavior-preservation defect.

Resolution

Use a collision-free key that retains the full u64 ID and type, or disable per-seed recording when any reachable ID is at least 2^56. Document the compatibility condition in the change map and add a release-compatible regression test with high valid IDs that compares per-seed and legacy output.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR

@Vedaant-J
Vedaant-J added this pull request to stack #103 October 22, 2026 18:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments: 1
packages/diskgraph/diskgraph-engine/src/state.rs: 1

261-268: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bound retained sampling buffers in SamplingState::reset.

PooledState::drop resets and returns each SamplingState to StatePool, but reset does not clear or shrink raw_edges or commit_scratch. A large layer can therefore retain high-water capacities across requests.

order, res, and new_edges scale with the layer edge count. bounds, cursor, and new_bounds scale with seed slots and can also retain large capacities. Apply the existing RESET_THRESHOLD policy to these vectors.

♻️ Suggested fix
         for m in &mut self.seed_maps {
             m.clear();
             if m.capacity() > SEED_MAP_KEEP {
                 m.shrink_to(SEED_MAP_KEEP);
             }
         }
+        for buf in &mut self.raw_edges {
+            buf.clear();
+            if buf.capacity() > RESET_THRESHOLD {
+                buf.shrink_to(RESET_THRESHOLD);
+            }
+        }
+        let cs = &mut self.commit_scratch;
+        for v in [&mut cs.res, &mut cs.bounds, &mut cs.cursor, &mut cs.new_bounds] {
+            v.clear();
+            if v.capacity() > RESET_THRESHOLD {
+                v.shrink_to(RESET_THRESHOLD);
+            }
+        }
+        cs.order.clear();
+        if cs.order.capacity() > RESET_THRESHOLD {
+            cs.order.shrink_to(RESET_THRESHOLD);
+        }
+        cs.new_edges.clear();
+        if cs.new_edges.capacity() > RESET_THRESHOLD {
+            cs.new_edges.shrink_to(RESET_THRESHOLD);
+        }

ℹ️ Review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 0c895ce and 954c87a.

📒 Files selected for processing: 4
  • packages/diskgraph/diskgraph-engine/src/engine.rs
  • packages/diskgraph/diskgraph-engine/src/state.rs
  • packages/diskgraph/diskgraph-engine/src/tests_unit.rs
  • packages/diskgraph/diskgraph-engine/tests/per_seed_recording_real_graph.rs

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 4 remain after this review.

@Vedaant-J

Vedaant-J commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Change map

File Function / scope What changed Why
packages/diskgraph/diskgraph-engine/src/engine.rs DiskGraphEngine::legacy_recording, sample_with_cutoff, process_layer, new commit_edges, emit_edge, record_edge, node_key Standalone layers commit edges through per-seed maps: bucket by seed, dedup per seed, assign ids and emit in edge order, write ids back. Seeds are registered in per-seed maps; commit_edges is sized by the request's seed slots; map-phase edge buffers are reused; record_edge shares emit_edge; the flag keeps the previous path. A request errors once one type reaches 2^31 local ids. The per-edge global index_map lookup was ~70% of deep-hop sampling CPU; output stays identical. The 2^31 check turns a silent id collision with the pending marker into an error.
packages/diskgraph/diskgraph-engine/src/state.rs SamplingState (seed_maps, seed_slot_base, seed_slots, raw_edges, commit_scratch), RawEdge, CommitScratch, clear_and_cap, reset, TypeState::index_map doc Per-seed maps, slot bases, commit scratch and edge buffers live in the pooled state; RawEdge moved here. Reset caps edge buffers and scratch at RESET_THRESHOLD, keeps seed maps only while their total capacity fits that budget, and keeps surplus edge buffers when a type has fewer shards. The index_map doc says it holds only seeds under per-seed recording. Reuse across requests and layers without one heavy request pinning its peak memory.
packages/diskgraph/diskgraph-engine/src/tests_unit.rs write_adjacency_shard, random_adjacency, per_seed_request, assert_per_seed_matches_legacy, tests per_seed_recording_matches_legacy_recording, per_seed_recording_matches_legacy_recording_across_seed_types The test shard builder takes arbitrary per-node adjacency. Equivalence vs the previous path is checked with one node type, and with two node types (A→B, B→A) and seeds of both types at overlapping indices, at 3/40/700 seeds and 1 and 4 threads. Proves identical ids, edges, nodes and stats, including the per-seed-type slot offsets and the type bits in node_key.
packages/diskgraph/diskgraph-engine/src/state.rs (tests) reset_caps_edge_buffers_and_commit_scratch Covers edge buffers, commit scratch and the seed-map budget. Pins the memory bound.
packages/diskgraph/diskgraph-engine/tests/per_seed_recording_real_graph.rs new ignored test Compares both paths on a real index (DISKGRAPH_VERIFY_INDEX) at 2–6 hops, and the previous path across fresh states. Equivalence on large graphs.

@Vedaant-J
Vedaant-J force-pushed the vedaant/dg-per-seed-recording branch from 954c87a to 16465c8 Compare October 22, 2026 20:32
@Vedaant-J
Vedaant-J had a problem deploying to gha-kumo-workflow-tests October 22, 2026 20:32 — with GitHub Actions Error
@Vedaant-J
Vedaant-J temporarily deployed to gha-kumo-workflow-tests October 22, 2026 20:32 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments: 1
packages/diskgraph/diskgraph-engine/src/state.rs: 1

232-233: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use one shared retention budget for per-seed maps.

A large request can grow up to 1024 seed maps, each retaining at least 16,384 element slots after reset. That is over 16 million retained slots per SamplingState, before hash-table metadata. StatePool has no size limit, so concurrent states can retain this memory until the pool is dropped.

Derive the per-map cap from RESET_THRESHOLD:

Suggested fix
-const SEED_MAP_KEEP: usize = 1 << 14;
-const SEED_MAPS_KEEP: usize = 1 << 10;
-
 const RESET_THRESHOLD: usize = 1 << 18; // ~262k items
+const SEED_MAPS_KEEP: usize = 1 << 10;
+const SEED_MAP_KEEP: usize = RESET_THRESHOLD / SEED_MAPS_KEEP;
🧹 Nitpick comments: 1
packages/diskgraph/diskgraph-engine/src/engine.rs: 1

2682-2683: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Keep surplus edge buffers when the shard count shrinks.

resize_with(shards.len(), Vec::new) truncates buffers when the previous node type had more shards than the current one. The zip also drops the excess buffers. The next type with more shards then allocates new empty buffers. A heterogeneous graph with types of different shard counts loses the buffer reuse that this PR adds. The loss repeats for every type and layer.

Split off the surplus buffers before the map phase. Append them back to state.raw_edges after each reassignment of state.raw_edges at Lines 2776 and 2797. Do not pass the surplus buffers to commit_edges or to the reduce loop, because they can hold stale edges.

♻️ Proposed change
             let mut buffers = std::mem::take(&mut state.raw_edges);
+            let spare = if buffers.len() > shards.len() {
+                buffers.split_off(shards.len())
+            } else {
+                Vec::new()
+            };
             buffers.resize_with(shards.len(), Vec::new);

Then, in both places that assign state.raw_edges = raw_edges_results;, add this line directly after the assignment:

state.raw_edges.extend(spare);

ℹ️ Review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 954c87a and 16465c8.

📒 Files selected for processing: 2
  • packages/diskgraph/diskgraph-engine/src/engine.rs
  • packages/diskgraph/diskgraph-engine/src/state.rs

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.

Base automatically changed from vedaant/dg-sampler-type-order to main October 27, 2026 21:06
Each standalone sampling layer committed its edges one at a time through
one hash map keyed by (seed, node) over every node sampled so far, which
grows to millions of entries and misses cache on nearly every lookup.
Edges are now grouped by seed and deduplicated in a small per-seed map,
then local ids are assigned and edges emitted in the original order, so
ids, edge order and stats are unchanged. Per-shard edge buffers are
reused across layers and requests and, like index_map, capped on reset.
`DiskGraphEngine::legacy_recording`
selects the previous path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/diskgraph/diskgraph-engine/src/state.rs
Comment thread packages/diskgraph/diskgraph-engine/src/tests_unit.rs Outdated
Comment thread packages/diskgraph/diskgraph-engine/src/engine.rs
Comment thread packages/diskgraph/diskgraph-engine/src/engine.rs
Comment thread packages/diskgraph/diskgraph-engine/src/engine.rs
@aaron-kumo

Copy link
Copy Markdown
Collaborator

Approach to split by seed looks solid. The id and edge-order logic appears correct. In addition to the inline comments, small suggestion: Add a benchmark on the training path. The numbers here use pooled DiskGraphEngine::sample. Training goes through runtime_batch_iterator → sample_runtime_batch, which creates a fresh SamplingState::default() for every batch. The trainer's auto-config (diskgraph_nested_trainer.py:919) selects standalone mode for larger subgraphs, which is the case this PR targets. On that path, reusing pooled buffers never applies, every batch grows about b seed maps from empty, and LP requests add several seed types. Could you add one measurement for the stack through sample_runtime_batch, ideally with an LP plan?

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 1
packages/diskgraph/diskgraph-engine/src/engine.rs: 1

2882-2882: Minor · Performance & Scalability · Quick win

Use the active slot count for commit loops.

SamplingState::reset retains up to SEED_MAPS_KEEP maps. A later request can use fewer slots, but commit_edges uses state.seed_maps.len() and iterates every retained map for each layer and source type. Store the current request’s slot count when sample_with_cutoff computes it, and use that count for the bounds, cursor, and new_bounds loops. Keep seed_maps.len() as the capacity.

Nitpick comments: 2
packages/diskgraph/diskgraph-engine/src/engine.rs: 1

2682-2683: Trivial · Performance & Scalability · Quick win

Buffer reuse loses buffers when the shard count decreases.

buffers.resize_with only grows the vector. Assume state.raw_edges holds more buffers than shards.len(). The zip with shard_groups then stops at the shorter side. buffers.into_par_iter() drops the unused buffers together with their capacity. Line 2776 and Line 2797 then store the shorter raw_edges_results back in state.raw_edges.

work_queue is processed by type ID, so a type with few shards can follow a type with many shards. This happens in every layer. The next type with many shards then allocates empty buffers again. The reuse of per-shard buffers then fails for graphs where node types have different shard counts.

Keep the surplus buffers and return them after the commit. Add the same line at Line 2776 (before continue) and at Line 2797.

♻️ Proposed fix
             let mut buffers = std::mem::take(&mut state.raw_edges);
-            buffers.resize_with(shards.len(), Vec::new);
+            let spare = if buffers.len() > shards.len() {
+                buffers.split_off(shards.len())
+            } else {
+                buffers.resize_with(shards.len(), Vec::new);
+                Vec::new()
+            };
// After each `state.raw_edges = raw_edges_results;` (Line 2776 and Line 2797):
state.raw_edges.extend(spare);
packages/diskgraph/diskgraph-engine/src/tests_unit.rs: 1

2510-2550: Trivial · Functional Correctness · Quick win

Compare the multi-type run with legacy recording.

No existing legacy comparison uses multiple seed types. repeated_runs_give_identical_output exercises overlapping seed index 0 for lhs and rhs, including the lhs-to-rhs relation, but it checks only repeated per-seed consistency.

Compare the non-fused run with legacy recording. Capture every type state, not only rhs: MockOutput::edges does not include node types, so the current proposed assertion could miss a type-key assignment that changes the lhs state while preserving numeric edge IDs.

✅ Suggested change
-    let sampler = DiskGraphEngine::new(
+    let mut sampler = DiskGraphEngine::new(
...
-                let rhs: Vec<_> = state.type_states[&1].nodes.iter().map(|n| (n.global_id, n.seed_type_id)).collect();
-                (output.edges, rhs, output.fused_batches)
+                let mut nodes: Vec<(usize, Vec<(u64, u32, u32, u32)>)> = state
+                    .type_states
+                    .iter()
+                    .map(|(&type_id, ts)| {
+                        (
+                            type_id,
+                            ts.nodes
+                                .iter()
+                                .map(|n| (n.global_id, n.seed_index, n.local_id, n.seed_type_id))
+                                .collect(),
+                        )
+                    })
+                    .collect();
+                nodes.sort_by_key(|(type_id, _)| *type_id);
+                (output.edges, nodes, output.fused_batches)
...
-            let rhs: Vec<_> = state.type_states[&1].nodes.iter().map(|n| (n.global_id, n.seed_type_id)).collect();
-            assert_eq!((output.edges, rhs), (runs[0].0.clone(), runs[0].1.clone()));
+            let mut nodes: Vec<(usize, Vec<(u64, u32, u32, u32)>)> = state
+                .type_states
+                .iter()
+                .map(|(&type_id, ts)| {
+                    (
+                        type_id,
+                        ts.nodes
+                            .iter()
+                            .map(|n| (n.global_id, n.seed_index, n.local_id, n.seed_type_id))
+                            .collect(),
+                    )
+                })
+                .collect();
+            nodes.sort_by_key(|(type_id, _)| *type_id);
+            assert_eq!((output.edges, nodes), (runs[0].0.clone(), runs[0].1.clone()));

Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 16465c8 and 16465c8.

Files selected for processing: 2
  • packages/diskgraph/diskgraph-engine/src/engine.rs
  • packages/diskgraph/diskgraph-engine/src/tests_unit.rs

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Vedaant-J and others added 8 commits October 27, 2026 21:18
A cleared map shrunk to a per-map cap still keeps its rounded-up bucket
array, so 1024 retained maps could pin ~0.5 GiB per pooled state. Keep
maps only while their summed capacity fits RESET_THRESHOLD and free the
rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pooled state keeps up to 1024 cleared seed maps from earlier requests;
the bucket and prefix-sum loops now cover only the current request's slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node types with different shard counts no longer drop the extra map-phase
buffers, so a later type with more shards reuses them instead of
reallocating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit_edges tags pending entries with bit 31, so a larger local id would
be misread as an edge index. Fail the request instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A->B and B->A relations with seeds of both types at overlapping indices,
3/40/700 seeds, 1 and 4 threads: covers seed_slot_base and the type bits
of node_key against the legacy path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Formats only lines this change adds; the surrounding map-phase block was
already unformatted and is left as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clippy's manual_is_multiple_of fires once the request index is a usize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Vedaant-J
Vedaant-J force-pushed the vedaant/dg-per-seed-recording branch from 16465c8 to 0d8c832 Compare October 27, 2026 22:12
@Vedaant-J
Vedaant-J deployed to gha-kumo-workflow-tests October 27, 2026 22:12 — with GitHub Actions Active
@Vedaant-J
Vedaant-J requested a review from aaron-kumo October 27, 2026 23:47
@Vedaant-J
Vedaant-J merged commit 299a403 into main Oct 28, 2026
168 of 169 checks passed
@Vedaant-J
Vedaant-J deleted the vedaant/dg-per-seed-recording branch October 28, 2026 17:27
Vedaant-J added a commit that referenced this pull request Oct 28, 2026
…uest (#102)

Stacked on #101 (`vedaant/dg-per-seed-recording`).

## Summary

This PR **runs the per-seed deduplication on the sampler's thread pool**
and **resolves seed anchor times once per request**. Together with the
previous PR, multi-threaded sampling is 2–3.3× faster than `main` at 3–6
hops, with identical output.

**Problem.** After the previous PR, a process with 8 sampler threads
still spent most of a 5–6-hop request in serial work, and one per-edge
lookup remained in the parallel phase:
- With 8 threads at 5 hops, the commit phase took 6.6 s of a 7.3 s run
(100M graph, 5,000 seeds), with about 2.3 cores busy.
- Every sampled (node, relation) pair looked up its seed's anchor time
by hashing the seed type's name string, about 8% of single-thread CPU.

**Cause.** The per-seed step only touches one seed's map and edges, but
it ran on one thread. The anchor lookup went `seed_type_id → type name →
HashMap<String, Vec<i64>>` on every call to `sample_segment_edges`.

**Change.**
- `commit_edges` splits the seeds into contiguous ranges of roughly
equal edge counts (4 per thread) and runs the deduplication and the
final map update on the engine's pool. Local id assignment and edge
output remain one in-order pass, so output is unchanged. The parallel
path runs only when the pool has more than one thread and the layer has
at least `DiskGraphEngine::parallel_commit_min_edges` edges (default
32,768); single-thread processes run the same code inline. The default
comes from forcing the threshold to 1 and to infinity at 8 threads:
layers of a few thousand to ~25k edges ran 10–50% slower on the pool,
layers of 100k+ edges ran faster. Cuts fall on seed boundaries, so
ranges are uneven; 4 ranges per thread let the pool balance them.
- The in-order pass keeps each destination type's state at hand instead
of looking it up in `type_states` for every new node.
- `sample_with_cutoff` resolves each seed type's anchor slice once;
`sample_segment_edges` takes that slice table instead of the request.

## Back-compat

| Situation | Behaviour |
|---|---|
| Standalone sampling, any thread count | Same local ids, edges,
frontiers and stats as the previous path (tested at 4 threads). |
| 1-thread process | No parallel dispatch; same code inline. |
| Fused feature-fetching sampling | Anchor change only; output
unchanged. |
| `legacy_recording = true` | Previous path. |
| `parallel_commit_min_edges` | New public engine field; only changes
when the parallel dedup runs, never the output. |

## Measured results

Same indexes, workload, ctxbench-style driver and machine as the
previous PR. Sampling seeds/s, better of two interleaved rounds:

| graph | threads | hops | `main` | previous PR | this PR | this PR /
`main` | with feature fetch, `main` → this PR | cores busy, `main` →
this PR | p99 per batch, `main` → this PR |
|---|---|---|---|---|---|---|---|---|---|
| 100M | 1 | 2 | 36,496 | 31,250 | 35,211 | 0.96× | 18,587 → 17,794 |
1.0 → 1.0 | 30 → 29 ms |
| 100M | 1 | 3 | 7,899 | 9,960 | 10,846 | 1.37× | 5,123 → 6,090 | 1.0 →
1.0 | 107 → 85 ms |
| 100M | 1 | 4 | 2,836 | 3,213 | 4,340 | 1.53× | 2,094 → 2,769 | 1.0 →
1.0 | 250 → 191 ms |
| 100M | 1 | 5 | 331 | 578 | 648 | 1.96× | 270 → 435 | 1.0 → 1.0 | 1.95
→ 1.23 s |
| 100M | 1 | 6 | 154 | 226 | 269 | 1.74× | 138 → 222 | 1.0 → 1.0 | 3.82
→ 2.44 s |
| 100M | 8 | 2 | 51,546 | 53,191 | 56,180 | 1.09× | 27,778 → 28,902 |
3.1 → 3.2 | 19 → 18 ms |
| 100M | 8 | 3 | 9,804 | 12,987 | 19,685 | 2.01× | 7,728 → 13,021 | 2.5
→ 3.2 | 67 → 41 ms |
| 100M | 8 | 4 | 4,444 | 5,359 | 8,865 | 1.99× | 3,771 → 6,831 | 2.6 →
3.5 | 139 → 82 ms |
| 100M | 8 | 5 | 421 | 704 | 1,268 | 3.01× | 393 → 1,041 | 1.8 → 3.2 |
1.34 → 0.52 s |
| 100M | 8 | 6 | 235 | 397 | 630 | 2.68× | 225 → 563 | 1.9 → 3.1 | 2.38
→ 1.00 s |
| 1B | 1 | 2 | 30,675 | 27,473 | 30,303 | 0.99× | 15,385 → 15,773 | 1.0
→ 1.0 | 34 → 33 ms |
| 1B | 1 | 3 | 6,821 | 8,913 | 9,980 | 1.46× | 4,355 → 5,556 | 1.0 → 1.0
| 125 → 99 ms |
| 1B | 1 | 4 | 2,900 | 3,183 | 3,843 | 1.33× | 2,033 → 2,449 | 1.0 → 1.0
| 266 → 224 ms |
| 1B | 1 | 5 | 327 | 554 | 612 | 1.87× | 262 → 418 | 1.0 → 1.0 | 2.12 →
1.33 s |
| 1B | 1 | 6 | 162 | 236 | 282 | 1.74× | 142 → 228 | 1.0 → 1.0 | 3.85 →
2.39 s |
| 1B | 8 | 2 | 48,544 | 48,077 | 53,191 | 1.10× | 25,253 → 26,455 | 3.7
→ 3.8 | 21 → 20 ms |
| 1B | 8 | 3 | 9,634 | 13,587 | 19,455 | 2.02× | 7,396 → 12,255 | 2.8 →
3.6 | 73 → 46 ms |
| 1B | 8 | 4 | 4,651 | 5,525 | 8,000 | 1.72× | 3,975 → 5,995 | 2.5 → 3.8
| 133 → 89 ms |
| 1B | 8 | 5 | 402 | 716 | 1,334 | 3.32× | 374 → 1,066 | 1.8 → 3.3 |
1.42 → 0.51 s |
| 1B | 8 | 6 | 233 | 407 | 680 | 2.91× | 223 → 593 | 1.9 → 3.5 | 2.39 →
0.92 s |

Four concurrent processes of 8 threads each (aggregate sampling seeds/s;
a scaled-down version of the 16×8 layout used on large hosts, limited by
this shared machine):

| graph | hops | `main` | this PR | × | with feature fetch × | cores
busy, `main` → this PR |
|---|---|---|---|---|---|---|
| 100M | 2 | 197,466 | 241,907 | 1.23× | 1.14× | 12.5 → 12.6 |
| 100M | 3 | 39,897 | 74,313 | 1.86× | 1.60× | 9.6 → 12.7 |
| 100M | 4 | 17,195 | 31,178 | 1.81× | 1.62× | 9.7 → 14.1 |
| 100M | 5 | 1,409 | 4,261 | 3.02× | 2.62× | 6.4 → 10.9 |
| 100M | 6 | 877 | 2,338 | 2.67× | 2.48× | 7.0 → 12.5 |
| 1B | 2 | 191,409 | 218,250 | 1.14× | 1.06× | 13.7 → 14.0 |
| 1B | 3 | 38,287 | 74,560 | 1.95× | 1.58× | 10.3 → 14.2 |
| 1B | 4 | 17,512 | 33,131 | 1.89× | 1.64× | 10.1 → 15.2 |
| 1B | 5 | 1,349 | 4,406 | 3.27× | 2.82× | 6.7 → 13.1 |
| 1B | 6 | 748 | 2,218 | 2.96× | 2.72× | 7.1 → 13.7 |

Peak RSS per process rises by 0.0–0.3 GB over `main`.

### Training path (`sample_runtime_batch`, link prediction)

Same setup as the training-path table in #101: `sample_runtime_batch`
with a fresh `SamplingState` per batch, 1B index, two-tower
link-prediction stage table, b=512 with one engine negative per row
(three seed groups of 512), features skipped unless noted. Per-batch
time, previous path (`legacy_recording = true`) → this PR, better of
three interleaved rounds of 4 batches; identical node counts in every
cell.

| threads | 2 hops | 3 hops | 4 hops | 5 hops | 6 hops |
|---|---|---|---|---|---|
| 2 | 96 → 87 ms (1.10×) | 714 → 470 ms (1.52×) | 1.64 → 1.11 s (1.48×)
| 7.23 → 3.90 s (1.86×) | 16.5 → 9.31 s (1.78×) |
| 8 | 79 → 67 ms (1.17×) | 676 → 371 ms (1.82×) | 1.46 → 0.88 s (1.66×)
| 6.74 → 3.01 s (2.24×) | 14.9 → 7.02 s (2.12×) |

With feature fetch for every sampled node (8 threads): 114 → 101 ms
(1.13×) at 2 hops, 1.89 → 1.28 s (1.48×) at 4 hops.

### 50B index

| Hops | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|
| seeds/s (best config, #102) | 392k | 169k | 98k | 13.8k | 9.3k |
| vs main (best config) | 1.10× | 1.16× | 1.19× | 1.6× | 1.4× |

50B commerce, i7ie.48xlarge, warm, b=512, numa_balancing=0, identical
row counts.

Same published synthetic graph as #93 (49,997M rows, 7 node types × 171
shards, 2.6 TB index). Machine: 192 vCPU, 1.5 TiB RAM, local NVMe,
`kernel.numa_balancing=0`. Harness: P processes × T threads, sampling
plus feature fetch for every sampled node, b=512, warm (a warm-up pass,
then the same seeds measured). `main` and this PR return identical row
counts in every cell.

Seeds/s, `main` → this PR:

| hops | 64×3 | 16×8 | 96×2 |
|---|---|---|---|
| 2 | 273,570 → 374,560 (1.37×)¹ | 200,983 → 219,036 (1.09×) | 356,736 →
392,243 (1.10×) |
| 3 | 118,877 → 161,784 (1.36×) | 61,169 → 84,639 (1.38×) | 146,194 →
169,197 (1.16×) |
| 4 | 70,050 → 92,251 (1.32×) | 29,632 → 44,783 (1.51×) | 81,754 →
97,583 (1.19×) |
| 5 | 7,850 → 13,750 (1.75×) | 3,619 → 6,713 (1.86×) | 8,560 → 11,687
(1.37×) |
| 6 | 5,729 → 8,740 (1.53×) | 2,114 → 4,146 (1.96×) | 6,777 → 9,319
(1.38×) |

Best config per hop:

| hops | `main` | this PR | #93 run, b=512 | #93 run, best config |
DuckDB 2.0 native 8×16 / 8×24 |
|---|---|---|---|---|---|
| 2 | 356,736 | 392,243 | 387,683 (64×3) | 421,042 (64×3) | 37,332 /
42,651 |
| 3 | 146,194 | 169,197 | —² | 143,935 (96×2) | 24,549 / 27,605 |
| 4 | 81,754 | 97,583 | 77,214 (96×2) | 77,316 (96×2) | 15,867 / 15,045
|
| 5 | 8,560 | 13,750 | —² | 10,919 (96×2) | 4,184 / 5,002 |
| 6 | 6,777 | 9,319 | 6,774 (96×2) | 6,582 (96×2) | 3,020 / 3,138 |

¹ `main`'s measured pass still read 6.7 GB from disk in this cell, so
part of the gain is warm-up; 96×2 is the cleaner 2-hop comparison.
² The #93 run measured b=512 only at 2, 4 and 6 hops. Its best-config
column is the 192-vCPU row of #93's table, at batch sizes tuned per hop.

At 6 hops, p99 per batch at 16×8 drops from 4.2 s to 2.1 s.

Each cell above was measured once; repeated runs on this machine type
have shown about 15% run-to-run spread, so the 1.10–1.19× best-config
gains at 2–4 hops are within noise; the 1.4–1.6× gains at 5–6 hops are
clearly real. The gains are smaller than on the 64-vCPU machine because
the best 50B configs run many processes with 2–3 threads each: there the
commit phase is a smaller share and page faults dominate. The 16×8
layout, where commit work is concentrated in fewer processes, gains
1.4–2.0× at 3–6 hops.

## Known limitations

- One seed that holds most of a layer's edges (a hub seed) lands in a
single range, so its dedup runs on one thread and caps the parallel
speedup for that request.
- With 8 threads at 5–6 hops, the remaining serial work is the in-order
id assignment and edge output (about 60% of commit time) and the
counting sort (about 20%).

## Testing Done

- Both equivalence tests from #101 also run with
`parallel_commit_min_edges = 1`, so at 4 threads every multi-seed layer
takes the parallel branch, and compare that run with the previous path.
- `tests/per_seed_recording_real_graph.rs` (4 threads) passes on both
indexes at 2–6 hops.
- `cargo test -p diskgraph-engine`, `-p diskgraph-ingest --test
seed_table_ingest --test resolution_runtime_batch` and `-p
diskgraph-pybind` pass; `cargo clippy --workspace --all-targets -- -D
warnings` is clean.

## Checklist
- [x] No breaking changes.
- [x] Unit tests added.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
gha-kumo-workflow-tests — 0d8c8325 Deployed Oct 27, 2026 by Vedaant-J via import #892
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.

2 participants