from aethergraph import Graph, DynamicGraph, HeteroGraph
from aethergraph.pytorch import NeighborLoader, HeteroNeighborLoader| Crate | Purpose |
|---|---|
aethergraph-core |
Static CSR graph, homo + hetero sampling, feature store, io_uring |
aether-graph |
Dynamic C-tree graph, lock-free concurrent R/W, arena bump-alloc |
aether-stream |
AF_XDP ingestion, seqlock features, ibverbs RDMA, GPUDirect |
aether-mem |
Lock-free slab allocator, HugePages, RDMA/CUDA memory hooks |
aether-epoch |
EpochClock — monotonic version counter shared by subsystems |
aethergraph-py |
PyO3 Python bindings for all of the above |
aethergraph-cli |
CLI tools (convert, info, stats) |
CSR-backed graph with SIMD-accelerated sampling. Memory-mapped from NVMe, instant startup.
Sampling design:
- Generation-tagged direct array (< 1M nodes) or FxHashMap (> 1M nodes) for node dedup — O(1) lookup
- Pre-allocated buffers reused across calls via
clear()— zero hot-path allocations - Local indices assigned during sampling — no post-sort, no binary search for edge remapping
- Floyd's O(k) sampling with 256-byte stack bitmap for small degree nodes
- Double-buffered frontiers —
mem::swapinstead of allocating per hop
Heterogeneous sampling:
- Multiple CSR matrices (one per edge type) with per-type local node IDs.
- Per-edge-type fanout. Stack-copied edge type IDs in the hot loop to avoid borrow conflicts.
- Macro-inlined Floyd's to avoid
&mut selfconflicts with bitmap.
Cache-locality reordering (Rabbit Order, Arai et al. IPDPS 2016):
- Phase 1 (parallel over V): each node picks its lowest-degree neighbor as a merge candidate.
- Phase 2 (parallel over E): lock-free concurrent union-find with
AtomicU32parent + rank, path-splittingfind, CASunion. Logs(winner, loser)pairs in merge order. - Phase 3 (sequential O(V)): replay the merge log into a dendrogram and emit the permutation by in-order traversal.
- Community partitions are a free byproduct of the merge log —
rabbit_partitions()runs a sequential UF replay without rebuilding the dendrogram.reorder_rabbit_with_partitions()returns both in one pass. partition_aligned_batchesuses the partition labels to construct seed batches whose neighborhoods overlap, amortizing destination-array reads across the batch.- Measured in
benches/graph_benchmarks.rs::reorder_sampling_speedup— sampling throughput delta on the same seeds before vs. after permutation.
Lock-free C-tree for live-updating graphs. Single-writer, multi-reader via functional persistence.
Chunk (64 bytes = 1 cache line):
┌────────────────────────────────────────────────┐
│ count: u8 │ pad: 3B │ neighbors: [u32; 15] │
└────────────────────────────────────────────────┘
C-tree for degree 45 node:
[Interior]
/ \
[Chunk: 15] [Interior]
/ \
[Chunk: 15] [Chunk: 15]
Design:
- 90% of nodes (degree < 15) fit in a single chunk — 1 cache line read, same as CSR
- Edge insert: path-copy affected nodes, arena bump-alloc (single
fetch_add), atomic root swap - Readers see a consistent snapshot via functional persistence — old tree valid for concurrent readers
- C-tree balance: scapegoat scheme (α = 2/3) — an insert that pushes its path past the α-height bound rebuilds the highest weight-unbalanced subtree, so depth stays O(log degree) even for monotonically increasing neighbor IDs
- Arena: pre-allocated contiguous region (max 2 GiB — u32 offsets with a tag
bit), zero-alloc writes; superseded nodes are reclaimed in bulk by
DynamicGraph::compact, which rebuilds live trees into a fresh arena - 9M inserts/sec, 38M neighbor reads/sec (criterion benchmarks)
Linux-only kernel-bypass engine. Compiles to empty crate on macOS/Windows.
Seqlock slot layout (RDMA-safe):
[head_version: u64] [features: f32 × dim] [tail_version: u64]
Writers: head odd → write features → tail even → head even. RDMA readers: single bulk read, GPU kernel validates head == tail.
ibverbs FFI: 16 extern C functions, 12 #[repr(C)] structs. Non-opaque
IbvMr (rkey/lkey fields). IbvQp with correct field offsets through qp_num.
IbvSendWrUnion as a real union with atomic-variant padding.
GPU pipeline: GpuGatherBuffer (VRAM via cudarc, registered with NIC via
nvidia-peermem) → SeqlockValidator (CUDA kernel compiled at runtime via nvrtc
from standalone .cu file) → DLPack capsule → torch.from_dlpack().
Seed Nodes
│
▼
NeighborLoader (prefetch thread)
│
├── sample_neighbors() ─── CSR (mmap'd from NVMe)
│ Floyd's O(k), zero-alloc
│
├── get_batch() ────────── FeatureStore (mmap or io_uring)
│
└── PyO3 boundary ──────── numpy via from_vec (zero-copy)
│
▼
PyG Data object
(x, edge_index, n_id, ...)
Kafka / Event Stream
│
▼
DynamicGraph.insert_edge()
│ C-tree path copy
│ Arena bump alloc
│ Atomic root swap
│
▼
Vertex Array [AtomicU32 roots]
│
│ (concurrent, lock-free)
│
▼
Sampler reads C-tree
│ Iterates sorted chunks
│ Sees consistent snapshot
│
▼
PyG Data / HeteroData
Raw Ethernet frames (UDP)
│
▼
┌──────────────────────┐
│ AF_XDP Ingestion │ ← busy-poll, per-NIC-queue thread, pinned to core
│ (XdpSocket + Umem) │
└──────────────────────┘
│
▼
┌──────────────────────┐
│ FeatureTable │ ← seqlock write (head odd → write → tail even → head even)
│ (HugePage RAM) │
└──────────────────────┘
│ GPU Node
├── TCP Control Plane ──► fetch_advertisement() → base_addr, rkey, schema
│ connect_with_qp() → QP endpoint exchange
│ │
└── RDMA READ ◄──────────────── RdmaFeatureClient::gather(node_ids)
│
▼
GpuGatherBuffer (VRAM staging, nvidia-peermem)
│
▼
SeqlockValidator (CUDA kernel)
if head == tail && even → compact to output
else → retry
│
▼
DLPack → torch.from_dlpack()
│
▼
batch.x on CUDA (< 5us total)
HeteroGraph:
node_types: [user(100K), post(500K), comment(1M), subreddit(10K)]
edge_types:
(user, votes, post) → CSR [100K src × 500K dst]
(user, writes, comment) → CSR [100K src × 1M dst]
(comment, reply_to, comment) → CSR [1M src × 1M dst]
(post, belongs_to, subreddit)→ CSR [500K src × 10K dst]
HeteroNeighborSampler:
For each hop:
For each (node_type, local_id) in frontier:
For each edge_type where src_type == node_type:
sample fanout neighbors from CSR
assign local indices during sampling (zero post-processing)
| Type | Crate | Purpose |
|---|---|---|
Graph |
core | Static CSR, mmap'd from NVMe |
HeteroGraph |
core | Multi-relational CSR (one per edge type) |
DynamicGraph |
aether-graph | Lock-free C-tree, concurrent R/W |
NeighborSampler |
core | Floyd's O(k) sampling, zero-alloc |
reorder_rabbit |
core | Rabbit Order permutation (parallel UF merge) |
rabbit_partitions |
core | Community labels (free byproduct of merge log) |
HeteroNeighborSampler |
core | Typed multi-hop, pre-computed local indices |
NeighborLoader |
core | Prefetch thread, io_uring features |
FeatureTable |
aether-stream | Seqlock feature table for RDMA |
RdmaFeatureClient |
aether-stream | GPUDirect RDMA gather API |
Chunk |
aether-graph | 64-byte sorted neighbor chunk (1 cache line) |
Arena |
aether-graph | Bump allocator for C-tree nodes |
CTree |
aether-graph | Balanced tree of chunks (functional persistence) |
SharedMemoryRing |
aether-mem | Lock-free slab with HugePages |
| Platform | Static Graph | Dynamic Graph | Feature I/O | Streaming |
|---|---|---|---|---|
| Linux | mmap | C-tree | io_uring + SQPOLL | AF_XDP + RDMA |
| macOS | mmap | C-tree | mmap + madvise | N/A |
| Windows | mmap | C-tree | mmap | N/A |
Every on-disk format starts with a 32-bit magic plus a 32-bit version integer.
The version is monotonically increasing — old wheels MUST refuse to load a
newer-version file rather than mis-parsing it. Adding backward-compatibility for
an older version requires a deliberate code change + a golden-file regression
test (see crates/aethergraph-core/tests/golden/).
| Version | Magic | Status | Notes |
|---|---|---|---|
| v1 | 0x41455448 ("AETH") |
current | only version emitted today. |
Layout (v1):
[Header: 32 bytes — see `aethergraph_core::internal::mmap::Header`]
magic u32 = 0x41455448 ("AETH")
version u32 = 1
num_nodes u64
num_edges u64
has_weights u32 (0 = absent, 1 = present)
integrity_checksum u32 (0 = absent; FNV-1a 32-bit of offsets+edges otherwise;
verified only under Full validation — large files
default to OffsetsOnly, which skips the body hash)
[Offsets: (num_nodes + 1) × 8 bytes, u64 LE]
[Edges: num_edges × 4 bytes, u32 LE]
[Weights: num_edges × 4 bytes, f32 LE] (only if has_weights = 1)
Forward-compat policy:
- A wheel that knows version N MUST reject version N+1 with a clear error message ("file format version N+1 is newer than this wheel knows about (max N)"), not attempt a best-effort parse.
- Removing a field from a struct = bump major version.
- Adding a trailing optional section = same magic, bump version, MUST read header.version before deciding whether to consume the new bytes.
| Version | Magic | Status | Notes |
|---|---|---|---|
| v1 | "AETHHETG" |
current | One CSR section per edge type. |
Layout (v1): 64-byte header (magic, version u32, type counts, reserved),
node/edge type tables, then per-edge-type CSR sections. u32 sections (edges,
weights) are zero-padded to 8-byte boundaries so every offsets array stays
u64-aligned. See aethergraph_core::internal::mmap_hetero for field-level
detail. The loader bounds-checks every section against the file length with
checked arithmetic — corrupt or truncated files fail at load, not at access.
| Version | Magic | Status | Notes |
|---|---|---|---|
| v1 | "AETHFEAT" |
current | f32 row-major. |
Layout (v1):
[Header]
magic char[8] = "AETHFEAT"
num_nodes u64
feature_dim u64
data_offset u64
[Data: num_nodes × feature_dim × 4 bytes, f32 LE row-major]
crates/aethergraph-core/tests/golden_format.rs round-trips the v1 graph and
feature formats from committed byte vectors. Any silent layout drift (field
reorder, padding change, magic typo) fails CI on the first affected commit.
To add support for v2 of any format:
- Bump the
VERSIONconstant in the producer. - Add the new struct alongside the old one (do NOT mutate the existing one).
- Branch in the loader on
header.version. - Commit a golden file at the new version and a regression test that loads BOTH v1 (existing golden) and v2 (new golden).
| Feature | Homogeneous | Heterogeneous |
|---|---|---|
data |
Graph | HeteroGraph |
num_neighbors |
list[int] | dict[(src, rel, dst) → list[int]] |
input_nodes |
Tensor | (node_type, Tensor) |
batch_size, shuffle |
yes | yes |
pin_memory |
yes | yes |
transform |
yes | yes |
weighted |
yes | — |
temporal_strategy |
uniform/last | — |
disjoint |
yes | — |
neighbor_sampler |
callable | — |
| Returns | PyG Data | PyG HeteroData |
| API | Determinism |
|---|---|
NeighborSampler::sample_neighbors |
Bit-deterministic given seed. |
NeighborSampler::sample_neighbors_temporal |
Bit-deterministic given seed. |
NeighborSampler::sample_neighbors_disjoint |
Bit-deterministic given seed. |
ParallelBatchSampler::sample_batches |
Statistically reproducible given seed. Rayon work-stealing can permute per-batch order across runs. Set SamplingConfig::deterministic = true to fall back to serial execution and get bit-deterministic output at 2–8× lower throughput. |
Graph::reorder_rabbit |
Statistically reproducible. Concurrent union-find may converge to one of several valid dendrograms depending on thread interleaving. No deterministic mode (yet). |
HeteroNeighborSampler::sample_neighbors |
Bit-deterministic given seed. |
| RDMA gather paths | Statistical only (network reordering / NIC-level interleaving). |
Tests that need bit-equality MUST set SamplingConfig::deterministic = true
(Python: SamplingConfig(..., deterministic=True)). Tests that just need
statistical equivalence (mean fanout, expected hop coverage) can leave the
default.
The aether-epoch crate ships a single primitive — EpochClock, an AtomicU64
wrapped in an ergonomic API. Subsystems opt in by accepting an Arc<EpochClock>
and bumping it on commit; readers snapshot EpochClock::current() to pin a
version before issuing a multi-source read.
| Subsystem | Role | Contract |
|---|---|---|
DynamicGraph |
Producer | Advances the clock once on each clean writer-guard drop. Panic-poisoned drops do NOT advance — readers must never observe a version that claims to include partial edits. |
Future: versioned FeatureStore |
Producer | Will advance on each batch commit, sharing the same clock with the graph. |
| Reader code (sampler, RDMA path) | Consumer | Pins current_epoch before fan-out; passes it to subsystems for range-checked reads. |
Python: DynamicGraph.current_epoch is a @property returning int.
Three independent layers, each used independently.
1. Structured logging / spans (Rust + Python)
- Rust uses the
tracingcrate.#[tracing::instrument]is attached to coarse-grained entry points:NeighborLoader::new/NeighborLoader::shutdown,FeatureStore::load,load_graph, andWriter::drop(trace-level event per commit, warn-level on panic-poisoned drop). Hot per-sample paths are NOT instrumented — they use atomic counters instead. - Python uses OpenTelemetry through
aethergraph.tracing(install withpip install aethergraph[otel]).NeighborLoader.__iter__and the Ray datasource emit spans namedepoch,ray_worker,hetero_epoch. - Both layers serialize to OTLP — point them at the same collector to get a unified trace across the Rust and Python sides of a sampling call.
2. Atomic counters (zero-cost when unread)
| Subsystem | Collector | Exposed via |
|---|---|---|
| Neighborhood sampler | SamplingTelemetry |
NeighborSampler::with_telemetry (Rust); SamplingTelemetry() in SamplingConfig (Python) |
| Feature store | FeatureLoadTelemetry |
FeatureStore::with_telemetry (Rust); FeatureStore.load(..., telemetry=True) (Python) |
| Prefetch loader | PrefetchStats |
NeighborLoader::stats (always available) |
All three are Arc-shared, lock-free, and have negligible overhead. Call
summary() / stats() to drain at scrape time.
3. Prometheus exposition
aethergraph_core::metrics::MetricsSnapshot rolls the three collectors into one
snapshot and serializes to Prometheus 0.0.4 text format:
use aethergraph_core::metrics::MetricsSnapshot;
let snap = MetricsSnapshot::collect(
Some(&sampling_telemetry),
feature_store.telemetry(),
Some(&loader.stats_arc()),
);
let exposition: String = snap.to_prometheus(); // serve at GET /metricsMetric names follow aethergraph_<subsystem>_<event>_total for counters and
aethergraph_<subsystem>_<event> for gauges. There is no embedded HTTP server —
wire exposition into whichever framework you host (axum, warp, hyper, etc.).
Python consumers use prometheus_client directly against the existing telemetry
attributes (loader.stats.hit_rate, store.telemetry().summary(), etc.) — no
separate Python-side serializer.
aether-graph ships an optional append-only write-ahead log behind the wal
Cargo feature. Default builds get no WAL code or runtime cost; callers who need
crash durability opt in:
use aether_graph::DynamicGraph;
let g = DynamicGraph::open_with_wal("/var/lib/aethergraph/edges.wal", 1_000_000, 8 << 20)?;Record format (24 bytes per insert):
| Bytes | Field | Notes |
|---|---|---|
| 0..8 | epoch | EpochClock value at insert time. Reserved for future MVCC use. |
| 8..12 | src | u32 little-endian |
| 12..16 | dst | u32 little-endian |
| 16..20 | _pad | reserved (record-type tag for future deletes / annotations) |
| 20..24 | crc32 | CRC32 (IEEE) of bytes [0..20] |
Fixed-size records keep replay branchless; the per-record CRC catches partial writes from a kernel that crashed mid-flush.
Durability contract:
Writer::insert_edgewrites one record to aBufWriter. No fsync.Writer::drop(clean path) callsfdatasync(2)— one syscall per writer-guard regardless of edge count. Returns control after the data is durable.- WAL append failure inside
insert_edge, or fsync failure on drop, poisons the graph. Subsequentwriter()calls returnWriterError::Poisoned. Recovery requires destroying the in-memory graph and re-opening the WAL.
Recovery:
open_with_wal reads every record in order, invoking Writer::insert_edge
internally to rebuild the in-memory state. The shared EpochClock advances once
per replayed record, so current_epoch() after recovery equals the number of
records replayed. A live run advances the clock once per writer-guard drop, so
the two agree only when each guard inserted exactly one edge — never compare
epoch values across a restart. If the file ends in a torn record (CRC mismatch /
short read), recovery truncates back to the last clean boundary so subsequent
appends sit on intact data. A record referencing a vertex ≥ the num_vertices
being recovered into, or an arena too small for the log's contents, aborts
recovery with an error rather than silently dropping records.
What is NOT in scope today:
- Checkpoints — the WAL grows unbounded; no mechanism truncates "everything before epoch N" yet. Production users with high ingest rates must rotate manually.
- Per-batch atomicity — a single
Writerguard's inserts are not a transaction. A crash mid-guard recovers a prefix, not all-or-nothing. - Async /
io_uring— sync writes viaBufWriter+fdatasync. Enough for the typical Kafka-driven ingest rate; the I/O path can be swapped later without changing the on-disk format.