## 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
- [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>
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
mainat 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:
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 tosample_segment_edges.Change.
commit_edgessplits 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 leastDiskGraphEngine::parallel_commit_min_edgesedges (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.type_statesfor every new node.sample_with_cutoffresolves each seed type's anchor slice once;sample_segment_edgestakes that slice table instead of the request.Back-compat
legacy_recording = trueparallel_commit_min_edgesMeasured results
Same indexes, workload, ctxbench-style driver and machine as the previous PR. Sampling seeds/s, better of two interleaved rounds:
mainmainmain→ this PRmain→ this PRmain→ this PRFour 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):
mainmain→ this PRPeak 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_batchwith a freshSamplingStateper 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.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
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).mainand this PR return identical row counts in every cell.Seeds/s,
main→ this PR:Best config per hop:
main¹
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
Testing Done
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_batchand-p diskgraph-pybindpass;cargo clippy --workspace --all-targets -- -D warningsis clean.Checklist
🤖 Generated with Claude Code