chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""Implementation of GraphBolt."""
|
||||
from .basic_feature_store import *
|
||||
from .fused_csc_sampling_graph import *
|
||||
from .gpu_feature_cache import *
|
||||
from .gpu_cached_feature import *
|
||||
from .in_subgraph_sampler import *
|
||||
from .legacy_dataset import *
|
||||
from .neighbor_sampler import *
|
||||
from .temporal_neighbor_sampler import *
|
||||
from .ondisk_dataset import *
|
||||
from .ondisk_metadata import *
|
||||
from .sampled_subgraph_impl import *
|
||||
from .torch_based_feature_store import *
|
||||
from .uniform_negative_sampler import *
|
||||
from .gpu_graph_cache import *
|
||||
from .cpu_feature_cache import *
|
||||
from .cpu_cached_feature import *
|
||||
from .cooperative_conv import *
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Basic feature store for GraphBolt."""
|
||||
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from ..feature_store import Feature, FeatureKey, FeatureStore
|
||||
|
||||
__all__ = ["BasicFeatureStore"]
|
||||
|
||||
|
||||
class BasicFeatureStore(FeatureStore):
|
||||
r"""A basic feature store to manage multiple features for access."""
|
||||
|
||||
def __init__(self, features: Dict[Tuple[str, str, str], Feature]):
|
||||
r"""Initiate a basic feature store.
|
||||
|
||||
|
||||
Parameters
|
||||
----------
|
||||
features : Dict[Tuple[str, str, str], Feature]
|
||||
The dict of features served by the feature store, in which the key
|
||||
is tuple of (domain, type_name, feature_name).
|
||||
|
||||
Returns
|
||||
-------
|
||||
The feature stores.
|
||||
"""
|
||||
super().__init__()
|
||||
self._features = features
|
||||
|
||||
def __getitem__(self, feature_key: FeatureKey) -> Feature:
|
||||
"""Access the underlying `Feature` with its (domain, type, name) as
|
||||
the feature_key.
|
||||
"""
|
||||
return self._features[feature_key]
|
||||
|
||||
def __setitem__(self, feature_key: FeatureKey, feature: Feature):
|
||||
"""Set the underlying `Feature` with its (domain, type, name) as
|
||||
the feature_key and feature as the value.
|
||||
"""
|
||||
self._features[feature_key] = feature
|
||||
|
||||
def __contains__(self, feature_key: FeatureKey) -> bool:
|
||||
"""Checks whether the provided (domain, type, name) as the feature_key
|
||||
is container in the BasicFeatureStore."""
|
||||
return feature_key in self._features
|
||||
|
||||
def __len__(self):
|
||||
"""Return the number of features."""
|
||||
return len(self._features)
|
||||
|
||||
def keys(self):
|
||||
"""Get the keys of the features.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[tuple]
|
||||
The keys of the features. The tuples are in `(domain, type_name,
|
||||
feat_name)` format.
|
||||
"""
|
||||
return list(self._features.keys())
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Graphbolt cooperative convolution."""
|
||||
from typing import Dict, Union
|
||||
|
||||
import torch
|
||||
|
||||
from ..sampled_subgraph import SampledSubgraph
|
||||
from ..subgraph_sampler import all_to_all, convert_to_hetero, revert_to_homo
|
||||
|
||||
__all__ = ["CooperativeConvFunction", "CooperativeConv"]
|
||||
|
||||
|
||||
class CooperativeConvFunction(torch.autograd.Function):
|
||||
"""Cooperative convolution operation from Cooperative Minibatching.
|
||||
|
||||
Implements the `all-to-all` message passing algorithm
|
||||
in Cooperative Minibatching, which was initially proposed in
|
||||
`Deep Graph Library PR#4337<https://github.com/dmlc/dgl/pull/4337>`__ and
|
||||
was later first fully described in
|
||||
`Cooperative Minibatching in Graph Neural Networks
|
||||
<https://arxiv.org/abs/2310.12403>`__.
|
||||
Cooperation between the GPUs eliminates duplicate work performed across the
|
||||
GPUs due to the overlapping sampled k-hop neighborhoods of seed nodes when
|
||||
performing GNN minibatching. This reduces the redundant computations across
|
||||
GPUs at the expense of communication.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def forward(
|
||||
ctx,
|
||||
subgraph: SampledSubgraph,
|
||||
tensor: Union[torch.Tensor, Dict[str, torch.Tensor]],
|
||||
):
|
||||
"""Implements the forward pass."""
|
||||
counts_sent = convert_to_hetero(subgraph._counts_sent)
|
||||
counts_received = convert_to_hetero(subgraph._counts_received)
|
||||
seed_inverse_ids = convert_to_hetero(subgraph._seed_inverse_ids)
|
||||
seed_sizes = convert_to_hetero(subgraph._seed_sizes)
|
||||
ctx.communication_variables = (
|
||||
counts_sent,
|
||||
counts_received,
|
||||
seed_inverse_ids,
|
||||
seed_sizes,
|
||||
)
|
||||
outs = {}
|
||||
for ntype, typed_tensor in convert_to_hetero(tensor).items():
|
||||
out = typed_tensor.new_empty(
|
||||
(sum(counts_sent[ntype]),) + typed_tensor.shape[1:]
|
||||
)
|
||||
all_to_all(
|
||||
torch.split(out, counts_sent[ntype]),
|
||||
torch.split(
|
||||
typed_tensor[seed_inverse_ids[ntype]],
|
||||
counts_received[ntype],
|
||||
),
|
||||
)
|
||||
outs[ntype] = out
|
||||
return revert_to_homo(out)
|
||||
|
||||
@staticmethod
|
||||
def backward(
|
||||
ctx, grad_output: Union[torch.Tensor, Dict[str, torch.Tensor]]
|
||||
):
|
||||
"""Implements the backward pass."""
|
||||
(
|
||||
counts_sent,
|
||||
counts_received,
|
||||
seed_inverse_ids,
|
||||
seed_sizes,
|
||||
) = ctx.communication_variables
|
||||
delattr(ctx, "communication_variables")
|
||||
outs = {}
|
||||
for ntype, typed_grad_output in convert_to_hetero(grad_output).items():
|
||||
out = typed_grad_output.new_empty(
|
||||
(sum(counts_received[ntype]),) + typed_grad_output.shape[1:]
|
||||
)
|
||||
all_to_all(
|
||||
torch.split(out, counts_received[ntype]),
|
||||
torch.split(typed_grad_output, counts_sent[ntype]),
|
||||
)
|
||||
i = out.new_empty(2, out.shape[0], dtype=torch.int64)
|
||||
i[0] = seed_inverse_ids[ntype] # src
|
||||
i[1] = torch.arange(
|
||||
out.shape[0], device=typed_grad_output.device
|
||||
) # dst
|
||||
coo = torch.sparse_coo_tensor(
|
||||
i,
|
||||
torch.ones(
|
||||
i.shape[1], dtype=grad_output.dtype, device=i.device
|
||||
),
|
||||
size=(seed_sizes[ntype], i.shape[1]),
|
||||
)
|
||||
outs[ntype] = torch.sparse.mm(coo, out)
|
||||
return None, revert_to_homo(outs)
|
||||
|
||||
|
||||
class CooperativeConv(torch.nn.Module):
|
||||
"""Cooperative convolution operation from Cooperative Minibatching.
|
||||
|
||||
Implements the `all-to-all` message passing algorithm
|
||||
in Cooperative Minibatching, which was initially proposed in
|
||||
`Deep Graph Library PR#4337<https://github.com/dmlc/dgl/pull/4337>`__ and
|
||||
was later first fully described in
|
||||
`Cooperative Minibatching in Graph Neural Networks
|
||||
<https://arxiv.org/abs/2310.12403>`__.
|
||||
Cooperation between the GPUs eliminates duplicate work performed across the
|
||||
GPUs due to the overlapping sampled k-hop neighborhoods of seed nodes when
|
||||
performing GNN minibatching. This reduces the redundant computations across
|
||||
GPUs at the expense of communication.
|
||||
"""
|
||||
|
||||
def forward(
|
||||
self,
|
||||
subgraph: SampledSubgraph,
|
||||
x: Union[torch.Tensor, Dict[str, torch.Tensor]],
|
||||
):
|
||||
"""Implements the forward pass."""
|
||||
return CooperativeConvFunction.apply(subgraph, x)
|
||||
@@ -0,0 +1,499 @@
|
||||
"""CPU cached feature for GraphBolt."""
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
import torch
|
||||
|
||||
from ..base import get_device_to_host_uva_stream, get_host_to_device_uva_stream
|
||||
from ..feature_store import (
|
||||
bytes_to_number_of_items,
|
||||
Feature,
|
||||
FeatureKey,
|
||||
wrap_with_cached_feature,
|
||||
)
|
||||
|
||||
from .cpu_feature_cache import CPUFeatureCache
|
||||
|
||||
__all__ = ["CPUCachedFeature", "cpu_cached_feature"]
|
||||
|
||||
|
||||
class CPUCachedFeature(Feature):
|
||||
r"""CPU cached feature wrapping a fallback feature. Use `cpu_cached_feature`
|
||||
to construct an instance of this class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fallback_feature : Feature
|
||||
The fallback feature.
|
||||
cache : CPUFeatureCache
|
||||
A CPUFeatureCache instance to serve as the cache backend.
|
||||
offset : int, optional
|
||||
The offset value to add to the given ids before using the cache. This
|
||||
parameter is useful if multiple `CPUCachedFeature`s are sharing a single
|
||||
CPUFeatureCache object.
|
||||
"""
|
||||
|
||||
_cache_type = CPUFeatureCache
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fallback_feature: Feature,
|
||||
cache: CPUFeatureCache,
|
||||
offset: int = 0,
|
||||
):
|
||||
super(CPUCachedFeature, self).__init__()
|
||||
assert isinstance(fallback_feature, Feature), (
|
||||
f"The fallback_feature must be an instance of Feature, but got "
|
||||
f"{type(fallback_feature)}."
|
||||
)
|
||||
self._fallback_feature = fallback_feature
|
||||
self._feature = cache
|
||||
self._offset = offset
|
||||
|
||||
def read(self, ids: torch.Tensor = None):
|
||||
"""Read the feature by index.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids : torch.Tensor, optional
|
||||
The index of the feature. If specified, only the specified indices
|
||||
of the feature are read. If None, the entire feature is returned.
|
||||
|
||||
Returns
|
||||
-------
|
||||
torch.Tensor
|
||||
The read feature.
|
||||
"""
|
||||
if ids is None:
|
||||
return self._fallback_feature.read()
|
||||
return self._feature.query_and_replace(
|
||||
ids.cpu(), self._fallback_feature.read, self._offset
|
||||
).to(ids.device)
|
||||
|
||||
def read_async(self, ids: torch.Tensor):
|
||||
r"""Read the feature by index asynchronously.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids : torch.Tensor
|
||||
The index of the feature. Only the specified indices of the
|
||||
feature are read.
|
||||
Returns
|
||||
-------
|
||||
A generator object.
|
||||
The returned generator object returns a future on
|
||||
``read_async_num_stages(ids.device)``\ th invocation. The return result
|
||||
can be accessed by calling ``.wait()``. on the returned future object.
|
||||
It is undefined behavior to call ``.wait()`` more than once.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import dgl.graphbolt as gb
|
||||
>>> feature = gb.Feature(...)
|
||||
>>> ids = torch.tensor([0, 2])
|
||||
>>> for stage, future in enumerate(feature.read_async(ids)):
|
||||
... pass
|
||||
>>> assert stage + 1 == feature.read_async_num_stages(ids.device)
|
||||
>>> result = future.wait() # result contains the read values.
|
||||
"""
|
||||
policy = self._feature._policy
|
||||
cache = self._feature._cache
|
||||
if ids.is_cuda and self.is_pinned():
|
||||
ids_device = ids.device
|
||||
current_stream = torch.cuda.current_stream()
|
||||
device_to_host_stream = get_device_to_host_uva_stream()
|
||||
device_to_host_stream.wait_stream(current_stream)
|
||||
with torch.cuda.stream(device_to_host_stream):
|
||||
ids.record_stream(torch.cuda.current_stream())
|
||||
ids = ids.to("cpu", non_blocking=True)
|
||||
ids_copy_event = torch.cuda.Event()
|
||||
ids_copy_event.record()
|
||||
|
||||
yield # first stage is done.
|
||||
|
||||
ids_copy_event.synchronize()
|
||||
policy_future = policy.query_and_replace_async(ids, self._offset)
|
||||
|
||||
yield
|
||||
|
||||
(
|
||||
positions,
|
||||
index,
|
||||
pointers,
|
||||
missing_keys,
|
||||
found_offsets,
|
||||
missing_offsets,
|
||||
) = policy_future.wait()
|
||||
self._feature.total_queries += ids.shape[0]
|
||||
self._feature.total_miss += missing_keys.shape[0]
|
||||
found_cnt = ids.size(0) - missing_keys.size(0)
|
||||
found_positions = positions[:found_cnt]
|
||||
missing_positions = positions[found_cnt:]
|
||||
found_pointers = pointers[:found_cnt]
|
||||
missing_pointers = pointers[found_cnt:]
|
||||
host_to_device_stream = get_host_to_device_uva_stream()
|
||||
with torch.cuda.stream(host_to_device_stream):
|
||||
found_positions = found_positions.to(
|
||||
ids_device, non_blocking=True
|
||||
)
|
||||
values_from_cpu = cache.index_select(found_positions)
|
||||
values_from_cpu.record_stream(current_stream)
|
||||
values_from_cpu_copy_event = torch.cuda.Event()
|
||||
values_from_cpu_copy_event.record()
|
||||
|
||||
fallback_reader = self._fallback_feature.read_async(missing_keys)
|
||||
for _ in range(
|
||||
self._fallback_feature.read_async_num_stages(
|
||||
missing_keys.device
|
||||
)
|
||||
):
|
||||
missing_values_future = next(fallback_reader, None)
|
||||
yield # fallback feature stages.
|
||||
|
||||
values_from_cpu_copy_event.synchronize()
|
||||
reading_completed = policy.reading_completed_async(
|
||||
found_pointers, found_offsets
|
||||
)
|
||||
|
||||
missing_values = missing_values_future.wait()
|
||||
replace_future = cache.replace_async(
|
||||
missing_positions, missing_values
|
||||
)
|
||||
|
||||
host_to_device_stream = get_host_to_device_uva_stream()
|
||||
with torch.cuda.stream(host_to_device_stream):
|
||||
index = index.to(ids_device, non_blocking=True)
|
||||
missing_values = missing_values.to(
|
||||
ids_device, non_blocking=True
|
||||
)
|
||||
index.record_stream(current_stream)
|
||||
missing_values.record_stream(current_stream)
|
||||
missing_values_copy_event = torch.cuda.Event()
|
||||
missing_values_copy_event.record()
|
||||
|
||||
yield
|
||||
|
||||
reading_completed.wait()
|
||||
replace_future.wait()
|
||||
writing_completed = policy.writing_completed_async(
|
||||
missing_pointers, missing_offsets
|
||||
)
|
||||
|
||||
class _Waiter:
|
||||
def __init__(self, events, existing, missing, index):
|
||||
self.events = events
|
||||
self.existing = existing
|
||||
self.missing = missing
|
||||
self.index = index
|
||||
|
||||
def wait(self):
|
||||
"""Returns the stored value when invoked."""
|
||||
for event in self.events:
|
||||
event.wait()
|
||||
values = torch.empty(
|
||||
(self.index.shape[0],) + self.missing.shape[1:],
|
||||
dtype=self.missing.dtype,
|
||||
device=ids_device,
|
||||
)
|
||||
num_found = self.existing.size(0)
|
||||
found_index = self.index[:num_found]
|
||||
missing_index = self.index[num_found:]
|
||||
values[found_index] = self.existing
|
||||
values[missing_index] = self.missing
|
||||
# Ensure there is no memory leak.
|
||||
self.events = self.existing = None
|
||||
self.missing = self.index = None
|
||||
return values
|
||||
|
||||
yield _Waiter(
|
||||
[
|
||||
writing_completed,
|
||||
values_from_cpu_copy_event,
|
||||
missing_values_copy_event,
|
||||
],
|
||||
values_from_cpu,
|
||||
missing_values,
|
||||
index,
|
||||
)
|
||||
elif ids.is_cuda:
|
||||
ids_device = ids.device
|
||||
current_stream = torch.cuda.current_stream()
|
||||
device_to_host_stream = get_device_to_host_uva_stream()
|
||||
device_to_host_stream.wait_stream(current_stream)
|
||||
with torch.cuda.stream(device_to_host_stream):
|
||||
ids.record_stream(torch.cuda.current_stream())
|
||||
ids = ids.to("cpu", non_blocking=True)
|
||||
ids_copy_event = torch.cuda.Event()
|
||||
ids_copy_event.record()
|
||||
|
||||
yield # first stage is done.
|
||||
|
||||
ids_copy_event.synchronize()
|
||||
policy_future = policy.query_and_replace_async(ids, self._offset)
|
||||
|
||||
yield
|
||||
|
||||
(
|
||||
positions,
|
||||
index,
|
||||
pointers,
|
||||
missing_keys,
|
||||
found_offsets,
|
||||
missing_offsets,
|
||||
) = policy_future.wait()
|
||||
self._feature.total_queries += ids.shape[0]
|
||||
self._feature.total_miss += missing_keys.shape[0]
|
||||
found_cnt = ids.size(0) - missing_keys.size(0)
|
||||
found_positions = positions[:found_cnt]
|
||||
missing_positions = positions[found_cnt:]
|
||||
found_pointers = pointers[:found_cnt]
|
||||
missing_pointers = pointers[found_cnt:]
|
||||
values_future = cache.query_async(
|
||||
found_positions, index, ids.shape[0]
|
||||
)
|
||||
|
||||
fallback_reader = self._fallback_feature.read_async(missing_keys)
|
||||
for _ in range(
|
||||
self._fallback_feature.read_async_num_stages(
|
||||
missing_keys.device
|
||||
)
|
||||
):
|
||||
missing_values_future = next(fallback_reader, None)
|
||||
yield # fallback feature stages.
|
||||
|
||||
values = values_future.wait()
|
||||
reading_completed = policy.reading_completed_async(
|
||||
found_pointers, found_offsets
|
||||
)
|
||||
|
||||
missing_index = index[found_cnt:]
|
||||
|
||||
missing_values = missing_values_future.wait()
|
||||
replace_future = cache.replace_async(
|
||||
missing_positions, missing_values
|
||||
)
|
||||
values = torch.ops.graphbolt.scatter_async(
|
||||
values, missing_index, missing_values
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
host_to_device_stream = get_host_to_device_uva_stream()
|
||||
with torch.cuda.stream(host_to_device_stream):
|
||||
values = values.wait().to(ids_device, non_blocking=True)
|
||||
values.record_stream(current_stream)
|
||||
values_copy_event = torch.cuda.Event()
|
||||
values_copy_event.record()
|
||||
|
||||
reading_completed.wait()
|
||||
replace_future.wait()
|
||||
writing_completed = policy.writing_completed_async(
|
||||
missing_pointers, missing_offsets
|
||||
)
|
||||
|
||||
class _Waiter:
|
||||
def __init__(self, events, values):
|
||||
self.events = events
|
||||
self.values = values
|
||||
|
||||
def wait(self):
|
||||
"""Returns the stored value when invoked."""
|
||||
for event in self.events:
|
||||
event.wait()
|
||||
values = self.values
|
||||
# Ensure there is no memory leak.
|
||||
self.events = self.values = None
|
||||
return values
|
||||
|
||||
yield _Waiter([values_copy_event, writing_completed], values)
|
||||
else:
|
||||
policy_future = policy.query_and_replace_async(ids, self._offset)
|
||||
|
||||
yield
|
||||
|
||||
(
|
||||
positions,
|
||||
index,
|
||||
pointers,
|
||||
missing_keys,
|
||||
found_offsets,
|
||||
missing_offsets,
|
||||
) = policy_future.wait()
|
||||
self._feature.total_queries += ids.shape[0]
|
||||
self._feature.total_miss += missing_keys.shape[0]
|
||||
found_cnt = ids.size(0) - missing_keys.size(0)
|
||||
found_positions = positions[:found_cnt]
|
||||
missing_positions = positions[found_cnt:]
|
||||
found_pointers = pointers[:found_cnt]
|
||||
missing_pointers = pointers[found_cnt:]
|
||||
values_future = cache.query_async(
|
||||
found_positions, index, ids.shape[0]
|
||||
)
|
||||
|
||||
fallback_reader = self._fallback_feature.read_async(missing_keys)
|
||||
for _ in range(
|
||||
self._fallback_feature.read_async_num_stages(
|
||||
missing_keys.device
|
||||
)
|
||||
):
|
||||
missing_values_future = next(fallback_reader, None)
|
||||
yield # fallback feature stages.
|
||||
|
||||
values = values_future.wait()
|
||||
reading_completed = policy.reading_completed_async(
|
||||
found_pointers, found_offsets
|
||||
)
|
||||
|
||||
missing_index = index[found_cnt:]
|
||||
|
||||
missing_values = missing_values_future.wait()
|
||||
replace_future = cache.replace_async(
|
||||
missing_positions, missing_values
|
||||
)
|
||||
values = torch.ops.graphbolt.scatter_async(
|
||||
values, missing_index, missing_values
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
reading_completed.wait()
|
||||
replace_future.wait()
|
||||
writing_completed = policy.writing_completed_async(
|
||||
missing_pointers, missing_offsets
|
||||
)
|
||||
|
||||
class _Waiter:
|
||||
def __init__(self, event, values):
|
||||
self.event = event
|
||||
self.values = values
|
||||
|
||||
def wait(self):
|
||||
"""Returns the stored value when invoked."""
|
||||
self.event.wait()
|
||||
values = self.values.wait()
|
||||
# Ensure there is no memory leak.
|
||||
self.event = self.values = None
|
||||
return values
|
||||
|
||||
yield _Waiter(writing_completed, values)
|
||||
|
||||
def read_async_num_stages(self, ids_device: torch.device):
|
||||
"""The number of stages of the read_async operation. See read_async
|
||||
function for directions on its use. This function is required to return
|
||||
the number of yield operations when read_async is used with a tensor
|
||||
residing on ids_device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids_device : torch.device
|
||||
The device of the ids parameter passed into read_async.
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of stages of the read_async operation.
|
||||
"""
|
||||
if ids_device.type == "cuda":
|
||||
return 4 + self._fallback_feature.read_async_num_stages(
|
||||
torch.device("cpu")
|
||||
)
|
||||
else:
|
||||
return 3 + self._fallback_feature.read_async_num_stages(ids_device)
|
||||
|
||||
def size(self):
|
||||
"""Get the size of the feature.
|
||||
|
||||
Returns
|
||||
-------
|
||||
torch.Size
|
||||
The size of the feature.
|
||||
"""
|
||||
return self._fallback_feature.size()
|
||||
|
||||
def count(self):
|
||||
"""Get the count of the feature.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The count of the feature.
|
||||
"""
|
||||
return self._fallback_feature.count()
|
||||
|
||||
def update(self, value: torch.Tensor, ids: torch.Tensor = None):
|
||||
"""Update the feature.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value : torch.Tensor
|
||||
The updated value of the feature.
|
||||
ids : torch.Tensor, optional
|
||||
The indices of the feature to update. If specified, only the
|
||||
specified indices of the feature will be updated. For the feature,
|
||||
the `ids[i]` row is updated to `value[i]`. So the indices and value
|
||||
must have the same length. If None, the entire feature will be
|
||||
updated.
|
||||
"""
|
||||
if ids is None:
|
||||
feat0 = value[:1]
|
||||
self._fallback_feature.update(value)
|
||||
cache_size = min(
|
||||
bytes_to_number_of_items(self.cache_size_in_bytes, feat0),
|
||||
value.shape[0],
|
||||
)
|
||||
self._feature = None # Destroy the existing cache first.
|
||||
self._feature = self._cache_type(
|
||||
(cache_size,) + feat0.shape[1:], feat0.dtype
|
||||
)
|
||||
else:
|
||||
self._fallback_feature.update(value, ids)
|
||||
self._feature.replace(ids, value, None, self._offset)
|
||||
|
||||
def is_pinned(self):
|
||||
"""Returns True if the cache storage is pinned."""
|
||||
return self._feature.is_pinned()
|
||||
|
||||
@property
|
||||
def cache_size_in_bytes(self):
|
||||
"""Return the size taken by the cache in bytes."""
|
||||
return self._feature.max_size_in_bytes
|
||||
|
||||
@property
|
||||
def miss_rate(self):
|
||||
"""Returns the cache miss rate since creation."""
|
||||
return self._feature.miss_rate
|
||||
|
||||
|
||||
def cpu_cached_feature(
|
||||
fallback_features: Union[Feature, Dict[FeatureKey, Feature]],
|
||||
max_cache_size_in_bytes: int,
|
||||
policy: Optional[str] = None,
|
||||
pin_memory: bool = False,
|
||||
) -> Union[CPUCachedFeature, Dict[FeatureKey, CPUCachedFeature]]:
|
||||
r"""CPU cached feature wrapping a fallback feature.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fallback_features : Union[Feature, Dict[FeatureKey, Feature]]
|
||||
The fallback feature(s).
|
||||
max_cache_size_in_bytes : int
|
||||
The capacity of the cache in bytes. The size should be a few factors
|
||||
larger than the size of each read request. Otherwise, the caching policy
|
||||
will hang due to all cache entries being read and/or write locked,
|
||||
resulting in a deadlock.
|
||||
policy : str, optional
|
||||
The cache eviction policy algorithm name. The available policies are
|
||||
["s3-fifo", "sieve", "lru", "clock"]. Default is "sieve".
|
||||
pin_memory : bool, optional
|
||||
Whether the cache storage should be allocated on system pinned memory.
|
||||
Default is False.
|
||||
Returns
|
||||
-------
|
||||
Union[CPUCachedFeature, Dict[FeatureKey, CPUCachedFeature]]
|
||||
New feature(s) wrapped with CPUCachedFeature.
|
||||
"""
|
||||
return wrap_with_cached_feature(
|
||||
CPUCachedFeature,
|
||||
fallback_features,
|
||||
max_cache_size_in_bytes,
|
||||
policy=policy,
|
||||
pin_memory=pin_memory,
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""CPU Feature Cache implementation wrapper for graphbolt."""
|
||||
import torch
|
||||
|
||||
__all__ = ["CPUFeatureCache"]
|
||||
|
||||
caching_policies = {
|
||||
"s3-fifo": torch.ops.graphbolt.s3_fifo_cache_policy,
|
||||
"sieve": torch.ops.graphbolt.sieve_cache_policy,
|
||||
"lru": torch.ops.graphbolt.lru_cache_policy,
|
||||
"clock": torch.ops.graphbolt.clock_cache_policy,
|
||||
}
|
||||
|
||||
|
||||
class CPUFeatureCache(object):
|
||||
r"""High level wrapper for the CPU feature cache.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cache_shape : List[int]
|
||||
The shape of the cache. cache_shape[0] gives us the capacity.
|
||||
dtype : torch.dtype
|
||||
The data type of the elements stored in the cache.
|
||||
policy: str, optional
|
||||
The cache policy. Default is "sieve". "s3-fifo", "lru" and "clock" are
|
||||
also available.
|
||||
num_parts: int, optional
|
||||
The number of cache partitions for parallelism. Default is
|
||||
`torch.get_num_threads()`.
|
||||
pin_memory: bool, optional
|
||||
Whether the cache storage should be pinned.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_shape,
|
||||
dtype,
|
||||
policy=None,
|
||||
num_parts=None,
|
||||
pin_memory=False,
|
||||
):
|
||||
if policy is None:
|
||||
policy = "sieve"
|
||||
assert (
|
||||
policy in caching_policies
|
||||
), f"{list(caching_policies.keys())} are the available caching policies."
|
||||
if num_parts is None:
|
||||
num_parts = torch.get_num_threads()
|
||||
min_num_cache_items = num_parts * (10 if policy == "s3-fifo" else 1)
|
||||
# Since we partition the cache, each partition needs to have a positive
|
||||
# number of slots. In addition, each "s3-fifo" partition needs at least
|
||||
# 10 slots since the small queue is 10% and the small queue needs a
|
||||
# positive size.
|
||||
if cache_shape[0] < min_num_cache_items:
|
||||
cache_shape = (min_num_cache_items,) + cache_shape[1:]
|
||||
self._policy = caching_policies[policy](cache_shape[0], num_parts)
|
||||
self._cache = torch.ops.graphbolt.feature_cache(
|
||||
cache_shape, dtype, pin_memory
|
||||
)
|
||||
self.total_miss = 0
|
||||
self.total_queries = 0
|
||||
|
||||
def is_pinned(self):
|
||||
"""Returns True if the cache storage is pinned."""
|
||||
return self._cache.is_pinned()
|
||||
|
||||
@property
|
||||
def max_size_in_bytes(self):
|
||||
"""Return the size taken by the cache in bytes."""
|
||||
return self._cache.nbytes
|
||||
|
||||
def query(self, keys, offset=0):
|
||||
"""Queries the cache.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
keys : Tensor
|
||||
The keys to query the cache with.
|
||||
offset : int
|
||||
The offset to be added to the keys. Default is 0.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple(Tensor, Tensor, Tensor, Tensor)
|
||||
A tuple containing
|
||||
(values, missing_indices, missing_keys, missing_offsets) where
|
||||
values[missing_indices] corresponds to cache misses that should be
|
||||
filled by quering another source with missing_keys. If keys is
|
||||
pinned, then the returned values tensor is pinned as well. The
|
||||
missing_offsets tensor has the partition offsets of missing_keys.
|
||||
"""
|
||||
self.total_queries += keys.shape[0]
|
||||
(
|
||||
positions,
|
||||
index,
|
||||
missing_keys,
|
||||
found_pointers,
|
||||
found_offsets,
|
||||
missing_offsets,
|
||||
) = self._policy.query(keys, offset)
|
||||
values = self._cache.query(positions, index, keys.shape[0])
|
||||
self._policy.reading_completed(found_pointers, found_offsets)
|
||||
self.total_miss += missing_keys.shape[0]
|
||||
missing_index = index[positions.size(0) :]
|
||||
return values, missing_index, missing_keys, missing_offsets
|
||||
|
||||
def query_and_replace(self, keys, reader_fn, offset=0):
|
||||
"""Queries the cache. Then inserts the keys that are not found by
|
||||
reading them by calling `reader_fn(missing_keys)`, which are then
|
||||
inserted into the cache using the selected caching policy algorithm
|
||||
to remove the old entries if it is full.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
keys : Tensor
|
||||
The keys to query the cache with.
|
||||
reader_fn : reader_fn(keys: torch.Tensor) -> torch.Tensor
|
||||
A function that will take a missing keys tensor and will return
|
||||
their values.
|
||||
offset : int
|
||||
The offset to be added to the keys. Default is 0.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Tensor
|
||||
A tensor containing values corresponding to the keys. Should equal
|
||||
`reader_fn(keys)`, computed in a faster way.
|
||||
"""
|
||||
self.total_queries += keys.shape[0]
|
||||
(
|
||||
positions,
|
||||
index,
|
||||
pointers,
|
||||
missing_keys,
|
||||
found_offsets,
|
||||
missing_offsets,
|
||||
) = self._policy.query_and_replace(keys, offset)
|
||||
found_cnt = keys.size(0) - missing_keys.size(0)
|
||||
found_positions = positions[:found_cnt]
|
||||
values = self._cache.query(found_positions, index, keys.shape[0])
|
||||
found_pointers = pointers[:found_cnt]
|
||||
self._policy.reading_completed(found_pointers, found_offsets)
|
||||
self.total_miss += missing_keys.shape[0]
|
||||
missing_index = index[found_cnt:]
|
||||
missing_values = reader_fn(missing_keys)
|
||||
values[missing_index] = missing_values
|
||||
missing_positions = positions[found_cnt:]
|
||||
self._cache.replace(missing_positions, missing_values)
|
||||
missing_pointers = pointers[found_cnt:]
|
||||
self._policy.writing_completed(missing_pointers, missing_offsets)
|
||||
return values
|
||||
|
||||
def replace(self, keys, values, offsets=None, offset=0):
|
||||
"""Inserts key-value pairs into the cache using the selected caching
|
||||
policy algorithm to remove old key-value pairs if it is full.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
keys : Tensor
|
||||
The keys to insert to the cache.
|
||||
values : Tensor
|
||||
The values to insert to the cache.
|
||||
offsets : Tensor, optional
|
||||
The partition offsets of the keys.
|
||||
offset : int
|
||||
The offset to be added to the keys. Default is 0.
|
||||
"""
|
||||
positions, pointers, offsets = self._policy.replace(
|
||||
keys, offsets, offset
|
||||
)
|
||||
self._cache.replace(positions, values)
|
||||
self._policy.writing_completed(pointers, offsets)
|
||||
|
||||
@property
|
||||
def miss_rate(self):
|
||||
"""Returns the cache miss rate since creation."""
|
||||
return self.total_miss / self.total_queries
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
"""GPU cached feature for GraphBolt."""
|
||||
from typing import Dict, Union
|
||||
|
||||
import torch
|
||||
|
||||
from ..feature_store import (
|
||||
bytes_to_number_of_items,
|
||||
Feature,
|
||||
FeatureKey,
|
||||
wrap_with_cached_feature,
|
||||
)
|
||||
|
||||
from .gpu_feature_cache import GPUFeatureCache
|
||||
|
||||
__all__ = ["GPUCachedFeature", "gpu_cached_feature"]
|
||||
|
||||
|
||||
class GPUCachedFeature(Feature):
|
||||
r"""GPU cached feature wrapping a fallback feature. It uses the least
|
||||
recently used (LRU) algorithm as the cache eviction policy. Use
|
||||
`gpu_cached_feature` to construct an instance of this class.
|
||||
|
||||
Places the GPU cache to torch.cuda.current_device().
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fallback_feature : Feature
|
||||
The fallback feature.
|
||||
cache : GPUFeatureCache
|
||||
A GPUFeatureCache instance to serve as the cache backend.
|
||||
offset : int, optional
|
||||
The offset value to add to the given ids before using the cache. This
|
||||
parameter is useful if multiple `GPUCachedFeature`s are sharing a single
|
||||
GPUFeatureCache object.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import torch
|
||||
>>> from dgl import graphbolt as gb
|
||||
>>> torch_feat = torch.arange(10).reshape(2, -1).to("cuda")
|
||||
>>> cache_size = 5
|
||||
>>> fallback_feature = gb.TorchBasedFeature(torch_feat)
|
||||
>>> feature = gb.gpu_cached_feature(fallback_feature, cache_size)
|
||||
>>> feature.read()
|
||||
tensor([[0, 1, 2, 3, 4],
|
||||
[5, 6, 7, 8, 9]], device='cuda:0')
|
||||
>>> feature.read(torch.tensor([0]).to("cuda"))
|
||||
tensor([[0, 1, 2, 3, 4]], device='cuda:0')
|
||||
>>> feature.update(torch.tensor([[1 for _ in range(5)]]).to("cuda"),
|
||||
... torch.tensor([1]).to("cuda"))
|
||||
>>> feature.read(torch.tensor([0, 1]).to("cuda"))
|
||||
tensor([[0, 1, 2, 3, 4],
|
||||
[1, 1, 1, 1, 1]], device='cuda:0')
|
||||
>>> feature.size()
|
||||
torch.Size([5])
|
||||
"""
|
||||
|
||||
_cache_type = GPUFeatureCache
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fallback_feature: Feature,
|
||||
cache: GPUFeatureCache,
|
||||
offset: int = 0,
|
||||
):
|
||||
super(GPUCachedFeature, self).__init__()
|
||||
assert isinstance(fallback_feature, Feature), (
|
||||
f"The fallback_feature must be an instance of Feature, but got "
|
||||
f"{type(fallback_feature)}."
|
||||
)
|
||||
self._fallback_feature = fallback_feature
|
||||
self._feature = cache
|
||||
self._offset = offset
|
||||
|
||||
def read(self, ids: torch.Tensor = None):
|
||||
"""Read the feature by index.
|
||||
|
||||
The returned tensor is always in GPU memory, no matter whether the
|
||||
fallback feature is in memory or on disk.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids : torch.Tensor, optional
|
||||
The index of the feature. If specified, only the specified indices
|
||||
of the feature are read. If None, the entire feature is returned.
|
||||
|
||||
Returns
|
||||
-------
|
||||
torch.Tensor
|
||||
The read feature.
|
||||
"""
|
||||
if ids is None:
|
||||
return self._fallback_feature.read()
|
||||
values, missing_index, missing_keys = self._feature.query(
|
||||
ids if self._offset == 0 else ids + self._offset
|
||||
)
|
||||
missing_values = self._fallback_feature.read(
|
||||
missing_keys if self._offset == 0 else missing_keys - self._offset
|
||||
)
|
||||
values[missing_index] = missing_values
|
||||
self._feature.replace(missing_keys, missing_values)
|
||||
return values
|
||||
|
||||
def read_async(self, ids: torch.Tensor):
|
||||
r"""Read the feature by index asynchronously.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids : torch.Tensor
|
||||
The index of the feature. Only the specified indices of the
|
||||
feature are read.
|
||||
Returns
|
||||
-------
|
||||
A generator object.
|
||||
The returned generator object returns a future on
|
||||
``read_async_num_stages(ids.device)``\ th invocation. The return result
|
||||
can be accessed by calling ``.wait()``. on the returned future object.
|
||||
It is undefined behavior to call ``.wait()`` more than once.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import dgl.graphbolt as gb
|
||||
>>> feature = gb.Feature(...)
|
||||
>>> ids = torch.tensor([0, 2])
|
||||
>>> for stage, future in enumerate(feature.read_async(ids)):
|
||||
... pass
|
||||
>>> assert stage + 1 == feature.read_async_num_stages(ids.device)
|
||||
>>> result = future.wait() # result contains the read values.
|
||||
"""
|
||||
future = self._feature.query(
|
||||
ids if self._offset == 0 else ids + self._offset, async_op=True
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
values, missing_index, missing_keys = future.wait()
|
||||
|
||||
fallback_reader = self._fallback_feature.read_async(
|
||||
missing_keys if self._offset == 0 else missing_keys - self._offset
|
||||
)
|
||||
fallback_num_stages = self._fallback_feature.read_async_num_stages(
|
||||
missing_keys.device
|
||||
)
|
||||
for i in range(fallback_num_stages):
|
||||
missing_values_future = next(fallback_reader, None)
|
||||
if i < fallback_num_stages - 1:
|
||||
yield # fallback feature stages.
|
||||
|
||||
class _Waiter:
|
||||
def __init__(
|
||||
self,
|
||||
feature,
|
||||
values,
|
||||
missing_index,
|
||||
missing_keys,
|
||||
missing_values_future,
|
||||
):
|
||||
self.feature = feature
|
||||
self.values = values
|
||||
self.missing_index = missing_index
|
||||
self.missing_keys = missing_keys
|
||||
self.missing_values_future = missing_values_future
|
||||
|
||||
def wait(self):
|
||||
"""Returns the stored value when invoked."""
|
||||
missing_values = self.missing_values_future.wait()
|
||||
self.feature.replace(self.missing_keys, missing_values)
|
||||
self.values[self.missing_index] = missing_values
|
||||
values = self.values
|
||||
# Ensure there is no memory leak.
|
||||
self.feature = self.values = self.missing_index = None
|
||||
self.missing_keys = self.missing_values_future = None
|
||||
return values
|
||||
|
||||
yield _Waiter(
|
||||
self._feature,
|
||||
values,
|
||||
missing_index,
|
||||
missing_keys,
|
||||
missing_values_future,
|
||||
)
|
||||
|
||||
def read_async_num_stages(self, ids_device: torch.device):
|
||||
"""The number of stages of the read_async operation. See read_async
|
||||
function for directions on its use. This function is required to return
|
||||
the number of yield operations when read_async is used with a tensor
|
||||
residing on ids_device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids_device : torch.device
|
||||
The device of the ids parameter passed into read_async.
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of stages of the read_async operation.
|
||||
"""
|
||||
assert ids_device.type == "cuda"
|
||||
return 1 + self._fallback_feature.read_async_num_stages(ids_device)
|
||||
|
||||
def size(self):
|
||||
"""Get the size of the feature.
|
||||
|
||||
Returns
|
||||
-------
|
||||
torch.Size
|
||||
The size of the feature.
|
||||
"""
|
||||
return self._fallback_feature.size()
|
||||
|
||||
def count(self):
|
||||
"""Get the count of the feature.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The count of the feature.
|
||||
"""
|
||||
return self._fallback_feature.count()
|
||||
|
||||
def update(self, value: torch.Tensor, ids: torch.Tensor = None):
|
||||
"""Update the feature.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value : torch.Tensor
|
||||
The updated value of the feature.
|
||||
ids : torch.Tensor, optional
|
||||
The indices of the feature to update. If specified, only the
|
||||
specified indices of the feature will be updated. For the feature,
|
||||
the `ids[i]` row is updated to `value[i]`. So the indices and value
|
||||
must have the same length. If None, the entire feature will be
|
||||
updated.
|
||||
"""
|
||||
if ids is None:
|
||||
feat0 = value[:1]
|
||||
self._fallback_feature.update(value)
|
||||
cache_size = min(
|
||||
bytes_to_number_of_items(self.cache_size_in_bytes, feat0),
|
||||
value.shape[0],
|
||||
)
|
||||
self._feature = None # Destroy the existing cache first.
|
||||
self._feature = self._cache_type(
|
||||
(cache_size,) + feat0.shape[1:], feat0.dtype
|
||||
)
|
||||
else:
|
||||
self._fallback_feature.update(value, ids)
|
||||
self._feature.replace(ids, value)
|
||||
|
||||
@property
|
||||
def cache_size_in_bytes(self):
|
||||
"""Return the size taken by the cache in bytes."""
|
||||
return self._feature.max_size_in_bytes
|
||||
|
||||
@property
|
||||
def miss_rate(self):
|
||||
"""Returns the cache miss rate since creation."""
|
||||
return self._feature.miss_rate
|
||||
|
||||
|
||||
def gpu_cached_feature(
|
||||
fallback_features: Union[Feature, Dict[FeatureKey, Feature]],
|
||||
max_cache_size_in_bytes: int,
|
||||
) -> Union[GPUCachedFeature, Dict[FeatureKey, GPUCachedFeature]]:
|
||||
r"""GPU cached feature wrapping a fallback feature. It uses the least
|
||||
recently used (LRU) algorithm as the cache eviction policy.
|
||||
|
||||
Places the GPU cache to torch.cuda.current_device().
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fallback_features : Union[Feature, Dict[FeatureKey, Feature]]
|
||||
The fallback feature(s).
|
||||
max_cache_size_in_bytes : int
|
||||
The capacity of the GPU cache in bytes.
|
||||
Returns
|
||||
-------
|
||||
Union[GPUCachedFeature, Dict[FeatureKey, GPUCachedFeature]]
|
||||
The feature(s) wrapped with GPUCachedFeature.
|
||||
"""
|
||||
return wrap_with_cached_feature(
|
||||
GPUCachedFeature, fallback_features, max_cache_size_in_bytes
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HugeCTR gpu_cache wrapper for graphbolt."""
|
||||
from functools import reduce
|
||||
from operator import mul
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
class GPUFeatureCache(object):
|
||||
"""High-level wrapper for GPU embedding cache"""
|
||||
|
||||
def __init__(self, cache_shape, dtype):
|
||||
major, _ = torch.cuda.get_device_capability()
|
||||
assert (
|
||||
major >= 7
|
||||
), "GPUFeatureCache is supported only on CUDA compute capability >= 70 (Volta)."
|
||||
self._cache = torch.ops.graphbolt.gpu_cache(cache_shape, dtype)
|
||||
element_size = torch.tensor([], dtype=dtype).element_size()
|
||||
self.max_size_in_bytes = reduce(mul, cache_shape) * element_size
|
||||
self.total_miss = 0
|
||||
self.total_queries = 0
|
||||
|
||||
def query(self, keys, async_op=False):
|
||||
"""Queries the GPU cache.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
keys : Tensor
|
||||
The keys to query the GPU cache with.
|
||||
async_op: bool
|
||||
Boolean indicating whether the call is asynchronous. If so, the
|
||||
result can be obtained by calling wait on the returned future.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple(Tensor, Tensor, Tensor)
|
||||
A tuple containing (values, missing_indices, missing_keys) where
|
||||
values[missing_indices] corresponds to cache misses that should be
|
||||
filled by quering another source with missing_keys.
|
||||
"""
|
||||
|
||||
class _Waiter:
|
||||
def __init__(self, gpu_cache, future):
|
||||
self.gpu_cache = gpu_cache
|
||||
self.future = future
|
||||
|
||||
def wait(self):
|
||||
"""Returns the stored value when invoked."""
|
||||
gpu_cache = self.gpu_cache
|
||||
values, missing_index, missing_keys = (
|
||||
self.future.wait() if async_op else self.future
|
||||
)
|
||||
# Ensure there is no leak.
|
||||
self.gpu_cache = self.future = None
|
||||
|
||||
gpu_cache.total_queries += values.shape[0]
|
||||
gpu_cache.total_miss += missing_keys.shape[0]
|
||||
return values, missing_index, missing_keys
|
||||
|
||||
if async_op:
|
||||
return _Waiter(self, self._cache.query_async(keys))
|
||||
else:
|
||||
return _Waiter(self, self._cache.query(keys)).wait()
|
||||
|
||||
def replace(self, keys, values):
|
||||
"""Inserts key-value pairs into the GPU cache using the Least-Recently
|
||||
Used (LRU) algorithm to remove old key-value pairs if it is full.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
keys: Tensor
|
||||
The keys to insert to the GPU cache.
|
||||
values: Tensor
|
||||
The values to insert to the GPU cache.
|
||||
"""
|
||||
self._cache.replace(keys, values)
|
||||
|
||||
@property
|
||||
def miss_rate(self):
|
||||
"""Returns the cache miss rate since creation."""
|
||||
return self.total_miss / self.total_queries
|
||||
@@ -0,0 +1,118 @@
|
||||
"""HugeCTR gpu_cache wrapper for graphbolt."""
|
||||
import torch
|
||||
|
||||
|
||||
class GPUGraphCache(object):
|
||||
r"""High-level wrapper for GPU graph cache.
|
||||
|
||||
Places the GPU graph cache to torch.cuda.current_device().
|
||||
|
||||
Parameters
|
||||
----------
|
||||
num_edges : int
|
||||
Upperbound on number of edges to cache.
|
||||
threshold : int
|
||||
The number of accesses before the neighborhood of a vertex is cached.
|
||||
indptr_dtype : torch.dtype
|
||||
The dtype of the indptr tensor of the graph.
|
||||
dtypes : list[torch.dtype]
|
||||
The dtypes of the edge tensors that are going to be cached.
|
||||
has_original_edge_ids : bool
|
||||
Whether the graph to be cached has original edge ids.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, num_edges, threshold, indptr_dtype, dtypes, has_original_edge_ids
|
||||
):
|
||||
major, _ = torch.cuda.get_device_capability()
|
||||
assert (
|
||||
major >= 7
|
||||
), "GPUGraphCache is supported only on CUDA compute capability >= 70 (Volta)."
|
||||
self._cache = torch.ops.graphbolt.gpu_graph_cache(
|
||||
num_edges, threshold, indptr_dtype, dtypes, has_original_edge_ids
|
||||
)
|
||||
self.total_miss = 0
|
||||
self.total_queries = 0
|
||||
|
||||
def query(self, keys):
|
||||
"""Queries the GPU cache.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
keys : Tensor
|
||||
The keys to query the GPU graph cache with.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple(Tensor, func)
|
||||
A tuple containing (missing_keys, replace_fn) where replace_fn is a
|
||||
function that should be called with the graph structure
|
||||
corresponding to the missing keys. Its arguments are
|
||||
(Tensor, list(Tensor)), where the first tensor is the missing indptr
|
||||
and the second list is the missing edge tensors.
|
||||
"""
|
||||
self.total_queries += keys.shape[0]
|
||||
(
|
||||
index,
|
||||
position,
|
||||
num_hit,
|
||||
num_threshold,
|
||||
) = self._cache.query(keys)
|
||||
self.total_miss += keys.shape[0] - num_hit
|
||||
|
||||
def replace_functional(missing_indptr, missing_edge_tensors):
|
||||
return self._cache.replace(
|
||||
keys,
|
||||
index,
|
||||
position,
|
||||
num_hit,
|
||||
num_threshold,
|
||||
missing_indptr,
|
||||
missing_edge_tensors,
|
||||
)
|
||||
|
||||
return keys[index[num_hit:]], replace_functional
|
||||
|
||||
def query_async(self, keys):
|
||||
"""Queries the GPU cache asynchronously.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
keys : Tensor
|
||||
The keys to query the GPU graph cache with.
|
||||
|
||||
Returns
|
||||
-------
|
||||
A generator object.
|
||||
The returned generator object returns the missing keys on the second
|
||||
invocation and expects the fetched indptr and edge tensors on the
|
||||
next invocation. The third and last invocation returns a future
|
||||
object and the return result can be accessed by calling `.wait()`
|
||||
on the returned future object. It is undefined behavior to call
|
||||
`.wait()` more than once.
|
||||
"""
|
||||
future = self._cache.query_async(keys)
|
||||
|
||||
yield
|
||||
|
||||
index, position, num_hit, num_threshold = future.wait()
|
||||
|
||||
self.total_queries += keys.shape[0]
|
||||
self.total_miss += keys.shape[0] - num_hit
|
||||
|
||||
missing_indptr, missing_edge_tensors = yield keys[index[num_hit:]]
|
||||
|
||||
yield self._cache.replace_async(
|
||||
keys,
|
||||
index,
|
||||
position,
|
||||
num_hit,
|
||||
num_threshold,
|
||||
missing_indptr,
|
||||
missing_edge_tensors,
|
||||
)
|
||||
|
||||
@property
|
||||
def miss_rate(self):
|
||||
"""Returns the cache miss rate since creation."""
|
||||
return self.total_miss / self.total_queries
|
||||
@@ -0,0 +1,86 @@
|
||||
"""In-subgraph sampler for GraphBolt."""
|
||||
|
||||
from torch.utils.data import functional_datapipe
|
||||
|
||||
from ..internal import unique_and_compact_csc_formats
|
||||
|
||||
from ..subgraph_sampler import SubgraphSampler
|
||||
from .sampled_subgraph_impl import SampledSubgraphImpl
|
||||
|
||||
|
||||
__all__ = ["InSubgraphSampler"]
|
||||
|
||||
|
||||
@functional_datapipe("sample_in_subgraph")
|
||||
class InSubgraphSampler(SubgraphSampler):
|
||||
"""Sample the subgraph induced on the inbound edges of the given nodes.
|
||||
|
||||
Functional name: :obj:`sample_in_subgraph`.
|
||||
|
||||
In-subgraph sampler is responsible for sampling a subgraph from given data,
|
||||
returning an induced subgraph along with compacted information.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
datapipe : DataPipe
|
||||
The datapipe.
|
||||
graph : FusedCSCSamplingGraph
|
||||
The graph on which to perform in_subgraph sampling.
|
||||
|
||||
Examples
|
||||
-------
|
||||
>>> import dgl.graphbolt as gb
|
||||
>>> import torch
|
||||
>>> indptr = torch.LongTensor([0, 3, 5, 7, 9, 12, 14])
|
||||
>>> indices = torch.LongTensor([0, 1, 4, 2, 3, 0, 5, 1, 2, 0, 3, 5, 1, 4])
|
||||
>>> graph = gb.fused_csc_sampling_graph(indptr, indices)
|
||||
>>> item_set = gb.ItemSet(len(indptr) - 1, names="seeds")
|
||||
>>> item_sampler = gb.ItemSampler(item_set, batch_size=2)
|
||||
>>> insubgraph_sampler = gb.InSubgraphSampler(item_sampler, graph)
|
||||
>>> for _, data in enumerate(insubgraph_sampler):
|
||||
... print(data.sampled_subgraphs[0].sampled_csc)
|
||||
... print(data.sampled_subgraphs[0].original_row_node_ids)
|
||||
... print(data.sampled_subgraphs[0].original_column_node_ids)
|
||||
CSCFormatBase(indptr=tensor([0, 3, 5]),
|
||||
indices=tensor([0, 1, 2, 3, 4]),
|
||||
)
|
||||
tensor([0, 1, 4, 2, 3])
|
||||
tensor([0, 1])
|
||||
CSCFormatBase(indptr=tensor([0, 2, 4]),
|
||||
indices=tensor([2, 3, 4, 0]),
|
||||
)
|
||||
tensor([2, 3, 0, 5, 1])
|
||||
tensor([2, 3])
|
||||
CSCFormatBase(indptr=tensor([0, 3, 5]),
|
||||
indices=tensor([2, 3, 1, 4, 0]),
|
||||
)
|
||||
tensor([4, 5, 0, 3, 1])
|
||||
tensor([4, 5])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datapipe,
|
||||
graph,
|
||||
):
|
||||
super().__init__(datapipe)
|
||||
self.graph = graph
|
||||
self.sampler = graph.in_subgraph
|
||||
|
||||
def sample_subgraphs(
|
||||
self, seeds, seeds_timestamp, seeds_pre_time_window=None
|
||||
):
|
||||
subgraph = self.sampler(seeds)
|
||||
(
|
||||
original_row_node_ids,
|
||||
compacted_csc_formats,
|
||||
_,
|
||||
) = unique_and_compact_csc_formats(subgraph.sampled_csc, seeds)
|
||||
subgraph = SampledSubgraphImpl(
|
||||
sampled_csc=compacted_csc_formats,
|
||||
original_column_node_ids=seeds,
|
||||
original_row_node_ids=original_row_node_ids,
|
||||
original_edge_ids=subgraph.original_edge_ids,
|
||||
)
|
||||
seeds = original_row_node_ids
|
||||
return (seeds, [subgraph])
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Graphbolt dataset for legacy DGLDataset."""
|
||||
|
||||
from typing import List, Union
|
||||
|
||||
from ..base import etype_tuple_to_str
|
||||
from ..dataset import Dataset, Task
|
||||
from ..itemset import HeteroItemSet, ItemSet
|
||||
from ..sampling_graph import SamplingGraph
|
||||
from .basic_feature_store import BasicFeatureStore
|
||||
from .fused_csc_sampling_graph import from_dglgraph
|
||||
from .ondisk_dataset import OnDiskTask
|
||||
from .torch_based_feature_store import TorchBasedFeature
|
||||
|
||||
|
||||
class LegacyDataset(Dataset):
|
||||
"""A Graphbolt dataset for legacy DGLDataset."""
|
||||
|
||||
def __init__(self, legacy):
|
||||
# Only supports single graph cases.
|
||||
assert len(legacy) == 1
|
||||
graph = legacy[0]
|
||||
# Handle OGB Dataset.
|
||||
if isinstance(graph, tuple):
|
||||
graph, _ = graph
|
||||
if graph.is_homogeneous:
|
||||
self._init_as_homogeneous_node_pred(legacy)
|
||||
else:
|
||||
self._init_as_heterogeneous_node_pred(legacy)
|
||||
|
||||
def _init_as_heterogeneous_node_pred(self, legacy):
|
||||
def _init_item_set_dict(idx, labels):
|
||||
item_set_dict = {}
|
||||
for key in idx.keys():
|
||||
item_set = ItemSet(
|
||||
(idx[key], labels[key][idx[key]]),
|
||||
names=("seeds", "labels"),
|
||||
)
|
||||
item_set_dict[key] = item_set
|
||||
return HeteroItemSet(item_set_dict)
|
||||
|
||||
# OGB Dataset has the idx split.
|
||||
if hasattr(legacy, "get_idx_split"):
|
||||
graph, labels = legacy[0]
|
||||
split_idx = legacy.get_idx_split()
|
||||
|
||||
# Initialize tasks.
|
||||
tasks = []
|
||||
metadata = {
|
||||
"num_classes": legacy.num_classes,
|
||||
"name": "node_classification",
|
||||
}
|
||||
train_set = _init_item_set_dict(split_idx["train"], labels)
|
||||
validation_set = _init_item_set_dict(split_idx["valid"], labels)
|
||||
test_set = _init_item_set_dict(split_idx["test"], labels)
|
||||
task = OnDiskTask(metadata, train_set, validation_set, test_set)
|
||||
tasks.append(task)
|
||||
self._tasks = tasks
|
||||
|
||||
item_set_dict = {}
|
||||
for ntype in graph.ntypes:
|
||||
item_set = ItemSet(graph.num_nodes(ntype), names="seeds")
|
||||
item_set_dict[ntype] = item_set
|
||||
self._all_nodes_set = HeteroItemSet(item_set_dict)
|
||||
|
||||
features = {}
|
||||
for ntype in graph.ntypes:
|
||||
for name in graph.nodes[ntype].data.keys():
|
||||
tensor = graph.nodes[ntype].data[name]
|
||||
if tensor.dim() == 1:
|
||||
tensor = tensor.view(-1, 1)
|
||||
features[("node", ntype, name)] = TorchBasedFeature(tensor)
|
||||
for etype in graph.canonical_etypes:
|
||||
for name in graph.edges[etype].data.keys():
|
||||
tensor = graph.edges[etype].data[name]
|
||||
if tensor.dim() == 1:
|
||||
tensor = tensor.view(-1, 1)
|
||||
gb_etype = etype_tuple_to_str(etype)
|
||||
features[("edge", gb_etype, name)] = TorchBasedFeature(
|
||||
tensor
|
||||
)
|
||||
self._feature = BasicFeatureStore(features)
|
||||
self._graph = from_dglgraph(graph, is_homogeneous=False)
|
||||
self._dataset_name = legacy.name
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Only support heterogeneous ogn node pred dataset"
|
||||
)
|
||||
|
||||
def _init_as_homogeneous_node_pred(self, legacy):
|
||||
from dgl.data import AsNodePredDataset
|
||||
|
||||
legacy = AsNodePredDataset(legacy)
|
||||
|
||||
# Initialize tasks.
|
||||
tasks = []
|
||||
metadata = {
|
||||
"num_classes": legacy.num_classes,
|
||||
"name": "node_classification",
|
||||
}
|
||||
train_labels = legacy[0].ndata["label"][legacy.train_idx]
|
||||
validation_labels = legacy[0].ndata["label"][legacy.val_idx]
|
||||
test_labels = legacy[0].ndata["label"][legacy.test_idx]
|
||||
train_set = ItemSet(
|
||||
(legacy.train_idx, train_labels),
|
||||
names=("seeds", "labels"),
|
||||
)
|
||||
validation_set = ItemSet(
|
||||
(legacy.val_idx, validation_labels),
|
||||
names=("seeds", "labels"),
|
||||
)
|
||||
test_set = ItemSet(
|
||||
(legacy.test_idx, test_labels), names=("seeds", "labels")
|
||||
)
|
||||
task = OnDiskTask(metadata, train_set, validation_set, test_set)
|
||||
tasks.append(task)
|
||||
self._tasks = tasks
|
||||
|
||||
num_nodes = legacy[0].num_nodes()
|
||||
self._all_nodes_set = ItemSet(num_nodes, names="seeds")
|
||||
features = {}
|
||||
for name in legacy[0].ndata.keys():
|
||||
tensor = legacy[0].ndata[name]
|
||||
if tensor.dim() == 1:
|
||||
tensor = tensor.view(-1, 1)
|
||||
features[("node", None, name)] = TorchBasedFeature(tensor)
|
||||
for name in legacy[0].edata.keys():
|
||||
tensor = legacy[0].edata[name]
|
||||
if tensor.dim() == 1:
|
||||
tensor = tensor.view(-1, 1)
|
||||
features[("edge", None, name)] = TorchBasedFeature(tensor)
|
||||
self._feature = BasicFeatureStore(features)
|
||||
self._graph = from_dglgraph(legacy[0], is_homogeneous=True)
|
||||
self._dataset_name = legacy.name
|
||||
|
||||
@property
|
||||
def tasks(self) -> List[Task]:
|
||||
"""Return the tasks."""
|
||||
return self._tasks
|
||||
|
||||
@property
|
||||
def graph(self) -> SamplingGraph:
|
||||
"""Return the graph."""
|
||||
return self._graph
|
||||
|
||||
@property
|
||||
def feature(self) -> BasicFeatureStore:
|
||||
"""Return the feature."""
|
||||
return self._feature
|
||||
|
||||
@property
|
||||
def dataset_name(self) -> str:
|
||||
"""Return the dataset name."""
|
||||
return self._dataset_name
|
||||
|
||||
@property
|
||||
def all_nodes_set(self) -> Union[ItemSet, HeteroItemSet]:
|
||||
"""Return the itemset containing all nodes."""
|
||||
return self._all_nodes_set
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
"""Ondisk metadata of GraphBolt."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pydantic
|
||||
|
||||
from ..internal_utils import version
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OnDiskFeatureDataFormat",
|
||||
"OnDiskTVTSetData",
|
||||
"OnDiskTVTSet",
|
||||
"OnDiskFeatureDataDomain",
|
||||
"OnDiskFeatureData",
|
||||
"OnDiskMetaData",
|
||||
"OnDiskGraphTopologyType",
|
||||
"OnDiskGraphTopology",
|
||||
"OnDiskTaskData",
|
||||
]
|
||||
|
||||
|
||||
class ExtraMetaData(pydantic.BaseModel, extra="allow"):
|
||||
"""Group extra fields into metadata. Internal use only."""
|
||||
|
||||
extra_fields: Optional[Dict[str, Any]] = {}
|
||||
|
||||
# As pydantic 2.0 has changed the API of validators, we need to use
|
||||
# different validators for different versions to be compatible with
|
||||
# previous versions.
|
||||
if version.parse(pydantic.__version__) >= version.parse("2.0"):
|
||||
|
||||
@pydantic.model_validator(mode="before")
|
||||
@classmethod
|
||||
def build_extra(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build extra fields."""
|
||||
for key in list(values.keys()):
|
||||
if key not in cls.model_fields:
|
||||
values["extra_fields"] = values.get("extra_fields", {})
|
||||
values["extra_fields"][key] = values.pop(key)
|
||||
return values
|
||||
|
||||
else:
|
||||
|
||||
@pydantic.root_validator(pre=True)
|
||||
@classmethod
|
||||
def build_extra(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build extra fields."""
|
||||
for key in list(values.keys()):
|
||||
if key not in cls.__fields__:
|
||||
values["extra_fields"] = values.get("extra_fields", {})
|
||||
values["extra_fields"][key] = values.pop(key)
|
||||
return values
|
||||
|
||||
|
||||
class OnDiskFeatureDataFormat(str, Enum):
|
||||
"""Enum of data format."""
|
||||
|
||||
TORCH = "torch"
|
||||
NUMPY = "numpy"
|
||||
|
||||
|
||||
class OnDiskTVTSetData(pydantic.BaseModel):
|
||||
"""Train-Validation-Test set data."""
|
||||
|
||||
name: Optional[str] = None
|
||||
format: OnDiskFeatureDataFormat
|
||||
in_memory: Optional[bool] = True
|
||||
path: str
|
||||
|
||||
|
||||
class OnDiskTVTSet(pydantic.BaseModel):
|
||||
"""Train-Validation-Test set."""
|
||||
|
||||
type: Optional[str] = None
|
||||
data: List[OnDiskTVTSetData]
|
||||
|
||||
|
||||
class OnDiskFeatureDataDomain(str, Enum):
|
||||
"""Enum of feature data domain."""
|
||||
|
||||
NODE = "node"
|
||||
EDGE = "edge"
|
||||
GRAPH = "graph"
|
||||
|
||||
|
||||
class OnDiskFeatureData(ExtraMetaData):
|
||||
r"""The description of an on-disk feature."""
|
||||
domain: OnDiskFeatureDataDomain
|
||||
type: Optional[str] = None
|
||||
name: str
|
||||
format: OnDiskFeatureDataFormat
|
||||
path: str
|
||||
in_memory: Optional[bool] = True
|
||||
|
||||
|
||||
class OnDiskGraphTopologyType(str, Enum):
|
||||
"""Enum of graph topology type."""
|
||||
|
||||
FUSED_CSC_SAMPLING = "FusedCSCSamplingGraph"
|
||||
|
||||
|
||||
class OnDiskGraphTopology(pydantic.BaseModel):
|
||||
"""The description of an on-disk graph topology."""
|
||||
|
||||
type: OnDiskGraphTopologyType
|
||||
path: str
|
||||
|
||||
|
||||
class OnDiskTaskData(ExtraMetaData):
|
||||
"""Task specification in YAML."""
|
||||
|
||||
train_set: Optional[List[OnDiskTVTSet]] = []
|
||||
validation_set: Optional[List[OnDiskTVTSet]] = []
|
||||
test_set: Optional[List[OnDiskTVTSet]] = []
|
||||
|
||||
|
||||
class OnDiskMetaData(pydantic.BaseModel):
|
||||
"""Metadata specification in YAML.
|
||||
|
||||
As multiple node/edge types and multiple splits are supported, each TVT set
|
||||
is a list of list of ``OnDiskTVTSet``.
|
||||
"""
|
||||
|
||||
dataset_name: Optional[str] = None
|
||||
graph_topology: Optional[OnDiskGraphTopology] = None
|
||||
feature_data: Optional[List[OnDiskFeatureData]] = []
|
||||
tasks: Optional[List[OnDiskTaskData]] = []
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Sampled subgraph for FusedCSCSamplingGraph."""
|
||||
# pylint: disable= invalid-name
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Union
|
||||
|
||||
import torch
|
||||
|
||||
from ..base import CSCFormatBase, etype_str_to_tuple
|
||||
from ..internal_utils import get_attributes
|
||||
from ..sampled_subgraph import SampledSubgraph
|
||||
|
||||
__all__ = ["SampledSubgraphImpl"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SampledSubgraphImpl(SampledSubgraph):
|
||||
r"""Sampled subgraph of CSCSamplingGraph.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> sampled_csc = {"A:relation:B": CSCFormatBase(indptr=torch.tensor([0, 1, 2, 3]),
|
||||
... indices=torch.tensor([0, 1, 2]))}
|
||||
>>> original_column_node_ids = {'B': torch.tensor([10, 11, 12])}
|
||||
>>> original_row_node_ids = {'A': torch.tensor([13, 14, 15])}
|
||||
>>> original_edge_ids = {"A:relation:B": torch.tensor([19, 20, 21])}
|
||||
>>> subgraph = gb.SampledSubgraphImpl(
|
||||
... sampled_csc=sampled_csc,
|
||||
... original_column_node_ids=original_column_node_ids,
|
||||
... original_row_node_ids=original_row_node_ids,
|
||||
... original_edge_ids=original_edge_ids
|
||||
... )
|
||||
>>> print(subgraph.sampled_csc)
|
||||
{"A:relation:B": CSCForamtBase(indptr=torch.tensor([0, 1, 2, 3]),
|
||||
... indices=torch.tensor([0, 1, 2]))}
|
||||
>>> print(subgraph.original_column_node_ids)
|
||||
{'B': tensor([10, 11, 12])}
|
||||
>>> print(subgraph.original_row_node_ids)
|
||||
{'A': tensor([13, 14, 15])}
|
||||
>>> print(subgraph.original_edge_ids)
|
||||
{"A:relation:B": tensor([19, 20, 21])}
|
||||
"""
|
||||
sampled_csc: Union[CSCFormatBase, Dict[str, CSCFormatBase]] = None
|
||||
original_column_node_ids: Union[
|
||||
Dict[str, torch.Tensor], torch.Tensor
|
||||
] = None
|
||||
original_row_node_ids: Union[Dict[str, torch.Tensor], torch.Tensor] = None
|
||||
original_edge_ids: Union[Dict[str, torch.Tensor], torch.Tensor] = None
|
||||
# Used to fetch sampled_csc.indices if it is missing.
|
||||
_edge_ids_in_fused_csc_sampling_graph: Union[
|
||||
Dict[str, torch.Tensor], torch.Tensor
|
||||
] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if isinstance(self.sampled_csc, dict):
|
||||
for etype, pair in self.sampled_csc.items():
|
||||
assert (
|
||||
isinstance(etype, str)
|
||||
and len(etype_str_to_tuple(etype)) == 3
|
||||
), "Edge type should be a string in format of str:str:str."
|
||||
assert pair.indptr is not None and isinstance(
|
||||
pair.indptr, torch.Tensor
|
||||
), "Node pair should be have indptr of type torch.Tensor."
|
||||
# For CUDA, indices may be None because it will be fetched later.
|
||||
if not pair.indptr.is_cuda or pair.indices is not None:
|
||||
assert isinstance(
|
||||
pair.indices, torch.Tensor
|
||||
), "Node pair should be have indices of type torch.Tensor."
|
||||
else:
|
||||
assert isinstance(
|
||||
self._edge_ids_in_fused_csc_sampling_graph.get(
|
||||
etype, None
|
||||
),
|
||||
torch.Tensor,
|
||||
), "When indices is missing, sampled edge ids needs to be provided."
|
||||
else:
|
||||
assert self.sampled_csc.indptr is not None and isinstance(
|
||||
self.sampled_csc.indptr, torch.Tensor
|
||||
), "Node pair should be have torch.Tensor indptr."
|
||||
# For CUDA, indices may be None because it will be fetched later.
|
||||
if (
|
||||
not self.sampled_csc.indptr.is_cuda
|
||||
or self.sampled_csc.indices is not None
|
||||
):
|
||||
assert isinstance(
|
||||
self.sampled_csc.indices, torch.Tensor
|
||||
), "Node pair should have a torch.Tensor indices."
|
||||
else:
|
||||
assert isinstance(
|
||||
self._edge_ids_in_fused_csc_sampling_graph, torch.Tensor
|
||||
), "When indices is missing, sampled edge ids needs to be provided."
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return _sampled_subgraph_str(self, "SampledSubgraphImpl")
|
||||
|
||||
|
||||
def _sampled_subgraph_str(sampled_subgraph: SampledSubgraph, classname) -> str:
|
||||
final_str = classname + "("
|
||||
|
||||
attributes = get_attributes(sampled_subgraph)
|
||||
attributes.reverse()
|
||||
|
||||
for name in attributes:
|
||||
if name in "_edge_ids_in_fused_csc_sampling_graph":
|
||||
continue
|
||||
val = getattr(sampled_subgraph, name)
|
||||
|
||||
def _add_indent(_str, indent):
|
||||
lines = _str.split("\n")
|
||||
lines = [lines[0]] + [" " * indent + line for line in lines[1:]]
|
||||
return "\n".join(lines)
|
||||
|
||||
val = str(val)
|
||||
final_str = (
|
||||
final_str
|
||||
+ f"{name}={_add_indent(val, len(name) + len(classname) + 1)},\n"
|
||||
+ " " * len(classname)
|
||||
)
|
||||
return final_str[: -len(classname)] + ")"
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Temporal neighbor subgraph samplers for GraphBolt."""
|
||||
import torch
|
||||
from torch.utils.data import functional_datapipe
|
||||
|
||||
from ..internal import compact_csc_format
|
||||
|
||||
from ..subgraph_sampler import SubgraphSampler
|
||||
from .sampled_subgraph_impl import SampledSubgraphImpl
|
||||
|
||||
|
||||
__all__ = ["TemporalNeighborSampler", "TemporalLayerNeighborSampler"]
|
||||
|
||||
|
||||
class TemporalNeighborSamplerImpl(SubgraphSampler):
|
||||
"""Base class for TemporalNeighborSamplers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datapipe,
|
||||
graph,
|
||||
fanouts,
|
||||
replace,
|
||||
prob_name,
|
||||
node_timestamp_attr_name,
|
||||
edge_timestamp_attr_name,
|
||||
sampler,
|
||||
):
|
||||
super().__init__(datapipe)
|
||||
self.graph = graph
|
||||
# Convert fanouts to a list of tensors.
|
||||
self.fanouts = []
|
||||
for fanout in fanouts:
|
||||
if not isinstance(fanout, torch.Tensor):
|
||||
fanout = torch.LongTensor([int(fanout)])
|
||||
self.fanouts.insert(0, fanout)
|
||||
self.replace = replace
|
||||
self.prob_name = prob_name
|
||||
self.node_timestamp_attr_name = node_timestamp_attr_name
|
||||
self.edge_timestamp_attr_name = edge_timestamp_attr_name
|
||||
self.sampler = sampler
|
||||
|
||||
def sample_subgraphs(
|
||||
self, seeds, seeds_timestamp, seeds_pre_time_window=None
|
||||
):
|
||||
assert (
|
||||
seeds_timestamp is not None
|
||||
), "seeds_timestamp must be provided for temporal neighbor sampling."
|
||||
subgraphs = []
|
||||
num_layers = len(self.fanouts)
|
||||
# Enrich seeds with all node types. Ensure that the dtype and device
|
||||
# remain consistent with those of the existing seeds.
|
||||
if isinstance(seeds, dict):
|
||||
first_val = next(iter(seeds.items()))[1]
|
||||
ntypes = list(self.graph.node_type_to_id.keys())
|
||||
seeds = {
|
||||
ntype: seeds.get(
|
||||
ntype,
|
||||
torch.tensor(
|
||||
[], dtype=first_val.dtype, device=first_val.device
|
||||
),
|
||||
)
|
||||
for ntype in ntypes
|
||||
}
|
||||
empty_tensor = torch.tensor(
|
||||
[], dtype=torch.int64, device=first_val.device
|
||||
)
|
||||
seeds_timestamp = {
|
||||
ntype: seeds_timestamp.get(ntype, empty_tensor)
|
||||
for ntype in ntypes
|
||||
}
|
||||
if seeds_pre_time_window:
|
||||
seeds_pre_time_window = {
|
||||
ntype: seeds_pre_time_window.get(ntype, empty_tensor)
|
||||
for ntype in ntypes
|
||||
}
|
||||
for hop in range(num_layers):
|
||||
subgraph = self.sampler(
|
||||
seeds,
|
||||
seeds_timestamp,
|
||||
self.fanouts[hop],
|
||||
self.replace,
|
||||
seeds_pre_time_window,
|
||||
self.prob_name,
|
||||
self.node_timestamp_attr_name,
|
||||
self.edge_timestamp_attr_name,
|
||||
)
|
||||
(
|
||||
original_row_node_ids,
|
||||
compacted_csc_formats,
|
||||
row_timestamps,
|
||||
) = compact_csc_format(subgraph.sampled_csc, seeds, seeds_timestamp)
|
||||
|
||||
subgraph = SampledSubgraphImpl(
|
||||
sampled_csc=compacted_csc_formats,
|
||||
original_column_node_ids=seeds,
|
||||
original_row_node_ids=original_row_node_ids,
|
||||
original_edge_ids=subgraph.original_edge_ids,
|
||||
)
|
||||
|
||||
subgraphs.insert(0, subgraph)
|
||||
seeds = original_row_node_ids
|
||||
seeds_timestamp = row_timestamps
|
||||
return seeds, subgraphs
|
||||
|
||||
|
||||
@functional_datapipe("temporal_sample_neighbor")
|
||||
class TemporalNeighborSampler(TemporalNeighborSamplerImpl):
|
||||
"""Temporally sample neighbor edges from a graph and return sampled
|
||||
subgraphs.
|
||||
|
||||
Functional name: :obj:`temporal_sample_neighbor`.
|
||||
|
||||
Neighbor sampler is responsible for sampling a subgraph from given data. It
|
||||
returns an induced subgraph along with compacted information. In the
|
||||
context of a node classification task, the neighbor sampler directly
|
||||
utilizes the nodes provided as seed nodes. However, in scenarios involving
|
||||
link prediction, the process needs another pre-peocess operation. That is,
|
||||
gathering unique nodes from the given node pairs, encompassing both
|
||||
positive and negative node pairs, and employs these nodes as the seed nodes
|
||||
for subsequent steps.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
datapipe : DataPipe
|
||||
The datapipe.
|
||||
graph : FusedCSCSamplingGraph
|
||||
The graph on which to perform subgraph sampling.
|
||||
fanouts: list[torch.Tensor] or list[int]
|
||||
The number of edges to be sampled for each node with or without
|
||||
considering edge types. The length of this parameter implicitly
|
||||
signifies the layer of sampling being conducted.
|
||||
Note: The fanout order is from the outermost layer to innermost layer.
|
||||
For example, the fanout '[15, 10, 5]' means that 15 to the outermost
|
||||
layer, 10 to the intermediate layer and 5 corresponds to the innermost
|
||||
layer.
|
||||
replace: bool
|
||||
Boolean indicating whether the sample is preformed with or
|
||||
without replacement. If True, a value can be selected multiple
|
||||
times. Otherwise, each value can be selected only once.
|
||||
prob_name: str, optional
|
||||
The name of an edge attribute used as the weights of sampling for
|
||||
each node. This attribute tensor should contain (unnormalized)
|
||||
probabilities corresponding to each neighboring edge of a node.
|
||||
It must be a 1D floating-point or boolean tensor, with the number
|
||||
of elements equalling the total number of edges.
|
||||
node_timestamp_attr_name: str, optional
|
||||
The name of an node attribute used as the timestamps of nodes.
|
||||
It must be a 1D integer tensor, with the number of elements
|
||||
equalling the total number of nodes.
|
||||
edge_timestamp_attr_name: str, optional
|
||||
The name of an edge attribute used as the timestamps of edges.
|
||||
It must be a 1D integer tensor, with the number of elements
|
||||
equalling the total number of edges.
|
||||
|
||||
Examples
|
||||
-------
|
||||
TODO(zhenkun) : Add an example after the API to pass timestamps is finalized.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datapipe,
|
||||
graph,
|
||||
fanouts,
|
||||
replace=False,
|
||||
prob_name=None,
|
||||
node_timestamp_attr_name=None,
|
||||
edge_timestamp_attr_name=None,
|
||||
):
|
||||
super().__init__(
|
||||
datapipe,
|
||||
graph,
|
||||
fanouts,
|
||||
replace,
|
||||
prob_name,
|
||||
node_timestamp_attr_name,
|
||||
edge_timestamp_attr_name,
|
||||
graph.temporal_sample_neighbors,
|
||||
)
|
||||
|
||||
|
||||
@functional_datapipe("temporal_sample_layer_neighbor")
|
||||
class TemporalLayerNeighborSampler(TemporalNeighborSamplerImpl):
|
||||
"""Temporally sample neighbor edges from a graph and return sampled
|
||||
subgraphs.
|
||||
|
||||
Functional name: :obj:`temporal_sample_layer_neighbor`.
|
||||
|
||||
Sampler that builds computational dependency of node representations via
|
||||
labor sampling for multilayer GNN from the NeurIPS 2023 paper
|
||||
`Layer-Neighbor Sampling -- Defusing Neighborhood Explosion in GNNs
|
||||
<https://proceedings.neurips.cc/paper_files/paper/2023/file/51f9036d5e7ae822da8f6d4adda1fb39-Paper-Conference.pdf>`__
|
||||
|
||||
Layer-Neighbor sampler is responsible for sampling a subgraph from given
|
||||
data. It returns an induced subgraph along with compacted information. In
|
||||
the context of a node classification task, the neighbor sampler directly
|
||||
utilizes the nodes provided as seed nodes. However, in scenarios involving
|
||||
link prediction, the process needs another pre-process operation. That is,
|
||||
gathering unique nodes from the given node pairs, encompassing both
|
||||
positive and negative node pairs, and employs these nodes as the seed nodes
|
||||
for subsequent steps. When the graph is hetero, sampled subgraphs in
|
||||
minibatch will contain every edge type even though it is empty after
|
||||
sampling.
|
||||
|
||||
Implements the approach described in Appendix A.3 of the paper. Similar to
|
||||
dgl.dataloading.LaborSampler but this uses sequential poisson sampling
|
||||
instead of poisson sampling to keep the count of sampled edges per vertex
|
||||
deterministic like NeighborSampler. Thus, it is a drop-in replacement for
|
||||
NeighborSampler. However, unlike NeighborSampler, it samples fewer vertices
|
||||
and edges for multilayer GNN scenario without harming convergence speed with
|
||||
respect to training iterations.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
datapipe : DataPipe
|
||||
The datapipe.
|
||||
graph : FusedCSCSamplingGraph
|
||||
The graph on which to perform subgraph sampling.
|
||||
fanouts: list[torch.Tensor] or list[int]
|
||||
The number of edges to be sampled for each node with or without
|
||||
considering edge types. The length of this parameter implicitly
|
||||
signifies the layer of sampling being conducted.
|
||||
Note: The fanout order is from the outermost layer to innermost layer.
|
||||
For example, the fanout '[15, 10, 5]' means that 15 to the outermost
|
||||
layer, 10 to the intermediate layer and 5 corresponds to the innermost
|
||||
layer.
|
||||
replace: bool
|
||||
Boolean indicating whether the sample is preformed with or
|
||||
without replacement. If True, a value can be selected multiple
|
||||
times. Otherwise, each value can be selected only once.
|
||||
prob_name: str, optional
|
||||
The name of an edge attribute used as the weights of sampling for
|
||||
each node. This attribute tensor should contain (unnormalized)
|
||||
probabilities corresponding to each neighboring edge of a node.
|
||||
It must be a 1D floating-point or boolean tensor, with the number
|
||||
of elements equalling the total number of edges.
|
||||
node_timestamp_attr_name: str, optional
|
||||
The name of an node attribute used as the timestamps of nodes.
|
||||
It must be a 1D integer tensor, with the number of elements
|
||||
equalling the total number of nodes.
|
||||
edge_timestamp_attr_name: str, optional
|
||||
The name of an edge attribute used as the timestamps of edges.
|
||||
It must be a 1D integer tensor, with the number of elements
|
||||
equalling the total number of edges.
|
||||
|
||||
Examples
|
||||
-------
|
||||
TODO(zhenkun) : Add an example after the API to pass timestamps is finalized.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datapipe,
|
||||
graph,
|
||||
fanouts,
|
||||
replace=False,
|
||||
prob_name=None,
|
||||
node_timestamp_attr_name=None,
|
||||
edge_timestamp_attr_name=None,
|
||||
):
|
||||
super().__init__(
|
||||
datapipe,
|
||||
graph,
|
||||
fanouts,
|
||||
replace,
|
||||
prob_name,
|
||||
node_timestamp_attr_name,
|
||||
edge_timestamp_attr_name,
|
||||
graph.temporal_sample_layer_neighbors,
|
||||
)
|
||||
@@ -0,0 +1,665 @@
|
||||
"""Torch-based feature store for GraphBolt."""
|
||||
|
||||
import copy
|
||||
import textwrap
|
||||
from typing import Dict, List
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ..base import (
|
||||
get_device_to_host_uva_stream,
|
||||
get_host_to_device_uva_stream,
|
||||
index_select,
|
||||
)
|
||||
from ..feature_store import Feature
|
||||
from ..internal_utils import gb_warning, is_wsl
|
||||
from .basic_feature_store import BasicFeatureStore
|
||||
from .ondisk_metadata import OnDiskFeatureData
|
||||
|
||||
__all__ = ["TorchBasedFeature", "DiskBasedFeature", "TorchBasedFeatureStore"]
|
||||
|
||||
|
||||
class _Waiter:
|
||||
def __init__(self, event, values):
|
||||
self.event = event
|
||||
self.values = values
|
||||
|
||||
def wait(self):
|
||||
"""Returns the stored value when invoked."""
|
||||
self.event.wait()
|
||||
values = self.values
|
||||
# Ensure there is no memory leak.
|
||||
self.event = self.values = None
|
||||
return values
|
||||
|
||||
|
||||
class TorchBasedFeature(Feature):
|
||||
r"""A wrapper of pytorch based feature.
|
||||
|
||||
Initialize a torch based feature store by a torch feature.
|
||||
Note that the feature can be either in memory or on disk.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
torch_feature : torch.Tensor
|
||||
The torch feature.
|
||||
Note that the dimension of the tensor should be greater than 1.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import torch
|
||||
>>> from dgl import graphbolt as gb
|
||||
|
||||
1. The feature is in memory.
|
||||
|
||||
>>> torch_feat = torch.arange(10).reshape(2, -1)
|
||||
>>> feature = gb.TorchBasedFeature(torch_feat)
|
||||
>>> feature.read()
|
||||
tensor([[0, 1, 2, 3, 4],
|
||||
[5, 6, 7, 8, 9]])
|
||||
>>> feature.read(torch.tensor([0]))
|
||||
tensor([[0, 1, 2, 3, 4]])
|
||||
>>> feature.update(torch.tensor([[1 for _ in range(5)]]),
|
||||
... torch.tensor([1]))
|
||||
>>> feature.read(torch.tensor([0, 1]))
|
||||
tensor([[0, 1, 2, 3, 4],
|
||||
[1, 1, 1, 1, 1]])
|
||||
>>> feature.size()
|
||||
torch.Size([5])
|
||||
|
||||
2. The feature is on disk. Note that you can use gb.numpy_save_aligned as a
|
||||
replacement for np.save to potentially get increased performance.
|
||||
|
||||
>>> import numpy as np
|
||||
>>> arr = np.array([[1, 2], [3, 4]])
|
||||
>>> np.save("/tmp/arr.npy", arr)
|
||||
>>> torch_feat = torch.from_numpy(np.load("/tmp/arr.npy", mmap_mode="r+"))
|
||||
>>> feature = gb.TorchBasedFeature(torch_feat)
|
||||
>>> feature.read()
|
||||
tensor([[1, 2],
|
||||
[3, 4]])
|
||||
>>> feature.read(torch.tensor([0]))
|
||||
tensor([[1, 2]])
|
||||
|
||||
3. Pinned CPU feature.
|
||||
|
||||
>>> torch_feat = torch.arange(10).reshape(2, -1).pin_memory()
|
||||
>>> feature = gb.TorchBasedFeature(torch_feat)
|
||||
>>> feature.read().device
|
||||
device(type='cuda', index=0)
|
||||
>>> feature.read(torch.tensor([0]).cuda()).device
|
||||
device(type='cuda', index=0)
|
||||
"""
|
||||
|
||||
def __init__(self, torch_feature: torch.Tensor, metadata: Dict = None):
|
||||
super().__init__()
|
||||
self._is_inplace_pinned = set()
|
||||
assert isinstance(torch_feature, torch.Tensor), (
|
||||
f"torch_feature in TorchBasedFeature must be torch.Tensor, "
|
||||
f"but got {type(torch_feature)}."
|
||||
)
|
||||
assert torch_feature.dim() > 1, (
|
||||
f"dimension of torch_feature in TorchBasedFeature must be greater "
|
||||
f"than 1, but got {torch_feature.dim()} dimension."
|
||||
)
|
||||
# Make sure the tensor is contiguous.
|
||||
self._tensor = torch_feature.contiguous()
|
||||
self._metadata = metadata
|
||||
|
||||
def __del__(self):
|
||||
# torch.Tensor.pin_memory() is not an inplace operation. To make it
|
||||
# truly in-place, we need to use cudaHostRegister. Then, we need to use
|
||||
# cudaHostUnregister to unpin the tensor in the destructor.
|
||||
# https://github.com/pytorch/pytorch/issues/32167#issuecomment-753551842
|
||||
for tensor in self._is_inplace_pinned:
|
||||
assert self._inplace_unpinner(tensor.data_ptr()) == 0
|
||||
|
||||
def read(self, ids: torch.Tensor = None):
|
||||
"""Read the feature by index.
|
||||
|
||||
If the feature is on pinned CPU memory and `ids` is on GPU or pinned CPU
|
||||
memory, it will be read by GPU and the returned tensor will be on GPU.
|
||||
Otherwise, the returned tensor will be on CPU.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids : torch.Tensor, optional
|
||||
The index of the feature. If specified, only the specified indices
|
||||
of the feature are read. If None, the entire feature is returned.
|
||||
|
||||
Returns
|
||||
-------
|
||||
torch.Tensor
|
||||
The read feature.
|
||||
"""
|
||||
if ids is None:
|
||||
if self._tensor.is_pinned():
|
||||
return self._tensor.cuda()
|
||||
return self._tensor
|
||||
return index_select(self._tensor, ids)
|
||||
|
||||
def read_async(self, ids: torch.Tensor):
|
||||
r"""Read the feature by index asynchronously.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids : torch.Tensor
|
||||
The index of the feature. Only the specified indices of the
|
||||
feature are read.
|
||||
Returns
|
||||
-------
|
||||
A generator object.
|
||||
The returned generator object returns a future on
|
||||
``read_async_num_stages(ids.device)``\ th invocation. The return result
|
||||
can be accessed by calling ``.wait()``. on the returned future object.
|
||||
It is undefined behavior to call ``.wait()`` more than once.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import dgl.graphbolt as gb
|
||||
>>> feature = gb.Feature(...)
|
||||
>>> ids = torch.tensor([0, 2])
|
||||
>>> for stage, future in enumerate(feature.read_async(ids)):
|
||||
... pass
|
||||
>>> assert stage + 1 == feature.read_async_num_stages(ids.device)
|
||||
>>> result = future.wait() # result contains the read values.
|
||||
"""
|
||||
assert self._tensor.device.type == "cpu"
|
||||
if ids.is_cuda and self.is_pinned():
|
||||
current_stream = torch.cuda.current_stream()
|
||||
host_to_device_stream = get_host_to_device_uva_stream()
|
||||
host_to_device_stream.wait_stream(current_stream)
|
||||
with torch.cuda.stream(host_to_device_stream):
|
||||
ids.record_stream(torch.cuda.current_stream())
|
||||
values = index_select(self._tensor, ids)
|
||||
values.record_stream(current_stream)
|
||||
values_copy_event = torch.cuda.Event()
|
||||
values_copy_event.record()
|
||||
|
||||
yield _Waiter(values_copy_event, values)
|
||||
elif ids.is_cuda:
|
||||
ids_device = ids.device
|
||||
current_stream = torch.cuda.current_stream()
|
||||
device_to_host_stream = get_device_to_host_uva_stream()
|
||||
device_to_host_stream.wait_stream(current_stream)
|
||||
with torch.cuda.stream(device_to_host_stream):
|
||||
ids.record_stream(torch.cuda.current_stream())
|
||||
ids = ids.to(self._tensor.device, non_blocking=True)
|
||||
ids_copy_event = torch.cuda.Event()
|
||||
ids_copy_event.record()
|
||||
|
||||
yield # first stage is done.
|
||||
|
||||
ids_copy_event.synchronize()
|
||||
values = torch.ops.graphbolt.index_select_async(self._tensor, ids)
|
||||
yield
|
||||
|
||||
host_to_device_stream = get_host_to_device_uva_stream()
|
||||
with torch.cuda.stream(host_to_device_stream):
|
||||
values_cuda = values.wait().to(ids_device, non_blocking=True)
|
||||
values_cuda.record_stream(current_stream)
|
||||
values_copy_event = torch.cuda.Event()
|
||||
values_copy_event.record()
|
||||
|
||||
yield _Waiter(values_copy_event, values_cuda)
|
||||
else:
|
||||
yield torch.ops.graphbolt.index_select_async(self._tensor, ids)
|
||||
|
||||
def read_async_num_stages(self, ids_device: torch.device):
|
||||
"""The number of stages of the read_async operation. See read_async
|
||||
function for directions on its use. This function is required to return
|
||||
the number of yield operations when read_async is used with a tensor
|
||||
residing on ids_device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids_device : torch.device
|
||||
The device of the ids parameter passed into read_async.
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of stages of the read_async operation.
|
||||
"""
|
||||
if ids_device.type == "cuda":
|
||||
if self._tensor.is_cuda:
|
||||
# If the ids and the tensor are on cuda, no need for async.
|
||||
return 0
|
||||
return 1 if self.is_pinned() else 3
|
||||
else:
|
||||
return 1
|
||||
|
||||
def size(self):
|
||||
"""Get the size of the feature.
|
||||
|
||||
Returns
|
||||
-------
|
||||
torch.Size
|
||||
The size of the feature.
|
||||
"""
|
||||
return self._tensor.size()[1:]
|
||||
|
||||
def count(self):
|
||||
"""Get the count of the feature.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The count of the feature.
|
||||
"""
|
||||
return self._tensor.size()[0]
|
||||
|
||||
def update(self, value: torch.Tensor, ids: torch.Tensor = None):
|
||||
"""Update the feature store.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value : torch.Tensor
|
||||
The updated value of the feature.
|
||||
ids : torch.Tensor, optional
|
||||
The indices of the feature to update. If specified, only the
|
||||
specified indices of the feature will be updated. For the feature,
|
||||
the `ids[i]` row is updated to `value[i]`. So the indices and value
|
||||
must have the same length. If None, the entire feature will be
|
||||
updated.
|
||||
"""
|
||||
if ids is None:
|
||||
self._tensor = value
|
||||
else:
|
||||
assert ids.shape[0] == value.shape[0], (
|
||||
f"ids and value must have the same length, "
|
||||
f"but got {ids.shape[0]} and {value.shape[0]}."
|
||||
)
|
||||
assert self.size() == value.size()[1:], (
|
||||
f"The size of the feature is {self.size()}, "
|
||||
f"while the size of the value is {value.size()[1:]}."
|
||||
)
|
||||
if self._tensor.is_pinned() and value.is_cuda and ids.is_cuda:
|
||||
raise NotImplementedError(
|
||||
"Update the feature on pinned CPU memory by GPU is not "
|
||||
"supported yet."
|
||||
)
|
||||
self._tensor[ids] = value
|
||||
|
||||
def metadata(self):
|
||||
"""Get the metadata of the feature.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict
|
||||
The metadata of the feature.
|
||||
"""
|
||||
return (
|
||||
self._metadata if self._metadata is not None else super().metadata()
|
||||
)
|
||||
|
||||
def pin_memory_(self):
|
||||
"""In-place operation to copy the feature to pinned memory. Returns the
|
||||
same object modified in-place."""
|
||||
# torch.Tensor.pin_memory() is not an inplace operation. To make it
|
||||
# truly in-place, we need to use cudaHostRegister. Then, we need to use
|
||||
# cudaHostUnregister to unpin the tensor in the destructor.
|
||||
# https://github.com/pytorch/pytorch/issues/32167#issuecomment-753551842
|
||||
x = self._tensor
|
||||
if not x.is_pinned() and x.device.type == "cpu":
|
||||
assert (
|
||||
x.is_contiguous()
|
||||
), "Tensor pinning is only supported for contiguous tensors."
|
||||
cudart = torch.cuda.cudart()
|
||||
assert (
|
||||
cudart.cudaHostRegister(
|
||||
x.data_ptr(), x.numel() * x.element_size(), 0
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
self._is_inplace_pinned.add(x)
|
||||
self._inplace_unpinner = cudart.cudaHostUnregister
|
||||
|
||||
return self
|
||||
|
||||
def is_pinned(self):
|
||||
"""Returns True if the stored feature is pinned."""
|
||||
return self._tensor.is_pinned()
|
||||
|
||||
def to(self, device): # pylint: disable=invalid-name
|
||||
"""Copy `TorchBasedFeature` to the specified device."""
|
||||
# copy.copy is a shallow copy so it does not copy tensor memory.
|
||||
self2 = copy.copy(self)
|
||||
if device == "pinned":
|
||||
self2._tensor = self2._tensor.pin_memory()
|
||||
else:
|
||||
self2._tensor = self2._tensor.to(device)
|
||||
return self2
|
||||
|
||||
def __repr__(self) -> str:
|
||||
ret = (
|
||||
"{Classname}(\n"
|
||||
" feature={feature},\n"
|
||||
" metadata={metadata},\n"
|
||||
")"
|
||||
)
|
||||
|
||||
feature_str = textwrap.indent(
|
||||
str(self._tensor), " " * len(" feature=")
|
||||
).strip()
|
||||
metadata_str = textwrap.indent(
|
||||
str(self.metadata()), " " * len(" metadata=")
|
||||
).strip()
|
||||
|
||||
return ret.format(
|
||||
Classname=self.__class__.__name__,
|
||||
feature=feature_str,
|
||||
metadata=metadata_str,
|
||||
)
|
||||
|
||||
|
||||
class DiskBasedFeature(Feature):
|
||||
r"""A wrapper of disk based feature.
|
||||
|
||||
Initialize a disk based feature fetcher by a numpy file. Note that you can
|
||||
use gb.numpy_save_aligned as a replacement for np.save to potentially get
|
||||
increased performance.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : string
|
||||
The path to the numpy feature file.
|
||||
Note that the dimension of the numpy should be greater than 1.
|
||||
metadata : Dict
|
||||
The metadata of the feature.
|
||||
num_threads : int
|
||||
The number of threads driving io_uring queues.
|
||||
Examples
|
||||
--------
|
||||
>>> import torch
|
||||
>>> from dgl import graphbolt as gb
|
||||
>>> torch_feat = torch.arange(10).reshape(2, -1)
|
||||
>>> pth = "path/to/feat.npy"
|
||||
>>> np.save(pth, torch_feat)
|
||||
>>> feature = gb.DiskBasedFeature(pth)
|
||||
>>> feature.read(torch.tensor([0]))
|
||||
tensor([[0, 1, 2, 3, 4]])
|
||||
>>> feature.size()
|
||||
torch.Size([5])
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, metadata: Dict = None, num_threads=None):
|
||||
super().__init__()
|
||||
mmap_mode = "r+"
|
||||
ondisk_data = np.load(path, mmap_mode=mmap_mode)
|
||||
assert ondisk_data.flags[
|
||||
"C_CONTIGUOUS"
|
||||
], "DiskBasedFeature only supports C_CONTIGUOUS array."
|
||||
self._tensor = torch.from_numpy(ondisk_data)
|
||||
|
||||
self._metadata = metadata
|
||||
if torch.ops.graphbolt.detect_io_uring():
|
||||
self._ondisk_npy_array = torch.ops.graphbolt.ondisk_npy_array(
|
||||
path, self._tensor.dtype, self._tensor.shape, num_threads
|
||||
)
|
||||
|
||||
def read(self, ids: torch.Tensor = None):
|
||||
"""Read the feature by index.
|
||||
The returned tensor will be on CPU.
|
||||
Parameters
|
||||
----------
|
||||
ids : torch.Tensor
|
||||
The index of the feature. Only the specified indices of the
|
||||
feature are read.
|
||||
Returns
|
||||
-------
|
||||
torch.Tensor
|
||||
The read feature.
|
||||
"""
|
||||
if ids is None:
|
||||
return self._tensor
|
||||
elif torch.ops.graphbolt.detect_io_uring():
|
||||
try:
|
||||
return self._ondisk_npy_array.index_select(ids).wait()
|
||||
except RuntimeError:
|
||||
raise IndexError
|
||||
else:
|
||||
return index_select(self._tensor, ids)
|
||||
|
||||
def read_async(self, ids: torch.Tensor):
|
||||
r"""Read the feature by index asynchronously.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids : torch.Tensor
|
||||
The index of the feature. Only the specified indices of the
|
||||
feature are read.
|
||||
Returns
|
||||
-------
|
||||
A generator object.
|
||||
The returned generator object returns a future on
|
||||
``read_async_num_stages(ids.device)``\ th invocation. The return result
|
||||
can be accessed by calling ``.wait()``. on the returned future object.
|
||||
It is undefined behavior to call ``.wait()`` more than once.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import dgl.graphbolt as gb
|
||||
>>> feature = gb.Feature(...)
|
||||
>>> ids = torch.tensor([0, 2])
|
||||
>>> for stage, future in enumerate(feature.read_async(ids)):
|
||||
... pass
|
||||
>>> assert stage + 1 == feature.read_async_num_stages(ids.device)
|
||||
>>> result = future.wait() # result contains the read values.
|
||||
"""
|
||||
assert torch.ops.graphbolt.detect_io_uring()
|
||||
if ids.is_cuda:
|
||||
ids_device = ids.device
|
||||
current_stream = torch.cuda.current_stream()
|
||||
device_to_host_stream = get_device_to_host_uva_stream()
|
||||
device_to_host_stream.wait_stream(current_stream)
|
||||
with torch.cuda.stream(device_to_host_stream):
|
||||
ids.record_stream(torch.cuda.current_stream())
|
||||
ids = ids.to(self._tensor.device, non_blocking=True)
|
||||
ids_copy_event = torch.cuda.Event()
|
||||
ids_copy_event.record()
|
||||
|
||||
yield # first stage is done.
|
||||
|
||||
ids_copy_event.synchronize()
|
||||
values = self._ondisk_npy_array.index_select(ids)
|
||||
yield
|
||||
|
||||
host_to_device_stream = get_host_to_device_uva_stream()
|
||||
with torch.cuda.stream(host_to_device_stream):
|
||||
values_cuda = values.wait().to(ids_device, non_blocking=True)
|
||||
values_cuda.record_stream(current_stream)
|
||||
values_copy_event = torch.cuda.Event()
|
||||
values_copy_event.record()
|
||||
|
||||
yield _Waiter(values_copy_event, values_cuda)
|
||||
else:
|
||||
yield self._ondisk_npy_array.index_select(ids)
|
||||
|
||||
def read_async_num_stages(self, ids_device: torch.device):
|
||||
"""The number of stages of the read_async operation. See read_async
|
||||
function for directions on its use. This function is required to return
|
||||
the number of yield operations when read_async is used with a tensor
|
||||
residing on ids_device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ids_device : torch.device
|
||||
The device of the ids parameter passed into read_async.
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of stages of the read_async operation.
|
||||
"""
|
||||
return 3 if ids_device.type == "cuda" else 1
|
||||
|
||||
def size(self):
|
||||
"""Get the size of the feature.
|
||||
Returns
|
||||
-------
|
||||
torch.Size
|
||||
The size of the feature.
|
||||
"""
|
||||
return self._tensor.size()[1:]
|
||||
|
||||
def count(self):
|
||||
"""Get the count of the feature.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The count of the feature.
|
||||
"""
|
||||
return self._tensor.size()[0]
|
||||
|
||||
def update(self, value: torch.Tensor, ids: torch.Tensor = None):
|
||||
"""Disk based feature does not support update for now."""
|
||||
raise NotImplementedError
|
||||
|
||||
def metadata(self):
|
||||
"""Get the metadata of the feature.
|
||||
Returns
|
||||
-------
|
||||
Dict
|
||||
The metadata of the feature.
|
||||
"""
|
||||
return (
|
||||
self._metadata if self._metadata is not None else super().metadata()
|
||||
)
|
||||
|
||||
def read_into_memory(self) -> TorchBasedFeature:
|
||||
"""Change disk-based feature to torch-based feature."""
|
||||
return TorchBasedFeature(self._tensor, self._metadata)
|
||||
|
||||
def to(self, _): # pylint: disable=invalid-name
|
||||
"""Placeholder `DiskBasedFeature` to implementation. It is a no-op."""
|
||||
gb_warning(
|
||||
"`DiskBasedFeature.to(device)` is not supported. Leaving unmodified."
|
||||
)
|
||||
return self
|
||||
|
||||
def pin_memory_(self): # pylint: disable=invalid-name
|
||||
r"""Placeholder `DiskBasedFeature` pin_memory_ implementation. It is a no-op."""
|
||||
gb_warning(
|
||||
"`DiskBasedFeature.pin_memory_()` is not supported. Leaving unmodified."
|
||||
)
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
ret = (
|
||||
"{Classname}(\n"
|
||||
" feature={feature},\n"
|
||||
" metadata={metadata},\n"
|
||||
")"
|
||||
)
|
||||
|
||||
feature_str = textwrap.indent(
|
||||
str(self._tensor), " " * len(" feature=")
|
||||
).strip()
|
||||
metadata_str = textwrap.indent(
|
||||
str(self.metadata()), " " * len(" metadata=")
|
||||
).strip()
|
||||
|
||||
return ret.format(
|
||||
Classname=self.__class__.__name__,
|
||||
feature=feature_str,
|
||||
metadata=metadata_str,
|
||||
)
|
||||
|
||||
|
||||
class TorchBasedFeatureStore(BasicFeatureStore):
|
||||
r"""A store to manage multiple pytorch based feature for access.
|
||||
|
||||
The feature stores are described by the `feat_data`. The `feat_data` is a
|
||||
list of `OnDiskFeatureData`.
|
||||
|
||||
For a feature store, its format must be either "pt" or "npy" for Pytorch or
|
||||
Numpy formats. If the format is "pt", the feature store must be loaded in
|
||||
memory. If the format is "npy", the feature store can be loaded in memory or
|
||||
on disk. Note that you can use gb.numpy_save_aligned as a replacement for
|
||||
np.save to potentially get increased performance.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
feat_data : List[OnDiskFeatureData]
|
||||
The description of the feature stores.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import torch
|
||||
>>> import numpy as np
|
||||
>>> from dgl import graphbolt as gb
|
||||
>>> edge_label = torch.tensor([[1], [2], [3]])
|
||||
>>> node_feat = torch.tensor([[1, 2, 3], [4, 5, 6]])
|
||||
>>> torch.save(edge_label, "/tmp/edge_label.pt")
|
||||
>>> gb.numpy_save_aligned("/tmp/node_feat.npy", node_feat.numpy())
|
||||
>>> feat_data = [
|
||||
... gb.OnDiskFeatureData(domain="edge", type="author:writes:paper",
|
||||
... name="label", format="torch", path="/tmp/edge_label.pt",
|
||||
... in_memory=True),
|
||||
... gb.OnDiskFeatureData(domain="node", type="paper", name="feat",
|
||||
... format="numpy", path="/tmp/node_feat.npy", in_memory=False),
|
||||
... ]
|
||||
>>> feature_store = gb.TorchBasedFeatureStore(feat_data)
|
||||
"""
|
||||
|
||||
def __init__(self, feat_data: List[OnDiskFeatureData]):
|
||||
features = {}
|
||||
for spec in feat_data:
|
||||
key = (spec.domain, spec.type, spec.name)
|
||||
metadata = spec.extra_fields
|
||||
if spec.format == "torch":
|
||||
assert spec.in_memory, (
|
||||
f"Pytorch tensor can only be loaded in memory, "
|
||||
f"but the feature {key} is loaded on disk."
|
||||
)
|
||||
features[key] = TorchBasedFeature(
|
||||
torch.load(spec.path, weights_only=False), metadata=metadata
|
||||
)
|
||||
elif spec.format == "numpy":
|
||||
if spec.in_memory:
|
||||
# TorchBasedFeature is always in memory by default.
|
||||
features[key] = TorchBasedFeature(
|
||||
torch.as_tensor(np.load(spec.path)), metadata=metadata
|
||||
)
|
||||
else:
|
||||
# DiskBasedFeature is always out of memory by default.
|
||||
features[key] = DiskBasedFeature(
|
||||
spec.path, metadata=metadata
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown feature format {spec.format}")
|
||||
super().__init__(features)
|
||||
|
||||
def pin_memory_(self):
|
||||
"""In-place operation to copy the feature store to pinned memory.
|
||||
Returns the same object modified in-place."""
|
||||
if is_wsl():
|
||||
gb_warning(
|
||||
"In place pinning is not supported on WSL. "
|
||||
"Returning the out of place pinned `TorchBasedFeatureStore`."
|
||||
)
|
||||
return self.to("pinned")
|
||||
for feature in self._features.values():
|
||||
feature.pin_memory_()
|
||||
|
||||
return self
|
||||
|
||||
def is_pinned(self):
|
||||
"""Returns True if all the stored features are pinned."""
|
||||
return all(feature.is_pinned() for feature in self._features.values())
|
||||
|
||||
def to(self, device): # pylint: disable=invalid-name
|
||||
"""Copy `TorchBasedFeatureStore` to the specified device."""
|
||||
# copy.copy is a shallow copy so it does not copy tensor memory.
|
||||
self2 = copy.copy(self)
|
||||
self2._features = {k: v.to(device) for k, v in self2._features.items()}
|
||||
return self2
|
||||
|
||||
def __repr__(self) -> str:
|
||||
ret = "{Classname}(\n" + " {features}\n" + ")"
|
||||
features_str = textwrap.indent(str(self._features), " ").strip()
|
||||
return ret.format(
|
||||
Classname=self.__class__.__name__, features=features_str
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Uniform negative sampler for GraphBolt."""
|
||||
|
||||
import torch
|
||||
from torch.utils.data import functional_datapipe
|
||||
|
||||
from ..negative_sampler import NegativeSampler
|
||||
|
||||
__all__ = ["UniformNegativeSampler"]
|
||||
|
||||
|
||||
@functional_datapipe("sample_uniform_negative")
|
||||
class UniformNegativeSampler(NegativeSampler):
|
||||
"""Sample negative destination nodes for each source node based on a uniform
|
||||
distribution.
|
||||
|
||||
Functional name: :obj:`sample_uniform_negative`.
|
||||
|
||||
It's important to note that the term 'negative' refers to false negatives,
|
||||
indicating that the sampled pairs are not ensured to be absent in the graph.
|
||||
For each edge ``(u, v)``, it is supposed to generate `negative_ratio` pairs
|
||||
of negative edges ``(u, v')``, where ``v'`` is chosen uniformly from all
|
||||
the nodes in the graph.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
datapipe : DataPipe
|
||||
The datapipe.
|
||||
graph : FusedCSCSamplingGraph
|
||||
The graph on which to perform negative sampling.
|
||||
negative_ratio : int
|
||||
The proportion of negative samples to positive samples.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from dgl import graphbolt as gb
|
||||
>>> indptr = torch.LongTensor([0, 1, 2, 3, 4])
|
||||
>>> indices = torch.LongTensor([1, 2, 3, 0])
|
||||
>>> graph = gb.fused_csc_sampling_graph(indptr, indices)
|
||||
>>> seeds = torch.tensor([[0, 1], [1, 2], [2, 3], [3, 0]])
|
||||
>>> item_set = gb.ItemSet(seeds, names="seeds")
|
||||
>>> item_sampler = gb.ItemSampler(
|
||||
... item_set, batch_size=4,)
|
||||
>>> neg_sampler = gb.UniformNegativeSampler(
|
||||
... item_sampler, graph, 2)
|
||||
>>> for minibatch in neg_sampler:
|
||||
... print(minibatch.seeds)
|
||||
... print(minibatch.labels)
|
||||
... print(minibatch.indexes)
|
||||
tensor([[0, 1], [1, 2], [2, 3], [3, 0], [0, 1], [0, 3], [1, 1], [1, 2],
|
||||
[2, 1], [2, 0], [3, 0], [3, 2]])
|
||||
tensor([1., 1., 1., 1., 0., 0., 0., 0., 0., 0., 0., 0.])
|
||||
tensor([0, 1, 2, 3, 0, 0, 1, 1, 2, 2, 3, 3])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datapipe,
|
||||
graph,
|
||||
negative_ratio,
|
||||
):
|
||||
super().__init__(datapipe, negative_ratio)
|
||||
self.graph = graph
|
||||
|
||||
def _sample_with_etype(self, seeds, etype=None):
|
||||
assert seeds.ndim == 2 and seeds.shape[1] == 2, (
|
||||
"Only tensor with shape N*2 is supported for negative"
|
||||
+ f" sampling, but got {seeds.shape}."
|
||||
)
|
||||
# Sample negative edges, and concatenate positive edges with them.
|
||||
all_seeds = self.graph.sample_negative_edges_uniform(
|
||||
etype,
|
||||
seeds,
|
||||
self.negative_ratio,
|
||||
)
|
||||
# Construct indexes for all node pairs.
|
||||
pos_num = seeds.shape[0]
|
||||
negative_ratio = self.negative_ratio
|
||||
pos_indexes = torch.arange(0, pos_num, device=all_seeds.device)
|
||||
neg_indexes = pos_indexes.repeat_interleave(negative_ratio)
|
||||
indexes = torch.cat((pos_indexes, neg_indexes))
|
||||
# Construct labels for all node pairs.
|
||||
neg_num = all_seeds.shape[0] - pos_num
|
||||
labels = torch.empty(pos_num + neg_num, device=all_seeds.device)
|
||||
labels[:pos_num] = 1
|
||||
labels[pos_num:] = 0
|
||||
return all_seeds, labels, indexes
|
||||
Reference in New Issue
Block a user