chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:35:51 +08:00
commit c36a561cd8
2172 changed files with 455595 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
"""Graphbolt."""
import os
import sys
from .internal_utils import *
CUDA_ALLOCATOR_ENV_WARNING_STR = """
An experimental feature for CUDA allocations is turned on for better allocation
pattern resulting in better memory usage for minibatch GNN training workloads.
See https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf,
and set the environment variable `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:False`
if you want to disable it and set it True to acknowledge and disable the warning.
"""
cuda_allocator_env = os.getenv("PYTORCH_CUDA_ALLOC_CONF")
WARNING_STR_TO_BE_SHOWN = None
configs = (
{}
if cuda_allocator_env is None or len(cuda_allocator_env) == 0
else {
kv_pair.split(":")[0]: kv_pair.split(":")[1]
for kv_pair in cuda_allocator_env.split(",")
}
)
if "expandable_segments" in configs:
if configs["expandable_segments"] != "True":
WARNING_STR_TO_BE_SHOWN = (
"You should consider `expandable_segments:True` in the"
" environment variable `PYTORCH_CUDA_ALLOC_CONF` for lower"
" memory usage. See "
"https://pytorch.org/docs/stable/notes/cuda.html"
"#optimizing-memory-usage-with-pytorch-cuda-alloc-conf"
)
else:
configs["expandable_segments"] = "True"
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = ",".join(
[k + ":" + v for k, v in configs.items()]
)
WARNING_STR_TO_BE_SHOWN = CUDA_ALLOCATOR_ENV_WARNING_STR
del configs
del cuda_allocator_env
del CUDA_ALLOCATOR_ENV_WARNING_STR
# pylint: disable=wrong-import-position, wrong-import-order
import torch
### FROM DGL @todo
from .._ffi import libinfo
def load_graphbolt():
"""Load Graphbolt C++ library"""
vers = torch.__version__.split("+", maxsplit=1)[0]
if sys.platform.startswith("linux"):
basename = f"libgraphbolt_pytorch_{vers}.so"
elif sys.platform.startswith("darwin"):
basename = f"libgraphbolt_pytorch_{vers}.dylib"
elif sys.platform.startswith("win"):
basename = f"graphbolt_pytorch_{vers}.dll"
else:
raise NotImplementedError("Unsupported system: %s" % sys.platform)
dirname = os.path.dirname(libinfo.find_lib_path()[0])
path = os.path.join(dirname, "graphbolt", basename)
if not os.path.exists(path):
raise FileNotFoundError(
f"Unable to locate the DGL C++ GraphBolt library at {path}. This "
"error typically occurs due to a version mismatch between the "
"installed DGL and the PyTorch version you are currently using. "
"Please ensure that your DGL installation is compatible with your "
"PyTorch version. For more information, refer to the installation "
"guide at https://www.dgl.ai/pages/start.html."
)
try:
torch.classes.load_library(path)
except Exception: # pylint: disable=W0703
raise ImportError("Cannot load Graphbolt C++ library")
load_graphbolt()
# pylint: disable=wrong-import-position
from .base import *
from .minibatch import *
from .dataloader import *
from .datapipes import *
from .dataset import *
from .feature_fetcher import *
from .feature_store import *
from .impl import *
from .itemset import *
from .item_sampler import *
from .minibatch_transformer import *
from .negative_sampler import *
from .sampled_subgraph import *
from .subgraph_sampler import *
from .external_utils import add_reverse_edges, exclude_seed_edges
from .internal import (
compact_csc_format,
numpy_save_aligned,
unique_and_compact,
unique_and_compact_csc_formats,
)
if torch.cuda.is_available() and not built_with_cuda():
raise ImportError(
"torch was installed with CUDA support while GraphBolt's CPU version "
"is installed. Consider reinstalling GraphBolt with CUDA support, see "
"installation instructions at https://www.dgl.ai/pages/start.html"
)
if torch.cuda.is_available() and WARNING_STR_TO_BE_SHOWN is not None:
gb_warning(WARNING_STR_TO_BE_SHOWN)
del WARNING_STR_TO_BE_SHOWN
torch.ops.graphbolt.set_num_io_uring_threads(
min((torch.get_num_threads() + 1) // 2, 8)
)
+524
View File
@@ -0,0 +1,524 @@
"""Base types and utilities for Graph Bolt."""
from collections import deque
from dataclasses import dataclass
import torch
from torch.torch_version import TorchVersion
if (
TorchVersion(torch.__version__) >= "2.3.0"
and TorchVersion(torch.__version__) < "2.3.1"
):
# Due to https://github.com/dmlc/dgl/issues/7380, for torch 2.3.0, we need
# to check if dill is available before using it.
torch.utils.data.datapipes.utils.common.DILL_AVAILABLE = (
torch.utils._import_utils.dill_available()
)
# pylint: disable=wrong-import-position
from torch.utils.data import functional_datapipe, IterDataPipe
from .internal_utils import (
get_nonproperty_attributes,
recursive_apply,
recursive_apply_reduce_all,
)
__all__ = [
"CANONICAL_ETYPE_DELIMITER",
"ORIGINAL_EDGE_ID",
"etype_str_to_tuple",
"etype_tuple_to_str",
"CopyTo",
"Waiter",
"Bufferer",
"EndMarker",
"isin",
"index_select",
"expand_indptr",
"indptr_edge_ids",
"CSCFormatBase",
"seed",
"seed_type_str_to_ntypes",
"get_host_to_device_uva_stream",
"get_device_to_host_uva_stream",
]
CANONICAL_ETYPE_DELIMITER = ":"
ORIGINAL_EDGE_ID = "_ORIGINAL_EDGE_ID"
# There needs to be a single instance of the uva_stream, if it is created
# multiple times, it leads to multiple CUDA memory pools and memory leaks.
def get_host_to_device_uva_stream():
"""The host to device copy stream to be used for pipeline parallelism."""
if not hasattr(get_host_to_device_uva_stream, "stream"):
get_host_to_device_uva_stream.stream = torch.cuda.Stream(priority=-1)
return get_host_to_device_uva_stream.stream
def get_device_to_host_uva_stream():
"""The device to host copy stream to be used for pipeline parallelism."""
if not hasattr(get_device_to_host_uva_stream, "stream"):
get_device_to_host_uva_stream.stream = torch.cuda.Stream(priority=-1)
return get_device_to_host_uva_stream.stream
def seed(val):
"""Set the random seed of Graphbolt.
Parameters
----------
val : int
The seed.
"""
torch.ops.graphbolt.set_seed(val)
def isin(elements, test_elements):
"""Tests if each element of elements is in test_elements. Returns a boolean
tensor of the same shape as elements that is True for elements in
test_elements and False otherwise.
Parameters
----------
elements : torch.Tensor
A 1D tensor represents the input elements.
test_elements : torch.Tensor
A 1D tensor represents the values to test against for each input.
Examples
--------
>>> isin(torch.tensor([1, 2, 3, 4]), torch.tensor([2, 3]))
tensor([[False, True, True, False]])
"""
assert elements.dim() == 1, "Elements should be 1D tensor."
assert test_elements.dim() == 1, "Test_elements should be 1D tensor."
return torch.ops.graphbolt.isin(elements, test_elements)
if TorchVersion(torch.__version__) >= TorchVersion("2.2.0a0"):
torch_fake_decorator = (
torch.library.impl_abstract
if TorchVersion(torch.__version__) < TorchVersion("2.4.0a0")
else torch.library.register_fake
)
@torch_fake_decorator("graphbolt::expand_indptr")
def expand_indptr_fake(indptr, dtype, node_ids, output_size):
"""Fake implementation of expand_indptr for torch.compile() support."""
if output_size is None:
output_size = torch.library.get_ctx().new_dynamic_size()
if dtype is None:
dtype = node_ids.dtype
return indptr.new_empty(output_size, dtype=dtype)
def expand_indptr(indptr, dtype=None, node_ids=None, output_size=None):
"""Converts a given indptr offset tensor to a COO format tensor. If
node_ids is not given, it is assumed to be equal to
torch.arange(indptr.size(0) - 1, dtype=dtype, device=indptr.device).
This is equivalent to
.. code:: python
if node_ids is None:
node_ids = torch.arange(len(indptr) - 1, dtype=dtype, device=indptr.device)
return node_ids.to(dtype).repeat_interleave(indptr.diff())
Parameters
----------
indptr : torch.Tensor
A 1D tensor represents the csc_indptr tensor.
dtype : Optional[torch.dtype]
The dtype of the returned output tensor.
node_ids : Optional[torch.Tensor]
A 1D tensor represents the column node ids that the returned tensor will
be populated with.
output_size : Optional[int]
The size of the output tensor. Should be equal to indptr[-1]. Using this
argument avoids a stream synchronization to calculate the output shape.
Returns
-------
torch.Tensor
The converted COO tensor with values from node_ids.
"""
assert indptr.dim() == 1, "Indptr should be 1D tensor."
assert not (
node_ids is None and dtype is None
), "One of node_ids or dtype must be given."
assert (
node_ids is None or node_ids.dim() == 1
), "Node_ids should be 1D tensor."
if dtype is None:
dtype = node_ids.dtype
return torch.ops.graphbolt.expand_indptr(
indptr, dtype, node_ids, output_size
)
if TorchVersion(torch.__version__) >= TorchVersion("2.2.0a0"):
torch_fake_decorator = (
torch.library.impl_abstract
if TorchVersion(torch.__version__) < TorchVersion("2.4.0a0")
else torch.library.register_fake
)
@torch_fake_decorator("graphbolt::indptr_edge_ids")
def indptr_edge_ids_fake(indptr, dtype, offset, output_size):
"""Fake implementation of indptr_edge_ids for torch.compile() support."""
if output_size is None:
output_size = torch.library.get_ctx().new_dynamic_size()
if dtype is None:
dtype = offset.dtype
return indptr.new_empty(output_size, dtype=dtype)
def indptr_edge_ids(indptr, dtype=None, offset=None, output_size=None):
"""Converts a given indptr offset tensor to a COO format tensor for the edge
ids. For a given indptr [0, 2, 5, 7] and offset tensor [0, 100, 200], the
output will be [0, 1, 100, 101, 102, 201, 202]. If offset was not provided,
the output would be [0, 1, 0, 1, 2, 0, 1].
Parameters
----------
indptr : torch.Tensor
A 1D tensor represents the csc_indptr tensor.
dtype : Optional[torch.dtype]
The dtype of the returned output tensor.
offset : Optional[torch.Tensor]
A 1D tensor represents the offsets that the returned tensor will be
populated with.
output_size : Optional[int]
The size of the output tensor. Should be equal to indptr[-1]. Using this
argument avoids a stream synchronization to calculate the output shape.
Returns
-------
torch.Tensor
The converted COO edge ids tensor.
"""
assert indptr.dim() == 1, "Indptr should be 1D tensor."
assert offset is None or offset.dim() == 1, "Offset should be 1D tensor."
if dtype is None:
dtype = offset.dtype
return torch.ops.graphbolt.indptr_edge_ids(
indptr, dtype, offset, output_size
)
def index_select(tensor, index):
"""Returns a new tensor which indexes the input tensor along dimension dim
using the entries in index.
The returned tensor has the same number of dimensions as the original tensor
(tensor). The first dimension has the same size as the length of index;
other dimensions have the same size as in the original tensor.
When tensor is a pinned tensor and index.is_cuda is True, the operation runs
on the CUDA device and the returned tensor will also be on CUDA.
Parameters
----------
tensor : torch.Tensor
The input tensor.
index : torch.Tensor
The 1-D tensor containing the indices to index.
Returns
-------
torch.Tensor
The indexed input tensor, equivalent to tensor[index]. If index is in
pinned memory, then the result is placed into pinned memory as well.
"""
assert index.dim() == 1, "Index should be 1D tensor."
return torch.ops.graphbolt.index_select(tensor, index)
def etype_tuple_to_str(c_etype):
"""Convert canonical etype from tuple to string.
Examples
--------
>>> c_etype = ("user", "like", "item")
>>> c_etype_str = _etype_tuple_to_str(c_etype)
>>> print(c_etype_str)
"user:like:item"
"""
assert isinstance(c_etype, tuple) and len(c_etype) == 3, (
"Passed-in canonical etype should be in format of (str, str, str). "
f"But got {c_etype}."
)
return CANONICAL_ETYPE_DELIMITER.join(c_etype)
def etype_str_to_tuple(c_etype):
"""Convert canonical etype from string to tuple.
Examples
--------
>>> c_etype_str = "user:like:item"
>>> c_etype = _etype_str_to_tuple(c_etype_str)
>>> print(c_etype)
("user", "like", "item")
"""
if isinstance(c_etype, tuple):
return c_etype
ret = tuple(c_etype.split(CANONICAL_ETYPE_DELIMITER))
assert len(ret) == 3, (
"Passed-in canonical etype should be in format of 'str:str:str'. "
f"But got {c_etype}."
)
return ret
def seed_type_str_to_ntypes(seed_type, seed_size):
"""Convert seeds type to node types from string to list.
Examples
--------
1. node pairs
>>> seed_type = "user:like:item"
>>> seed_size = 2
>>> node_type = seed_type_str_to_ntypes(seed_type, seed_size)
>>> print(node_type)
["user", "item"]
2. hyperlink
>>> seed_type = "query:user:item"
>>> seed_size = 3
>>> node_type = seed_type_str_to_ntypes(seed_type, seed_size)
>>> print(node_type)
["query", "user", "item"]
"""
assert isinstance(
seed_type, str
), f"Passed-in seed type should be string, but got {type(seed_type)}"
ntypes = seed_type.split(CANONICAL_ETYPE_DELIMITER)
is_hyperlink = len(ntypes) == seed_size
if not is_hyperlink:
ntypes = ntypes[::2]
return ntypes
def apply_to(x, device, non_blocking=False):
"""Apply `to` function to object x only if it has `to`."""
if device == "pinned" and hasattr(x, "pin_memory"):
return x.pin_memory()
if not hasattr(x, "to"):
return x
if not non_blocking:
return x.to(device)
return x.to(device, non_blocking=True)
def is_object_pinned(obj):
"""Recursively check all members of the object and return True if only if
all are pinned."""
for attr in get_nonproperty_attributes(obj):
member_result = recursive_apply_reduce_all(
getattr(obj, attr),
lambda x: x is None or x.is_pinned(),
)
if not member_result:
return False
return True
@functional_datapipe("copy_to")
class CopyTo(IterDataPipe):
"""DataPipe that transfers each element yielded from the previous DataPipe
to the given device. For MiniBatch, only the related attributes
(automatically inferred) will be transferred by default.
Functional name: :obj:`copy_to`.
When ``data`` has ``to`` method implemented, ``CopyTo`` will be equivalent
to
.. code:: python
for data in datapipe:
yield data.to(device)
Parameters
----------
datapipe : DataPipe
The DataPipe.
device : torch.device
The PyTorch CUDA device.
non_blocking : bool
Whether the copy should be performed without blocking. All elements have
to be already in pinned system memory if enabled. Default is False.
"""
def __init__(self, datapipe, device, non_blocking=False):
super().__init__()
self.datapipe = datapipe
self.device = torch.device(device)
self.non_blocking = non_blocking
def __iter__(self):
for data in self.datapipe:
yield recursive_apply(
data, apply_to, self.device, self.non_blocking
)
@functional_datapipe("mark_end")
class EndMarker(IterDataPipe):
"""Used to mark the end of a datapipe and is a no-op."""
def __init__(self, datapipe):
self.datapipe = datapipe
def __iter__(self):
yield from self.datapipe
@functional_datapipe("buffer")
class Bufferer(IterDataPipe):
"""Buffers items before yielding them.
Parameters
----------
datapipe : DataPipe
The data pipeline.
buffer_size : int, optional
The size of the buffer which stores the fetched samples. If data coming
from datapipe has latency spikes, consider setting to a higher value.
Default is 1.
"""
def __init__(self, datapipe, buffer_size=1):
self.datapipe = datapipe
if buffer_size <= 0:
raise ValueError(
"'buffer_size' is required to be a positive integer."
)
self.buffer = deque(maxlen=buffer_size)
def __iter__(self):
for data in self.datapipe:
if len(self.buffer) < self.buffer.maxlen:
self.buffer.append(data)
else:
return_data = self.buffer.popleft()
self.buffer.append(data)
yield return_data
while len(self.buffer) > 0:
yield self.buffer.popleft()
def __getstate__(self):
state = (self.datapipe, self.buffer.maxlen)
if IterDataPipe.getstate_hook is not None:
return IterDataPipe.getstate_hook(state)
return state
def __setstate__(self, state):
self.datapipe, buffer_size = state
self.buffer = deque(maxlen=buffer_size)
def reset(self):
"""Resets the state of the datapipe."""
self.buffer.clear()
@functional_datapipe("wait")
class Waiter(IterDataPipe):
"""Calls the wait function of all items."""
def __init__(self, datapipe):
self.datapipe = datapipe
def __iter__(self):
for data in self.datapipe:
data.wait()
yield data
@dataclass
class CSCFormatBase:
r"""Basic class representing data in Compressed Sparse Column (CSC) format.
Examples
--------
>>> indptr = torch.tensor([0, 1, 3])
>>> indices = torch.tensor([1, 4, 2])
>>> csc_foramt_base = CSCFormatBase(indptr=indptr, indices=indices)
>>> print(csc_format_base.indptr)
... torch.tensor([0, 1, 3])
>>> print(csc_foramt_base)
... torch.tensor([1, 4, 2])
"""
indptr: torch.Tensor = None
indices: torch.Tensor = None
def __init__(self, indptr: torch.Tensor, indices: torch.Tensor):
self.indptr = indptr
self.indices = indices
if not indptr.is_cuda:
assert self.indptr[-1] == len(
self.indices
), "The last element of indptr should be the same as the length of indices."
def __repr__(self) -> str:
return _csc_format_base_str(self)
def to( # pylint: disable=invalid-name
self, device: torch.device, non_blocking=False
) -> None:
"""Copy `CSCFormatBase` to the specified device using reflection."""
for attr in dir(self):
# Only copy member variables.
if not callable(getattr(self, attr)) and not attr.startswith("__"):
setattr(
self,
attr,
recursive_apply(
getattr(self, attr),
apply_to,
device,
non_blocking=non_blocking,
),
)
return self
def pin_memory(self):
"""Copy `SampledSubgraph` to the pinned memory using reflection."""
return self.to("pinned")
def is_pinned(self) -> bool:
"""Check whether `SampledSubgraph` is pinned using reflection."""
return is_object_pinned(self)
def _csc_format_base_str(csc_format_base: CSCFormatBase) -> str:
final_str = "CSCFormatBase("
def _add_indent(_str, indent):
lines = _str.split("\n")
lines = [lines[0]] + [" " * indent + line for line in lines[1:]]
return "\n".join(lines)
final_str += (
f"indptr={_add_indent(str(csc_format_base.indptr), 21)},\n" + " " * 14
)
final_str += (
f"indices={_add_indent(str(csc_format_base.indices), 22)},\n" + ")"
)
return final_str
+191
View File
@@ -0,0 +1,191 @@
"""Graph Bolt DataLoaders"""
import torch
import torch.utils.data as torch_data
from .base import CopyTo
from .datapipes import (
datapipe_graph_to_adjlist,
find_dps,
replace_dp,
traverse_dps,
)
from .feature_fetcher import FeatureFetcher, FeatureFetcherStartMarker
from .impl.neighbor_sampler import SamplePerLayer
from .internal_utils import gb_warning
from .item_sampler import ItemSampler
from .minibatch_transformer import MiniBatchTransformer
__all__ = [
"DataLoader",
]
def _find_and_wrap_parent(datapipe_graph, target_datapipe, wrapper, **kwargs):
"""Find parent of target_datapipe and wrap it with ."""
datapipes = find_dps(
datapipe_graph,
target_datapipe,
)
datapipe_adjlist = datapipe_graph_to_adjlist(datapipe_graph)
for datapipe in datapipes:
datapipe_id = id(datapipe)
for parent_datapipe_id in datapipe_adjlist[datapipe_id][1]:
parent_datapipe, _ = datapipe_adjlist[parent_datapipe_id]
datapipe_graph = replace_dp(
datapipe_graph,
parent_datapipe,
wrapper(parent_datapipe, **kwargs),
)
return datapipe_graph
def _set_worker_id(worked_id):
torch.ops.graphbolt.set_worker_id(worked_id)
class MultiprocessingWrapper(torch_data.IterDataPipe):
"""Wraps a datapipe with multiprocessing.
Parameters
----------
datapipe : DataPipe
The data pipeline.
num_workers : int, optional
The number of worker processes. Default is 0, meaning that there
will be no multiprocessing.
persistent_workers : bool, optional
If True, the data loader will not shut down the worker processes after a
dataset has been consumed once. This allows to maintain the workers
instances alive.
"""
def __init__(self, datapipe, num_workers=0, persistent_workers=True):
self.datapipe = datapipe
self.dataloader = torch_data.DataLoader(
datapipe,
batch_size=None,
num_workers=num_workers,
persistent_workers=(num_workers > 0) and persistent_workers,
worker_init_fn=_set_worker_id if num_workers > 0 else None,
)
def __iter__(self):
yield from self.dataloader
class DataLoader(MiniBatchTransformer):
"""Multiprocessing DataLoader.
Iterates over the data pipeline with everything before feature fetching
(i.e. :class:`dgl.graphbolt.FeatureFetcher`) in subprocesses, and
everything after feature fetching in the main process. The datapipe
is modified in-place as a result.
When the copy_to operation is placed earlier in the data pipeline, the
num_workers argument is required to be 0 as utilizing CUDA in multiple
worker processes is not supported.
Parameters
----------
datapipe : DataPipe
The data pipeline.
num_workers : int, optional
Number of worker processes. Default is 0.
persistent_workers : bool, optional
If True, the data loader will not shut down the worker processes after a
dataset has been consumed once. This allows to maintain the workers
instances alive.
max_uva_threads : int, optional
Limits the number of CUDA threads used for UVA copies so that the rest
of the computations can run simultaneously with it. Setting it to a too
high value will limit the amount of overlap while setting it too low may
cause the PCI-e bandwidth to not get fully utilized. Manually tuned
default is 10240, meaning around 5-7 Streaming Multiprocessors.
"""
def __init__(
self,
datapipe,
num_workers=0,
persistent_workers=True,
max_uva_threads=10240,
):
# Multiprocessing requires two modifications to the datapipe:
#
# 1. Insert a stage after ItemSampler to distribute the
# minibatches evenly across processes.
# 2. Cut the datapipe at FeatureFetcher, and wrap the inner datapipe
# of the FeatureFetcher with a multiprocessing PyTorch DataLoader.
datapipe = datapipe.mark_end()
datapipe_graph = traverse_dps(datapipe)
if num_workers > 0:
# (1) Insert minibatch distribution.
# TODO(BarclayII): Currently I'm using sharding_filter() as a
# concept demonstration. Later on minibatch distribution should be
# merged into ItemSampler to maximize efficiency.
item_samplers = find_dps(
datapipe_graph,
ItemSampler,
)
for item_sampler in item_samplers:
datapipe_graph = replace_dp(
datapipe_graph,
item_sampler,
item_sampler.sharding_filter(),
)
# (2) Cut datapipe at FeatureFetcher and wrap.
datapipe_graph = _find_and_wrap_parent(
datapipe_graph,
FeatureFetcherStartMarker,
MultiprocessingWrapper,
num_workers=num_workers,
persistent_workers=persistent_workers,
)
# (3) Limit the number of UVA threads used if the feature_fetcher
# or any of the samplers have overlapping optimization enabled.
if num_workers == 0 and torch.cuda.is_available():
feature_fetchers = find_dps(
datapipe_graph,
FeatureFetcher,
)
for feature_fetcher in feature_fetchers:
if feature_fetcher.max_num_stages > 0: # Overlap enabled.
torch.ops.graphbolt.set_max_uva_threads(max_uva_threads)
if num_workers == 0 and torch.cuda.is_available():
samplers = find_dps(
datapipe_graph,
SamplePerLayer,
)
for sampler in samplers:
if sampler.overlap_fetch:
torch.ops.graphbolt.set_max_uva_threads(max_uva_threads)
# (4) Cut datapipe at CopyTo and wrap with pinning and prefetching
# before it. This enables enables non_blocking copies to the device.
# Prefetching enables the data pipeline up to the CopyTo to run in a
# separate thread.
copiers = find_dps(datapipe_graph, CopyTo)
if len(copiers) > 1:
gb_warning(
"Multiple CopyTo operations were found in the datapipe graph."
" This case is not officially supported."
)
for copier in copiers:
# We enable the prefetch at all times for good CPU only performance.
datapipe_graph = replace_dp(
datapipe_graph,
copier,
# Add prefetch so that CPU and GPU can run concurrently.
copier.datapipe.prefetch(2).copy_to(
copier.device, non_blocking=True
),
)
super().__init__(datapipe)
@@ -0,0 +1,3 @@
"""GraphBolt's datapipes, mostly copied from "torchdata==0.7.1"."""
from .utils import *
from .visualization import *
+372
View File
@@ -0,0 +1,372 @@
"""DataPipe utilities"""
import threading
import time
from collections import deque
from typing import final, List, Set, Type # pylint: disable=no-name-in-module
from torch.utils.data import functional_datapipe, IterDataPipe, MapDataPipe
from torch.utils.data.graph import DataPipe, DataPipeGraph, traverse_dps
__all__ = [
"datapipe_graph_to_adjlist",
"find_dps",
"replace_dp",
"traverse_dps",
]
# Copied from:
# https://github.com/pytorch/data/blob/88c8bdc6662f37649b7ea5df0bd90a4b24a56876/torchdata/datapipes/iter/util/prefetcher.py#L19-L20
# Interval between buffer fulfillment checks
PRODUCER_SLEEP_INTERVAL = 0.0001
# Interval between checking items availability in buffer
CONSUMER_SLEEP_INTERVAL = 0.0001
def _get_parents(result_dict, datapipe_graph):
for k, (v, parents) in datapipe_graph.items():
if k not in result_dict:
result_dict[k] = (v, list(parents.keys()))
_get_parents(result_dict, parents)
def datapipe_graph_to_adjlist(datapipe_graph):
"""Given a DataPipe graph returned by
:func:`torch.utils.data.graph.traverse_dps` in DAG form, convert it into
adjacency list form.
Namely, :func:`torch.utils.data.graph.traverse_dps` returns the following
data structure:
.. code::
{
id(datapipe): (
datapipe,
{
id(parent1_of_datapipe): (parent1_of_datapipe, {...}),
id(parent2_of_datapipe): (parent2_of_datapipe, {...}),
...
}
)
}
We convert it into the following for easier access:
.. code::
{
id(datapipe1): (
datapipe1,
[id(parent1_of_datapipe1), id(parent2_of_datapipe1), ...]
),
id(datapipe2): (
datapipe2,
[id(parent1_of_datapipe2), id(parent2_of_datapipe2), ...]
),
...
}
"""
result_dict = {}
_get_parents(result_dict, datapipe_graph)
return result_dict
# Copied from:
# https://github.com/pytorch/data/blob/88c8bdc6662f37649b7ea5df0bd90a4b24a56876/torchdata/dataloader2/graph/utils.py#L16-L35
def find_dps(graph: DataPipeGraph, dp_type: Type[DataPipe]) -> List[DataPipe]:
r"""
Given the graph of DataPipe generated by ``traverse_dps`` function, return DataPipe
instances with the provided DataPipe type.
"""
dps: List[DataPipe] = []
cache: Set[int] = set()
def helper(g) -> None: # pyre-ignore
for dp_id, (dp, src_graph) in g.items():
if dp_id in cache:
continue
cache.add(dp_id)
# Please not use `isinstance`, there is a bug.
if type(dp) is dp_type: # pylint: disable=unidiomatic-typecheck
dps.append(dp)
helper(src_graph)
helper(graph)
return dps
# Copied from:
# https://github.com/pytorch/data/blob/88c8bdc6662f37649b7ea5df0bd90a4b24a56876/torchdata/dataloader2/graph/utils.py#L82-L97
# Given the DataPipe needs to be replaced and the expected DataPipe, return a new graph
def replace_dp(
graph: DataPipeGraph, old_datapipe: DataPipe, new_datapipe: DataPipe
) -> DataPipeGraph:
r"""
Given the graph of DataPipe generated by ``traverse_dps`` function and the
DataPipe to be replaced and the new DataPipe, return the new graph of
DataPipe.
"""
assert len(graph) == 1
if id(old_datapipe) in graph:
graph = traverse_dps(new_datapipe)
final_datapipe = list(graph.values())[0][0]
for recv_dp, send_graph in graph.values():
_replace_dp(recv_dp, send_graph, old_datapipe, new_datapipe)
return traverse_dps(final_datapipe)
# For each `recv_dp`, find if the source_datapipe needs to be replaced by the new one.
# If found, find where the `old_dp` is located in `recv_dp` and switch it to the `new_dp`
def _replace_dp(
recv_dp, send_graph: DataPipeGraph, old_dp: DataPipe, new_dp: DataPipe
) -> None:
old_dp_id = id(old_dp)
for send_id in send_graph:
if send_id == old_dp_id:
_assign_attr(recv_dp, old_dp, new_dp, inner_dp=True)
else:
send_dp, sub_send_graph = send_graph[send_id]
_replace_dp(send_dp, sub_send_graph, old_dp, new_dp)
# Recursively re-assign datapipe for the sake of nested data structure
# `inner_dp` is used to prevent recursive call if we have already met a `DataPipe`
def _assign_attr(obj, old_dp, new_dp, inner_dp: bool = False):
if obj is old_dp:
return new_dp
elif isinstance(obj, (IterDataPipe, MapDataPipe)):
# Prevent recursive call for DataPipe
if not inner_dp:
return None
for k in list(obj.__dict__.keys()):
new_obj = _assign_attr(obj.__dict__[k], old_dp, new_dp)
if new_obj is not None:
obj.__dict__[k] = new_obj
break
return None
elif isinstance(obj, dict):
for k in list(obj.keys()):
new_obj = _assign_attr(obj[k], old_dp, new_dp)
if new_obj is not None:
obj[k] = new_obj
break
return None
# Tuple is immutable, has to re-create a tuple
elif isinstance(obj, tuple):
temp_list = []
flag = False
for item in obj:
new_obj = _assign_attr(item, old_dp, new_dp, inner_dp)
if new_obj is not None:
flag = True
temp_list.append(new_dp)
else:
temp_list.append(item)
if flag:
return tuple(temp_list) # Special case
else:
return None
elif isinstance(obj, list):
for i in range(len(obj)): # pylint: disable=consider-using-enumerate
new_obj = _assign_attr(obj[i], old_dp, new_dp, inner_dp)
if new_obj is not None:
obj[i] = new_obj
break
return None
elif isinstance(obj, set):
new_obj = None
for item in obj:
if _assign_attr(item, old_dp, new_dp, inner_dp) is not None:
new_obj = new_dp
break
if new_obj is not None:
obj.remove(old_dp)
obj.add(new_dp)
return None
else:
return None
class _PrefetchData:
def __init__(self, source_datapipe, buffer_size: int):
self.run_prefetcher: bool = True
self.prefetch_buffer: Deque = deque()
self.buffer_size: int = buffer_size
self.source_datapipe = source_datapipe
self.stop_iteration: bool = False
self.paused: bool = False
# Copied from:
# https://github.com/pytorch/data/blob/88c8bdc6662f37649b7ea5df0bd90a4b24a56876/torchdata/datapipes/iter/util/prefetcher.py#L34-L172
@functional_datapipe("prefetch")
class PrefetcherIterDataPipe(IterDataPipe):
r"""
Prefetches elements from the source DataPipe and puts them into a buffer
(functional name: ``prefetch``). Prefetching performs the operations (e.g.
I/O, computations) of the DataPipes up to this one ahead of time and stores
the result in the buffer, ready to be consumed by the subsequent DataPipe.
It has no effect aside from getting the sample ready ahead of time.
This is used by ``MultiProcessingReadingService`` when the arguments
``worker_prefetch_cnt`` (for prefetching at each worker process) or
``main_prefetch_cnt`` (for prefetching at the main loop) are greater than 0.
Beyond the built-in use cases, this can be useful to put after I/O DataPipes
that have expensive I/O operations (e.g. takes a long time to request a file
from a remote server).
Args:
source_datapipe: IterDataPipe from which samples are prefetched
buffer_size: the size of the buffer which stores the prefetched samples
Example:
>>> from torchdata.datapipes.iter import IterableWrapper
>>> dp = IterableWrapper(file_paths).open_files().prefetch(5)
"""
def __init__(self, source_datapipe, buffer_size: int = 10):
self.source_datapipe = source_datapipe
if buffer_size <= 0:
raise ValueError(
"'buffer_size' is required to be a positive integer."
)
self.buffer_size = buffer_size
self.thread: Optional[threading.Thread] = None
self.prefetch_data: Optional[_PrefetchData] = None
@staticmethod
def thread_worker(
prefetch_data: _PrefetchData,
): # pylint: disable=missing-function-docstring
itr = iter(prefetch_data.source_datapipe)
while not prefetch_data.stop_iteration:
# Run if not paused
while prefetch_data.run_prefetcher:
if (
len(prefetch_data.prefetch_buffer)
< prefetch_data.buffer_size
):
try:
item = next(itr)
prefetch_data.prefetch_buffer.append(item)
except Exception as e: # pylint: disable=broad-except
prefetch_data.run_prefetcher = False
prefetch_data.stop_iteration = True
prefetch_data.prefetch_buffer.append(e)
else: # Buffer is full, waiting for main thread to consume items
# TODO: Calculate sleep interval based on previous consumption speed
time.sleep(PRODUCER_SLEEP_INTERVAL)
prefetch_data.paused = True
# Sleep longer when this prefetcher thread is paused
time.sleep(PRODUCER_SLEEP_INTERVAL * 10)
def __iter__(self):
try:
prefetch_data = _PrefetchData(
self.source_datapipe, self.buffer_size
)
self.prefetch_data = prefetch_data
thread = threading.Thread(
target=PrefetcherIterDataPipe.thread_worker,
args=(prefetch_data,),
daemon=True,
)
thread.start()
self.thread = thread
while (
not prefetch_data.stop_iteration
or len(prefetch_data.prefetch_buffer) > 0
):
if len(prefetch_data.prefetch_buffer) > 0:
data = prefetch_data.prefetch_buffer.popleft()
if isinstance(data, Exception):
if isinstance(data, StopIteration):
break
raise data
yield data
else:
time.sleep(CONSUMER_SLEEP_INTERVAL)
finally:
if "prefetch_data" in locals():
prefetch_data.run_prefetcher = False
prefetch_data.stop_iteration = True
prefetch_data.paused = False
if "thread" in locals():
thread.join()
def __getstate__(self):
"""
Getting state in threading environment requires next operations:
1) Stopping of the producer thread.
2) Saving buffer.
3) Adding lazy restart of producer thread when __next__ is called again
(this will guarantee that you only change state of the source_datapipe
after entire state of the graph is saved).
"""
# TODO: Update __getstate__ and __setstate__ to support snapshotting and restoration
return {
"source_datapipe": self.source_datapipe,
"buffer_size": self.buffer_size,
}
def __setstate__(self, state):
self.source_datapipe = state["source_datapipe"]
self.buffer_size = state["buffer_size"]
self.thread = None
@final
def reset(self): # pylint: disable=missing-function-docstring
self.shutdown()
def pause(self): # pylint: disable=missing-function-docstring
if self.thread is not None:
assert self.prefetch_data is not None
self.prefetch_data.run_prefetcher = False
if self.thread.is_alive():
# Blocking until the thread is paused
while not self.prefetch_data.paused:
time.sleep(PRODUCER_SLEEP_INTERVAL * 10)
@final
def resume(self): # pylint: disable=missing-function-docstring
if (
self.thread is not None
and self.prefetch_data is not None
and (
not self.prefetch_data.stop_iteration
or len(self.prefetch_data.prefetch_buffer) > 0
)
):
self.prefetch_data.run_prefetcher = True
self.prefetch_data.paused = False
@final
def shutdown(self): # pylint: disable=missing-function-docstring
if hasattr(self, "prefetch_data") and self.prefetch_data is not None:
self.prefetch_data.run_prefetcher = False
self.prefetch_data.stop_iteration = True
self.prefetch_data.paused = False
self.prefetch_data = None
if hasattr(self, "thread") and self.thread is not None:
self.thread.join()
self.thread = None
def __del__(self):
self.shutdown()
def __len__(self) -> int:
if isinstance(self.source_datapipe, Sized):
return len(self.source_datapipe)
raise TypeError(
f"{type(self).__name__} instance doesn't have valid length"
)
@@ -0,0 +1,206 @@
# pylint: disable=W,C,R
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
# Original source:
# https://github.com/pytorch/data/blob/v0.7.1/torchdata/datapipes/utils/_visualization.py
import itertools
from collections import defaultdict
from typing import Optional, Set, TYPE_CHECKING
from torch.utils.data.datapipes.iter.combining import _ChildDataPipe
from .utils import IterDataPipe, traverse_dps
if TYPE_CHECKING:
import graphviz
__all__ = [
"to_graph",
]
class Node:
def __init__(self, dp, *, name=None):
self.dp = dp
self.name = name or type(dp).__name__.replace("IterDataPipe", "")
self.childs = set()
self.parents = set()
def add_child(self, child):
self.childs.add(child)
child.parents.add(self)
def remove_child(self, child):
self.childs.remove(child)
child.parents.remove(self)
def add_parent(self, parent):
self.parents.add(parent)
parent.childs.add(self)
def remove_parent(self, parent):
self.parents.remove(parent)
parent.childs.remove(self)
def __eq__(self, other):
if not isinstance(other, Node):
return NotImplemented
return hash(self) == hash(other)
def __hash__(self):
return hash(self.dp)
def __str__(self):
return self.name
def __repr__(self):
return f"{self}-{hash(self)}"
def to_nodes(dp, *, debug: bool) -> Set[Node]:
def recurse(dp_graph, child=None):
for _dp_id, (dp_node, dp_parents) in dp_graph.items():
node = Node(dp_node)
if child is not None:
node.add_child(child)
yield node
yield from recurse(dp_parents, child=node)
def aggregate(nodes):
groups = defaultdict(list)
for node in nodes:
groups[node].append(node)
nodes = set()
for node, group in groups.items():
if len(group) == 1:
nodes.add(node)
continue
aggregated_node = Node(node.dp)
for duplicate_node in group:
for child in duplicate_node.childs.copy():
duplicate_node.remove_child(child)
aggregated_node.add_child(child)
for parent in duplicate_node.parents.copy():
duplicate_node.remove_parent(parent)
aggregated_node.add_parent(parent)
nodes.add(aggregated_node)
if debug:
return nodes
child_dp_nodes = set(
itertools.chain.from_iterable(
node.parents
for node in nodes
if isinstance(node.dp, _ChildDataPipe)
)
)
if not child_dp_nodes:
return nodes
for node in child_dp_nodes:
fixed_parent_node = Node(
type(
str(node).lstrip("_"),
(IterDataPipe,),
dict(dp=node.dp, childs=node.childs),
)()
)
nodes.remove(node)
nodes.add(fixed_parent_node)
for parent in node.parents.copy():
node.remove_parent(parent)
fixed_parent_node.add_parent(parent)
for child in node.childs:
nodes.remove(child)
for actual_child in child.childs.copy():
actual_child.remove_parent(child)
actual_child.add_parent(fixed_parent_node)
return nodes
return aggregate(recurse(traverse_dps(dp)))
def to_graph(dp, *, debug: bool = False) -> "graphviz.Digraph":
"""Visualizes a DataPipe by returning a :class:`graphviz.Digraph`, which is a graph of the data pipeline.
This allows you to visually inspect all the transformation that takes place in your DataPipes.
.. note::
The package :mod:`graphviz` is required to use this function.
.. note::
The most common interfaces for the returned graph object are:
- :meth:`~graphviz.Digraph.render`: Save the graph to a file.
- :meth:`~graphviz.Digraph.view`: Open the graph in a viewer.
Args:
dp: DataPipe that you would like to visualize (generally the last one in a chain of DataPipes).
debug (bool): If ``True``, renders internal datapipes that are usually hidden from the user
(such as ``ChildDataPipe`` of `demux` and `fork`). Defaults to ``False``.
Example:
>>> from torchdata.datapipes.iter import IterableWrapper
>>> from torchdata.datapipes.utils import to_graph
>>> dp = IterableWrapper(range(10))
>>> dp1, dp2 = dp.demux(num_instances=2, classifier_fn=lambda x: x % 2)
>>> dp1 = dp1.map(lambda x: x + 1)
>>> dp2 = dp2.filter(lambda _: True)
>>> dp3 = dp1.zip(dp2).map(lambda t: t[0] + t[1])
>>> g = to_graph(dp3)
>>> g.view() # This will open the graph in a viewer
"""
try:
import graphviz
except ModuleNotFoundError:
raise ModuleNotFoundError(
"The package `graphviz` is required to be installed to use this function. "
"Please `pip install graphviz` or `conda install -c conda-forge graphviz`."
) from None
# The graph style as well as the color scheme below was copied from https://github.com/szagoruyko/pytorchviz/
# https://github.com/szagoruyko/pytorchviz/blob/0adcd83af8aa7ab36d6afd139cabbd9df598edb7/torchviz/dot.py#L78-L85
node_attr = dict(
style="filled",
shape="box",
align="left",
fontsize="10",
ranksep="0.1",
height="0.2",
fontname="monospace",
)
graph = graphviz.Digraph(node_attr=node_attr, graph_attr=dict(size="12,12"))
for node in to_nodes(dp, debug=debug):
fillcolor: Optional[str]
if not node.parents:
fillcolor = "lightblue"
elif not node.childs:
fillcolor = "darkolivegreen1"
else:
fillcolor = None
graph.node(name=repr(node), label=str(node), fillcolor=fillcolor)
for child in node.childs:
graph.edge(repr(node), repr(child))
return graph
+95
View File
@@ -0,0 +1,95 @@
"""GraphBolt Dataset."""
from typing import Dict, List, Union
from .feature_store import FeatureStore
from .itemset import HeteroItemSet, ItemSet
from .sampling_graph import SamplingGraph
__all__ = [
"Task",
"Dataset",
]
class Task:
"""An abstract task which consists of meta information and
Train/Validation/Test Set.
* meta information
The meta information of a task includes any kinds of data that are
defined by the user in YAML when instantiating the task.
* Train/Validation/Test Set
The train/validation/test (TVT) set which is used to train the neural
networks. We calculate the embeddings based on their respective features
and the graph structure, and then utilize the embeddings to optimize the
neural network parameters.
"""
@property
def metadata(self) -> Dict:
"""Return the task metadata."""
raise NotImplementedError
@property
def train_set(self) -> Union[ItemSet, HeteroItemSet]:
"""Return the training set."""
raise NotImplementedError
@property
def validation_set(self) -> Union[ItemSet, HeteroItemSet]:
"""Return the validation set."""
raise NotImplementedError
@property
def test_set(self) -> Union[ItemSet, HeteroItemSet]:
"""Return the test set."""
raise NotImplementedError
class Dataset:
"""An abstract dataset which provides abstraction for accessing the data
required for training.
The data abstraction could be a native CPU memory block, a shared memory
block, a file handle of an opened file on disk, a service that provides
the API to access the data e.t.c. There are 3 primary components in the
dataset:
* Task
A task consists of several meta information and the
Train/Validation/Test Set. A dataset could have multiple tasks.
* Feature Storage
A key-value store which stores node/edge/graph features.
* Graph Topology
Graph topology is used by the subgraph sampling algorithm to generate
a subgraph.
"""
@property
def tasks(self) -> List[Task]:
"""Return the tasks."""
raise NotImplementedError
@property
def graph(self) -> SamplingGraph:
"""Return the graph."""
raise NotImplementedError
@property
def feature(self) -> FeatureStore:
"""Return the feature."""
raise NotImplementedError
@property
def dataset_name(self) -> str:
"""Return the dataset name."""
raise NotImplementedError
@property
def all_nodes_set(self) -> Union[ItemSet, HeteroItemSet]:
"""Return the itemset containing all nodes."""
raise NotImplementedError
+159
View File
@@ -0,0 +1,159 @@
"""Utility functions for external use."""
from functools import partial
from typing import Dict, Union
import torch
from torch.utils.data import functional_datapipe
from .minibatch import MiniBatch
from .minibatch_transformer import MiniBatchTransformer
@functional_datapipe("exclude_seed_edges")
class SeedEdgesExcluder(MiniBatchTransformer):
"""A mini-batch transformer used to manipulate mini-batch.
Functional name: :obj:`transform`.
Parameters
----------
datapipe : DataPipe
The datapipe.
include_reverse_edges : bool
Whether reverse edges should be excluded as well. Default is False.
reverse_etypes_mapping : Dict[str, str] = None
The mapping from the original edge types to their reverse edge types.
asynchronous: bool
Boolean indicating whether edge exclusion stages should run on
background threads to hide the latency of CPU GPU synchronization.
Should be enabled only when sampling on the GPU.
"""
def __init__(
self,
datapipe,
include_reverse_edges: bool = False,
reverse_etypes_mapping: Dict[str, str] = None,
asynchronous=False,
):
exclude_seed_edges_fn = partial(
exclude_seed_edges,
include_reverse_edges=include_reverse_edges,
reverse_etypes_mapping=reverse_etypes_mapping,
async_op=asynchronous,
)
datapipe = datapipe.transform(exclude_seed_edges_fn)
if asynchronous:
datapipe = datapipe.buffer()
datapipe = datapipe.transform(self._wait_for_sampled_subgraphs)
super().__init__(datapipe)
@staticmethod
def _wait_for_sampled_subgraphs(minibatch):
minibatch.sampled_subgraphs = [
subgraph.wait() for subgraph in minibatch.sampled_subgraphs
]
return minibatch
def add_reverse_edges(
edges: Union[Dict[str, torch.Tensor], torch.Tensor],
reverse_etypes_mapping: Dict[str, str] = None,
):
r"""
This function finds the reverse edges of the given `edges` and returns the
composition of them. In a homogeneous graph, reverse edges have inverted
source and destination node IDs. While in a heterogeneous graph, reversing
also involves swapping node IDs and their types. This function could be
used before `exclude_edges` function to help find targeting edges.
Note: The found reverse edges may not really exists in the original graph.
And repeat edges could be added becasue reverse edges may already exists in
the `edges`.
Parameters
----------
edges : Union[Dict[str, torch.Tensor], torch.Tensor]
- If sampled subgraph is homogeneous, then `edges` should be a N*2
tensors.
- If sampled subgraph is heterogeneous, then `edges` should be a
dictionary of edge types and the corresponding edges to exclude.
reverse_etypes_mapping : Dict[str, str], optional
The mapping from the original edge types to their reverse edge types.
Returns
-------
Union[Dict[str, torch.Tensor], torch.Tensor]
The node pairs contain both the original edges and their reverse
counterparts.
Examples
--------
>>> edges = {"A:r:B": torch.tensor([[0, 1],[1, 2]]))}
>>> print(gb.add_reverse_edges(edges, {"A:r:B": "B:rr:A"}))
{'A:r:B': torch.tensor([[0, 1],[1, 2]]),
'B:rr:A': torch.tensor([[1, 0],[2, 1]])}
>>> edges = torch.tensor([[0, 1],[1, 2]])
>>> print(gb.add_reverse_edges(edges))
torch.tensor([[1, 0],[2, 1]])
"""
if isinstance(edges, torch.Tensor):
assert edges.ndim == 2 and edges.shape[1] == 2, (
"Only tensor with shape N*2 is supported now, but got "
+ f"{edges.shape}."
)
reverse_edges = edges.flip(dims=(1,))
return torch.cat((edges, reverse_edges))
else:
combined_edges = edges.copy()
for etype, reverse_etype in reverse_etypes_mapping.items():
if etype in edges:
assert edges[etype].ndim == 2 and edges[etype].shape[1] == 2, (
"Only tensor with shape N*2 is supported now, but got "
+ f"{edges[etype].shape}."
)
if reverse_etype in combined_edges:
combined_edges[reverse_etype] = torch.cat(
(
combined_edges[reverse_etype],
edges[etype].flip(dims=(1,)),
)
)
else:
combined_edges[reverse_etype] = edges[etype].flip(dims=(1,))
return combined_edges
def exclude_seed_edges(
minibatch: MiniBatch,
include_reverse_edges: bool = False,
reverse_etypes_mapping: Dict[str, str] = None,
async_op: bool = False,
):
"""
Exclude seed edges with or without their reverse edges from the sampled
subgraphs in the minibatch.
Parameters
----------
minibatch : MiniBatch
The minibatch.
include_reverse_edges : bool
Whether reverse edges should be excluded as well. Default is False.
reverse_etypes_mapping : Dict[str, str] = None
The mapping from the original edge types to their reverse edge types.
async_op: bool
Boolean indicating whether the call is asynchronous. If so, the result
can be obtained by calling wait on the modified sampled_subgraphs.
"""
edges_to_exclude = minibatch.seeds
if include_reverse_edges:
edges_to_exclude = add_reverse_edges(
edges_to_exclude, reverse_etypes_mapping
)
minibatch.sampled_subgraphs = [
subgraph.exclude_edges(edges_to_exclude, async_op=async_op)
for subgraph in minibatch.sampled_subgraphs
]
return minibatch
+282
View File
@@ -0,0 +1,282 @@
"""Feature fetchers"""
from functools import partial
from typing import Dict
import torch
from torch.utils.data import functional_datapipe
from .base import etype_tuple_to_str
from .impl.cooperative_conv import CooperativeConvFunction
from .minibatch_transformer import MiniBatchTransformer
__all__ = [
"FeatureFetcher",
"FeatureFetcherStartMarker",
]
def get_feature_key_list(feature_keys, domain):
"""Processes node_feature_keys and extracts their feature keys to a list."""
if isinstance(feature_keys, Dict):
return [
(domain, type_name, feature_name)
for type_name, feature_names in feature_keys.items()
for feature_name in feature_names
]
elif feature_keys is not None:
return [(domain, None, feature_name) for feature_name in feature_keys]
else:
return []
@functional_datapipe("mark_feature_fetcher_start")
class FeatureFetcherStartMarker(MiniBatchTransformer):
"""Used to mark the start of a FeatureFetcher and is a no-op. All the
datapipes created during a FeatureFetcher instantiation are guarenteed to be
contained between FeatureFetcherStartMarker and FeatureFetcher instances in
the datapipe graph.
"""
def __init__(self, datapipe):
super().__init__(datapipe, self._identity)
@functional_datapipe("fetch_feature")
class FeatureFetcher(MiniBatchTransformer):
"""A feature fetcher used to fetch features for node/edge in graphbolt.
Functional name: :obj:`fetch_feature`.
Parameters
----------
datapipe : DataPipe
The datapipe.
feature_store : FeatureStore
A storage for features, support read and update.
node_feature_keys : List[str] or Dict[str, List[str]]
Node features keys indicates the node features need to be read.
- If `node_features` is a list: It means the graph is homogeneous
graph, and the 'str' inside are feature names.
- If `node_features` is a dictionary: The keys should be node type
and the values are lists of feature names.
edge_feature_keys : List[str] or Dict[str, List[str]]
Edge features name indicates the edge features need to be read.
- If `edge_features` is a list: It means the graph is homogeneous
graph, and the 'str' inside are feature names.
- If `edge_features` is a dictionary: The keys are edge types,
following the format 'str:str:str', and the values are lists of
feature names.
overlap_fetch : bool, optional
If True, the feature fetcher will overlap the UVA feature fetcher
operations with the rest of operations by using an alternative CUDA
stream or utilizing asynchronous operations. Default is True.
cooperative: bool, optional
Boolean indicating whether 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.
"""
def __init__(
self,
datapipe,
feature_store,
node_feature_keys=None,
edge_feature_keys=None,
overlap_fetch=True,
cooperative=False,
):
datapipe = datapipe.mark_feature_fetcher_start()
self.feature_store = feature_store
self.node_feature_keys = node_feature_keys
self.edge_feature_keys = edge_feature_keys
max_val = 0
if overlap_fetch:
for feature_key_list in [
get_feature_key_list(node_feature_keys, "node"),
get_feature_key_list(edge_feature_keys, "edge"),
]:
for feature_key in feature_key_list:
if feature_key not in feature_store:
continue
for device_str in ["cpu", "cuda"]:
try:
max_val = max(
feature_store[
feature_key
].read_async_num_stages(
torch.device(device_str)
),
max_val,
)
except AssertionError:
pass
datapipe = datapipe.transform(self._read)
for i in range(max_val, 0, -1):
datapipe = datapipe.transform(
partial(self._execute_stage, i)
).buffer(1)
if max_val > 0:
datapipe = datapipe.transform(self._final_stage)
if cooperative:
datapipe = datapipe.transform(self._cooperative_exchange)
datapipe = datapipe.buffer()
super().__init__(datapipe)
# A positive value indicates that the overlap optimization is enabled.
self.max_num_stages = max_val
@staticmethod
def _execute_stage(current_stage, data):
all_features = [data.node_features] + [
data.edge_features[i] for i in range(data.num_layers())
]
for features in all_features:
for key in features:
handle, stage = features[key]
assert current_stage >= stage
if current_stage == stage:
value = next(handle)
features[key] = (handle if stage > 1 else value, stage - 1)
return data
@staticmethod
def _final_stage(data):
all_features = [data.node_features] + [
data.edge_features[i] for i in range(data.num_layers())
]
for features in all_features:
for key in features:
value, stage = features[key]
assert stage == 0
features[key] = value.wait()
return data
def _cooperative_exchange(self, data):
subgraph = data.sampled_subgraphs[0]
is_heterogeneous = isinstance(
self.node_feature_keys, Dict
) or isinstance(self.edge_feature_keys, Dict)
if is_heterogeneous:
node_features = {key: {} for key, _ in data.node_features.keys()}
for (key, ntype), feature in data.node_features.items():
node_features[key][ntype] = feature
for key, feature in node_features.items():
new_feature = CooperativeConvFunction.apply(subgraph, feature)
for ntype, tensor in new_feature.items():
data.node_features[(key, ntype)] = tensor
else:
for key in data.node_features:
feature = data.node_features[key]
new_feature = CooperativeConvFunction.apply(subgraph, feature)
data.node_features[key] = new_feature
return data
def _read(self, data):
"""
Fill in the node/edge features field in data.
Parameters
----------
data : MiniBatch
An instance of :class:`MiniBatch`. Even if 'node_feature' or
'edge_feature' is already filled, it will be overwritten for
overlapping features.
Returns
-------
MiniBatch
An instance of :class:`MiniBatch` filled with required features.
"""
node_features = {}
num_layers = data.num_layers()
edge_features = [{} for _ in range(num_layers)]
is_heterogeneous = isinstance(
self.node_feature_keys, Dict
) or isinstance(self.edge_feature_keys, Dict)
# Read Node features.
input_nodes = data.node_ids()
def read_helper(feature_key, index):
if self.max_num_stages > 0:
feature = self.feature_store[feature_key]
num_stages = feature.read_async_num_stages(index.device)
if num_stages > 0:
return (feature.read_async(index), num_stages)
else: # Asynchronicity is not needed, compute in _final_stage.
class _Waiter:
def __init__(self, feature, index):
self.feature = feature
self.index = index
def wait(self):
"""Returns the stored value when invoked."""
result = self.feature.read(self.index)
# Ensure there is no memory leak.
self.feature = self.index = None
return result
return (_Waiter(feature, index), 0)
else:
domain, type_name, feature_name = feature_key
return self.feature_store.read(
domain, type_name, feature_name, index
)
if self.node_feature_keys and input_nodes is not None:
if is_heterogeneous:
for type_name, nodes in input_nodes.items():
if type_name not in self.node_feature_keys or nodes is None:
continue
for feature_name in self.node_feature_keys[type_name]:
node_features[(type_name, feature_name)] = read_helper(
("node", type_name, feature_name), nodes
)
else:
for feature_name in self.node_feature_keys:
node_features[feature_name] = read_helper(
("node", None, feature_name), input_nodes
)
# Read Edge features.
if self.edge_feature_keys and num_layers > 0:
for i in range(num_layers):
original_edge_ids = data.edge_ids(i)
if is_heterogeneous:
# Convert edge type to string.
original_edge_ids = {
(
etype_tuple_to_str(key)
if isinstance(key, tuple)
else key
): value
for key, value in original_edge_ids.items()
}
for type_name, edges in original_edge_ids.items():
if (
type_name not in self.edge_feature_keys
or edges is None
):
continue
for feature_name in self.edge_feature_keys[type_name]:
edge_features[i][
(type_name, feature_name)
] = read_helper(
("edge", type_name, feature_name), edges
)
else:
for feature_name in self.edge_feature_keys:
edge_features[i][feature_name] = read_helper(
("edge", None, feature_name), original_edge_ids
)
data.set_node_features(node_features)
data.set_edge_features(edge_features)
return data
+351
View File
@@ -0,0 +1,351 @@
"""Feature store for GraphBolt."""
from typing import Dict, NamedTuple, Union
import torch
__all__ = [
"bytes_to_number_of_items",
"Feature",
"FeatureStore",
"FeatureKey",
"wrap_with_cached_feature",
]
class FeatureKey(NamedTuple):
"""A named tuple class to represent feature keys in FeatureStore classes.
The fields are domain, type and name all of which take string values.
"""
domain: str
type: str
name: int
class Feature:
r"""A wrapper of feature data for access."""
def __init__(self):
pass
def read(self, ids: torch.Tensor = None):
"""Read from the feature.
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.
"""
raise NotImplementedError
def read_async(self, ids: torch.Tensor):
"""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.
Example Usage
--------
>>> 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.
"""
raise NotImplementedError
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.
"""
raise NotImplementedError
def size(self):
"""Get the size of the feature.
Returns
-------
torch.Size
The size of the feature.
"""
raise NotImplementedError
def count(self):
"""Get the count of the feature.
Returns
-------
int
The count of the feature.
"""
raise NotImplementedError
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.
"""
raise NotImplementedError
def metadata(self):
"""Get the metadata of the feature.
Returns
-------
Dict
The metadata of the feature.
"""
return {}
class FeatureStore:
r"""A store to manage multiple features for access."""
def __init__(self):
pass
def __getitem__(self, feature_key: FeatureKey) -> Feature:
"""Access the underlying `Feature` with its (domain, type, name) as
the feature_key.
"""
raise NotImplementedError
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.
"""
raise NotImplementedError
def __contains__(self, feature_key: FeatureKey) -> bool:
"""Checks whether the provided (domain, type, name) as the feature_key
is container in the FeatureStore."""
raise NotImplementedError
def read(
self,
domain: str,
type_name: str,
feature_name: str,
ids: torch.Tensor = None,
):
"""Read from the feature store.
Parameters
----------
domain : str
The domain of the feature such as "node", "edge" or "graph".
type_name : str
The node or edge type name.
feature_name : str
The feature name.
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.
"""
return self.__getitem__((domain, type_name, feature_name)).read(ids)
def size(
self,
domain: str,
type_name: str,
feature_name: str,
):
"""Get the size of the specified feature in the feature store.
Parameters
----------
domain : str
The domain of the feature such as "node", "edge" or "graph".
type_name : str
The node or edge type name.
feature_name : str
The feature name.
Returns
-------
torch.Size
The size of the specified feature in the feature store.
"""
return self.__getitem__((domain, type_name, feature_name)).size()
def count(
self,
domain: str,
type_name: str,
feature_name: str,
):
"""Get the count the specified feature in the feature store.
Parameters
----------
domain : str
The domain of the feature such as "node", "edge" or "graph".
type_name : str
The node or edge type name.
feature_name : str
The feature name.
Returns
-------
int
The count of the specified feature in the feature store.
"""
return self.__getitem__((domain, type_name, feature_name)).count()
def metadata(
self,
domain: str,
type_name: str,
feature_name: str,
):
"""Get the metadata of the specified feature in the feature store.
Parameters
----------
domain : str
The domain of the feature such as "node", "edge" or "graph".
type_name : str
The node or edge type name.
feature_name : str
The feature name.
Returns
-------
Dict
The metadata of the feature.
"""
return self.__getitem__((domain, type_name, feature_name)).metadata()
def update(
self,
domain: str,
type_name: str,
feature_name: str,
value: torch.Tensor,
ids: torch.Tensor = None,
):
"""Update the feature store.
Parameters
----------
domain : str
The domain of the feature such as "node", "edge" or "graph".
type_name : str
The node or edge type name.
feature_name : str
The feature name.
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.
"""
self.__getitem__((domain, type_name, feature_name)).update(value, ids)
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.
"""
raise NotImplementedError
def bytes_to_number_of_items(cache_capacity_in_bytes, single_item):
"""Returns the number of rows to be cached."""
item_bytes = single_item.nbytes
# Round up so that we never get a size of 0, unless bytes is 0.
return (cache_capacity_in_bytes + item_bytes - 1) // item_bytes
def wrap_with_cached_feature(
cached_feature_type,
fallback_features: Union[Feature, Dict[FeatureKey, Feature]],
max_cache_size_in_bytes: int,
*args,
**kwargs,
) -> Union[Feature, Dict[FeatureKey, Feature]]:
"""Wraps the given features with the given cached feature type using
a single cache instance."""
if not isinstance(fallback_features, dict):
assert isinstance(fallback_features, Feature)
return wrap_with_cached_feature(
cached_feature_type,
{"a": fallback_features},
max_cache_size_in_bytes,
*args,
**kwargs,
)["a"]
row_bytes = None
cache = None
wrapped_features = {}
offset = 0
for feature_key, fallback_feature in fallback_features.items():
# Fetching the feature dimension from the underlying feature.
feat0 = fallback_feature.read(torch.tensor([0]))
if row_bytes is None:
row_bytes = feat0.nbytes
else:
assert (
row_bytes == feat0.nbytes
), "The # bytes of a single row of the features should match."
cache_size = bytes_to_number_of_items(max_cache_size_in_bytes, feat0)
if cache is None:
cache = cached_feature_type._cache_type(
cache_shape=(cache_size,) + feat0.shape[1:],
dtype=feat0.dtype,
*args,
**kwargs,
)
wrapped_features[feature_key] = cached_feature_type(
fallback_feature, cache=cache, offset=offset
)
offset += fallback_feature.count()
return wrapped_features
+18
View File
@@ -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])
+158
View File
@@ -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
@@ -0,0 +1,4 @@
"""Utility functions for GraphBolt."""
from .utils import *
from .sample_utils import *
from .item_sampler_utils import *
@@ -0,0 +1,112 @@
"""Utility functions for DistributedItemSampler."""
def count_split(total, num_workers, worker_id, batch_size=1):
"""Calculate the number of assigned items after splitting them by batch
size evenly. It will return the number for this worker and also a sum of
previous workers.
"""
quotient, remainder = divmod(total, num_workers * batch_size)
if batch_size == 1:
assigned = quotient + (worker_id < remainder)
else:
batch_count, last_batch = divmod(remainder, batch_size)
assigned = quotient * batch_size + (
batch_size
if worker_id < batch_count
else (last_batch if worker_id == batch_count else 0)
)
prefix_sum = quotient * worker_id * batch_size + min(
worker_id * batch_size, remainder
)
return (assigned, prefix_sum)
def calculate_range(
distributed,
total,
num_replicas,
rank,
num_workers,
worker_id,
batch_size,
drop_last,
drop_uneven_inputs,
):
"""Calculates the range of items to be assigned to the current worker.
This function evenly distributes `total` items among multiple workers,
batching them using `batch_size`. Each replica has `num_workers` workers.
The batches generated by workers within the same replica are combined into
the replica`s output. The `drop_last` parameter determines whether
incomplete batches should be dropped. If `drop_last` is True, incomplete
batches are discarded. The `drop_uneven_inputs` parameter determines if the
number of batches assigned to each replica should be the same. If
`drop_uneven_inputs` is True, excessive batches for some replicas will be
dropped.
Args:
distributed (bool): Whether it's in distributed mode.
total (int): The total number of items.
num_replicas (int): The total number of replicas.
rank (int): The rank of the current replica.
num_workers (int): The number of workers per replica.
worker_id (int): The ID of the current worker.
batch_size (int): The desired batch size.
drop_last (bool): Whether to drop incomplete batches.
drop_uneven_inputs (bool): Whether to drop excessive batches for some
replicas.
Returns:
tuple: A tuple containing three numbers:
- start_offset (int): The starting offset of the range assigned to
the current worker.
- assigned_count (int): The length of the range assigned to the
current worker.
- output_count (int): The number of items that the current worker
will produce after dropping.
"""
# Check if it's distributed mode.
if not distributed:
if not drop_last:
return (0, total, total)
else:
return (0, total, total // batch_size * batch_size)
# First, equally distribute items into all replicas.
assigned_count, start_offset = count_split(
total, num_replicas, rank, batch_size
)
# Calculate the number of outputs when drop_uneven_inputs is True.
# `assigned_count` is the number of items distributed to the current
# process. `output_count` is the number of items should be output
# by this process after dropping.
if not drop_uneven_inputs:
if not drop_last:
output_count = assigned_count
else:
output_count = assigned_count // batch_size * batch_size
else:
if not drop_last:
min_item_count, _ = count_split(
total, num_replicas, num_replicas - 1, batch_size
)
min_batch_count = (min_item_count + batch_size - 1) // batch_size
output_count = min(min_batch_count * batch_size, assigned_count)
else:
output_count = total // (batch_size * num_replicas) * batch_size
# If there are multiple workers, equally distribute the batches to
# all workers.
if num_workers > 1:
# Equally distribute the dropped number too.
dropped_items, prev_dropped_items = count_split(
assigned_count - output_count, num_workers, worker_id
)
output_count, prev_output_count = count_split(
output_count,
num_workers,
worker_id,
batch_size,
)
assigned_count = output_count + dropped_items
start_offset += prev_output_count + prev_dropped_items
return (start_offset, assigned_count, output_count)
@@ -0,0 +1,568 @@
"""Utility functions for sampling."""
from collections import defaultdict
from typing import Dict, List, Optional, Tuple, Union
import torch
from ..base import CSCFormatBase, etype_str_to_tuple, expand_indptr
def unique_and_compact(
nodes: Union[
List[torch.Tensor],
Dict[str, List[torch.Tensor]],
],
rank: int = 0,
world_size: int = 1,
async_op: bool = False,
):
"""
Compact a list of nodes tensor. The `rank` and `world_size` parameters are
relevant when using 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.
When `world_size` is greater than 1, then the given ids are partitioned
between the available ranks. The ids corresponding to the given rank are
guaranteed to come before the ids of other ranks. To do this, the
partitioned ids are rotated backwards by the given rank so that the ids are
ordered as: `[rank, rank + 1, world_size, 0, ..., rank - 1]`. This is
supported only for Volta and later generation NVIDIA GPUs.
Parameters
----------
nodes : List[torch.Tensor] or Dict[str, List[torch.Tensor]]
List of nodes for compacting.
the unique_and_compact will be done per type
- If `nodes` is a list of tensor: All the tensors will do unique and
compact together, usually it is used for homogeneous graph.
- If `nodes` is a list of dictionary: The keys should be node type and
the values should be corresponding nodes, the unique and compact will
be done per type, usually it is used for heterogeneous graph.
rank : int
The rank of the current process.
world_size : int
The number of processes.
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[unique_nodes, compacted_node_list, unique_nodes_offsets]
The Unique nodes (per type) of all nodes in the input. And the compacted
nodes list, where IDs inside are replaced with compacted node IDs.
"Compacted node list" indicates that the node IDs in the input node
list are replaced with mapped node IDs, where each type of node is
mapped to a contiguous space of IDs ranging from 0 to N.
The unique nodes offsets tensor partitions the unique_nodes tensor. Has
size `world_size + 1` and `unique_nodes[offsets[i]: offsets[i + 1]]`
belongs to the rank `(rank + i) % world_size`.
"""
is_heterogeneous = isinstance(nodes, dict)
if not is_heterogeneous:
homo_ntype = "a"
nodes = {homo_ntype: nodes}
nums = {}
concat_nodes, empties = [], []
for ntype, nodes_of_type in nodes.items():
nums[ntype] = [node.size(0) for node in nodes_of_type]
concat_nodes.append(torch.cat(nodes_of_type))
empties.append(concat_nodes[-1].new_empty(0))
unique_fn = (
torch.ops.graphbolt.unique_and_compact_batched_async
if async_op
else torch.ops.graphbolt.unique_and_compact_batched
)
results = unique_fn(concat_nodes, empties, empties, rank, world_size)
class _Waiter:
def __init__(self, future, ntypes, nums):
self.future = future
self.ntypes = ntypes
self.nums = nums
def wait(self):
"""Returns the stored value when invoked."""
results = self.future.wait() if async_op else self.future
ntypes = self.ntypes
nums = self.nums
# Ensure there is no memory leak.
self.future = self.ntypes = self.nums = None
unique, compacted, offsets = {}, {}, {}
for ntype, result in zip(ntypes, results):
(
unique[ntype],
concat_compacted,
_,
offsets[ntype],
) = result
compacted[ntype] = list(concat_compacted.split(nums[ntype]))
if is_heterogeneous:
return unique, compacted, offsets
else:
return (
unique[homo_ntype],
compacted[homo_ntype],
offsets[homo_ntype],
)
post_processer = _Waiter(results, nodes.keys(), nums)
if async_op:
return post_processer
else:
return post_processer.wait()
def compact_temporal_nodes(nodes, nodes_timestamp):
"""Compact a list of temporal nodes without unique.
Note that since there is no unique, the nodes and nodes_timestamp are simply
concatenated. And the compacted nodes are consecutive numbers starting from
0.
Parameters
----------
nodes : List[torch.Tensor] or Dict[str, List[torch.Tensor]]
List of nodes for compacting.
the compact operator will be done per type
- If `nodes` is a list of tensor: All the tensors will compact together,
usually it is used for homogeneous graph.
- If `nodes` is a list of dictionary: The keys should be node type and
the values should be corresponding nodes, the compact will be done per
type, usually it is used for heterogeneous graph.
nodes_timestamp : List[torch.Tensor] or Dict[str, List[torch.Tensor]]
List of timestamps for compacting.
Returns
-------
Tuple[nodes, nodes_timestamp, compacted_node_list]
The concatenated nodes and nodes_timestamp, and the compacted nodes list,
where IDs inside are replaced with compacted node IDs.
"""
def _compact_per_type(per_type_nodes, per_type_nodes_timestamp):
nums = [node.size(0) for node in per_type_nodes]
per_type_nodes = torch.cat(per_type_nodes)
per_type_nodes_timestamp = torch.cat(per_type_nodes_timestamp)
compacted_nodes = torch.arange(
0,
per_type_nodes.numel(),
dtype=per_type_nodes.dtype,
device=per_type_nodes.device,
)
compacted_nodes = list(compacted_nodes.split(nums))
return per_type_nodes, per_type_nodes_timestamp, compacted_nodes
if isinstance(nodes, dict):
ret_nodes, ret_timestamp, compacted = {}, {}, {}
for ntype, nodes_of_type in nodes.items():
(
ret_nodes[ntype],
ret_timestamp[ntype],
compacted[ntype],
) = _compact_per_type(nodes_of_type, nodes_timestamp[ntype])
return ret_nodes, ret_timestamp, compacted
else:
return _compact_per_type(nodes, nodes_timestamp)
def unique_and_compact_csc_formats(
csc_formats: Union[
Tuple[torch.Tensor, torch.Tensor],
Dict[str, Tuple[torch.Tensor, torch.Tensor]],
],
unique_dst_nodes: Union[
torch.Tensor,
Dict[str, torch.Tensor],
],
rank: int = 0,
world_size: int = 1,
async_op: bool = False,
):
"""
Compact csc formats and return unique nodes (per type). The `rank` and
`world_size` parameters are relevant when using 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.
When `world_size` is greater than 1, then the given ids are partitioned
between the available ranks. The ids corresponding to the given rank are
guaranteed to come before the ids of other ranks. To do this, the
partitioned ids are rotated backwards by the given rank so that the ids are
ordered as: `[rank, rank + 1, world_size, 0, ..., rank - 1]`. This is
supported only for Volta and later generation NVIDIA GPUs.
Parameters
----------
csc_formats : Union[CSCFormatBase, Dict(str, CSCFormatBase)]
CSC formats representing source-destination edges.
- If `csc_formats` is a CSCFormatBase: It means the graph is
homogeneous. Also, indptr and indice in it should be torch.tensor
representing source and destination pairs in csc format. And IDs inside
are homogeneous ids.
- If `csc_formats` is a Dict[str, CSCFormatBase]: The keys
should be edge type and the values should be csc format node pairs.
And IDs inside are heterogeneous ids.
unique_dst_nodes: torch.Tensor or Dict[str, torch.Tensor]
Unique nodes of all destination nodes in the node pairs.
- If `unique_dst_nodes` is a tensor: It means the graph is homogeneous.
- If `csc_formats` is a dictionary: The keys are node type and the
values are corresponding nodes. And IDs inside are heterogeneous ids.
rank : int
The rank of the current process.
world_size : int
The number of processes.
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[unique_nodes, csc_formats, unique_nodes_offsets]
The compacted csc formats, where node IDs are replaced with mapped node
IDs, and the unique nodes (per type).
"Compacted csc formats" indicates that the node IDs in the input node
pairs are replaced with mapped node IDs, where each type of node is
mapped to a contiguous space of IDs ranging from 0 to N. The unique
nodes offsets tensor partitions the unique_nodes tensor. Has size
`world_size + 1` and `unique_nodes[offsets[i]: offsets[i + 1]]` belongs
to the rank `(rank + i) % world_size`.
Examples
--------
>>> import dgl.graphbolt as gb
>>> N1 = torch.LongTensor([1, 2, 2])
>>> N2 = torch.LongTensor([5, 5, 6])
>>> unique_dst = {
... "n1": torch.LongTensor([1, 2]),
... "n2": torch.LongTensor([5, 6])}
>>> csc_formats = {
... "n1:e1:n2": gb.CSCFormatBase(indptr=torch.tensor([0, 2, 3]),indices=N1),
... "n2:e2:n1": gb.CSCFormatBase(indptr=torch.tensor([0, 1, 3]),indices=N2)}
>>> unique_nodes, compacted_csc_formats, _ = gb.unique_and_compact_csc_formats(
... csc_formats, unique_dst
... )
>>> print(unique_nodes)
{'n1': tensor([1, 2]), 'n2': tensor([5, 6])}
>>> print(compacted_csc_formats)
{"n1:e1:n2": CSCFormatBase(indptr=torch.tensor([0, 2, 3]),
indices=torch.tensor([0, 1, 1])),
"n2:e2:n1": CSCFormatBase(indptr=torch.tensor([0, 1, 3]),
indices=torch.Longtensor([0, 0, 1]))}
"""
is_homogeneous = not isinstance(csc_formats, dict)
if is_homogeneous:
csc_formats = {"_N:_E:_N": csc_formats}
if unique_dst_nodes is not None:
assert isinstance(
unique_dst_nodes, torch.Tensor
), "Edge type not supported in homogeneous graph."
unique_dst_nodes = {"_N": unique_dst_nodes}
# Collect all source and destination nodes for each node type.
indices = defaultdict(list)
device = None
for etype, csc_format in csc_formats.items():
if device is None:
device = csc_format.indices.device
src_type, _, dst_type = etype_str_to_tuple(etype)
assert len(unique_dst_nodes.get(dst_type, [])) + 1 == len(
csc_format.indptr
), "The seed nodes should correspond to indptr."
indices[src_type].append(csc_format.indices)
indices = {ntype: torch.cat(nodes) for ntype, nodes in indices.items()}
ntypes = set(indices.keys())
dtype = list(indices.values())[0].dtype
default_tensor = torch.tensor([], dtype=dtype, device=device)
indice_list = []
unique_dst_list = []
for ntype in ntypes:
indice_list.append(indices.get(ntype, default_tensor))
unique_dst_list.append(unique_dst_nodes.get(ntype, default_tensor))
dst_list = [torch.tensor([], dtype=dtype, device=device)] * len(
unique_dst_list
)
uniq_fn = (
torch.ops.graphbolt.unique_and_compact_batched_async
if async_op
else torch.ops.graphbolt.unique_and_compact_batched
)
results = uniq_fn(indice_list, dst_list, unique_dst_list, rank, world_size)
class _Waiter:
def __init__(self, future, csc_formats):
self.future = future
self.csc_formats = csc_formats
def wait(self):
"""Returns the stored value when invoked."""
results = self.future.wait() if async_op else self.future
csc_formats = self.csc_formats
# Ensure there is no memory leak.
self.future = self.csc_formats = None
unique_nodes = {}
compacted_indices = {}
offsets = {}
for i, ntype in enumerate(ntypes):
(
unique_nodes[ntype],
compacted_indices[ntype],
_,
offsets[ntype],
) = results[i]
compacted_csc_formats = {}
# Map back with the same order.
for etype, csc_format in csc_formats.items():
num_elem = csc_format.indices.size(0)
src_type, _, _ = etype_str_to_tuple(etype)
indice = compacted_indices[src_type][:num_elem]
indptr = csc_format.indptr
compacted_csc_formats[etype] = CSCFormatBase(
indptr=indptr, indices=indice
)
compacted_indices[src_type] = compacted_indices[src_type][
num_elem:
]
# Return singleton for a homogeneous graph.
if is_homogeneous:
compacted_csc_formats = list(compacted_csc_formats.values())[0]
unique_nodes = list(unique_nodes.values())[0]
offsets = list(offsets.values())[0]
return unique_nodes, compacted_csc_formats, offsets
post_processer = _Waiter(results, csc_formats)
if async_op:
return post_processer
else:
return post_processer.wait()
def _broadcast_timestamps(csc, dst_timestamps):
"""Broadcast the timestamp of each destination node to its corresponding
source nodes."""
return expand_indptr(
csc.indptr, node_ids=dst_timestamps, output_size=len(csc.indices)
)
def compact_csc_format(
csc_formats: Union[CSCFormatBase, Dict[str, CSCFormatBase]],
dst_nodes: Union[torch.Tensor, Dict[str, torch.Tensor]],
dst_timestamps: Optional[
Union[torch.Tensor, Dict[str, torch.Tensor]]
] = None,
):
"""
Relabel the row (source) IDs in the csc formats into a contiguous range from
0 and return the original row node IDs per type.
Note that
1. The column (destination) IDs are included in the relabeled row IDs.
2. If there are repeated row IDs, they would not be uniqued and will be
treated as different nodes.
3. If `dst_timestamps` is given, the timestamp of each destination node will
be broadcasted to its corresponding source nodes.
Parameters
----------
csc_formats: Union[CSCFormatBase, Dict[str, CSCFormatBase]]
CSC formats representing source-destination edges.
- If `csc_formats` is a CSCFormatBase: It means the graph is
homogeneous. Also, indptr and indice in it should be torch.tensor
representing source and destination pairs in csc format. And IDs inside
are homogeneous ids.
- If `csc_formats` is a Dict[str, CSCFormatBase]: The keys
should be edge type and the values should be csc format node pairs.
And IDs inside are heterogeneous ids.
dst_nodes: Union[torch.Tensor, Dict[str, torch.Tensor]]
Nodes of all destination nodes in the node pairs.
- If `dst_nodes` is a tensor: It means the graph is homogeneous.
- If `dst_nodes` is a dictionary: The keys are node type and the
values are corresponding nodes. And IDs inside are heterogeneous ids.
dst_timestamps: Optional[Union[torch.Tensor, Dict[str, torch.Tensor]]]
Timestamps of all destination nodes in the csc formats.
If given, the timestamp of each destination node will be broadcasted
to its corresponding source nodes.
Returns
-------
Tuple[original_row_node_ids, compacted_csc_formats, ...]
A tensor of original row node IDs (per type) of all nodes in the input.
The compacted CSC formats, where node IDs are replaced with mapped node
IDs ranging from 0 to N.
The source timestamps (per type) of all nodes in the input if
`dst_timestamps` is given.
Examples
--------
>>> import dgl.graphbolt as gb
>>> csc_formats = {
... "n2:e2:n1": gb.CSCFormatBase(
... indptr=torch.tensor([0, 1, 3]), indices=torch.tensor([5, 4, 6])
... ),
... "n1:e1:n1": gb.CSCFormatBase(
... indptr=torch.tensor([0, 1, 3]), indices=torch.tensor([1, 2, 3])
... ),
... }
>>> dst_nodes = {"n1": torch.LongTensor([2, 4])}
>>> original_row_node_ids, compacted_csc_formats = gb.compact_csc_format(
... csc_formats, dst_nodes
... )
>>> original_row_node_ids
{'n1': tensor([2, 4, 1, 2, 3]), 'n2': tensor([5, 4, 6])}
>>> compacted_csc_formats
{'n2:e2:n1': CSCFormatBase(indptr=tensor([0, 1, 3]),
indices=tensor([0, 1, 2]),
), 'n1:e1:n1': CSCFormatBase(indptr=tensor([0, 1, 3]),
indices=tensor([2, 3, 4]),
)}
>>> csc_formats = {
... "n2:e2:n1": gb.CSCFormatBase(
... indptr=torch.tensor([0, 1, 3]), indices=torch.tensor([5, 4, 6])
... ),
... "n1:e1:n1": gb.CSCFormatBase(
... indptr=torch.tensor([0, 1, 3]), indices=torch.tensor([1, 2, 3])
... ),
... }
>>> dst_nodes = {"n1": torch.LongTensor([2, 4])}
>>> original_row_node_ids, compacted_csc_formats = gb.compact_csc_format(
... csc_formats, dst_nodes
... )
>>> original_row_node_ids
{'n1': tensor([2, 4, 1, 2, 3]), 'n2': tensor([5, 4, 6])}
>>> compacted_csc_formats
{'n2:e2:n1': CSCFormatBase(indptr=tensor([0, 1, 3]),
indices=tensor([0, 1, 2]),
), 'n1:e1:n1': CSCFormatBase(indptr=tensor([0, 1, 3]),
indices=tensor([2, 3, 4]),
)}
>>> dst_timestamps = {"n1": torch.LongTensor([10, 20])}
>>> (
... original_row_node_ids,
... compacted_csc_formats,
... src_timestamps,
... ) = gb.compact_csc_format(csc_formats, dst_nodes, dst_timestamps)
>>> src_timestamps
{'n1': tensor([10, 20, 10, 20, 20]), 'n2': tensor([10, 20, 20])}
"""
is_homogeneous = not isinstance(csc_formats, dict)
has_timestamp = dst_timestamps is not None
if is_homogeneous:
if dst_nodes is not None:
assert isinstance(
dst_nodes, torch.Tensor
), "Edge type not supported in homogeneous graph."
assert len(dst_nodes) + 1 == len(
csc_formats.indptr
), "The seed nodes should correspond to indptr."
offset = dst_nodes.size(0)
original_row_ids = torch.cat((dst_nodes, csc_formats.indices))
compacted_csc_formats = CSCFormatBase(
indptr=csc_formats.indptr,
indices=(
torch.arange(
0,
csc_formats.indices.size(0),
device=csc_formats.indices.device,
)
+ offset
),
)
src_timestamps = None
if has_timestamp:
src_timestamps = torch.cat(
[
dst_timestamps,
_broadcast_timestamps(
compacted_csc_formats, dst_timestamps
),
]
)
else:
compacted_csc_formats = {}
src_timestamps = None
original_row_ids = {key: val.clone() for key, val in dst_nodes.items()}
if has_timestamp:
src_timestamps = {
key: val.clone() for key, val in dst_timestamps.items()
}
for etype, csc_format in csc_formats.items():
src_type, _, dst_type = etype_str_to_tuple(etype)
assert len(dst_nodes.get(dst_type, [])) + 1 == len(
csc_format.indptr
), "The seed nodes should correspond to indptr."
device = csc_format.indices.device
offset = original_row_ids.get(
src_type, torch.tensor([], device=device)
).size(0)
original_row_ids[src_type] = torch.cat(
(
original_row_ids.get(
src_type,
torch.tensor(
[], dtype=csc_format.indices.dtype, device=device
),
),
csc_format.indices,
)
)
compacted_csc_formats[etype] = CSCFormatBase(
indptr=csc_format.indptr,
indices=(
torch.arange(
0,
csc_format.indices.size(0),
dtype=csc_format.indices.dtype,
device=device,
)
+ offset
),
)
if has_timestamp:
# If destination timestamps are given, broadcast them to the
# corresponding source nodes.
src_timestamps[src_type] = torch.cat(
(
src_timestamps.get(
src_type,
torch.tensor(
[],
dtype=dst_timestamps[dst_type].dtype,
device=device,
),
),
_broadcast_timestamps(
csc_format, dst_timestamps[dst_type]
),
)
)
if has_timestamp:
return original_row_ids, compacted_csc_formats, src_timestamps
return original_row_ids, compacted_csc_formats
+216
View File
@@ -0,0 +1,216 @@
"""Utility functions for GraphBolt."""
import hashlib
import json
import os
import shutil
from typing import List, Union
import numpy as np
import pandas as pd
import torch
from numpy.lib.format import read_array_header_1_0, read_array_header_2_0
def numpy_save_aligned(*args, **kwargs):
"""A wrapper for numpy.save(), ensures the array is stored 4KiB aligned."""
# https://github.com/numpy/numpy/blob/2093a6d5b933f812d15a3de0eafeeb23c61f948a/numpy/lib/format.py#L179
has_array_align = hasattr(np.lib.format, "ARRAY_ALIGN")
if has_array_align:
default_alignment = np.lib.format.ARRAY_ALIGN
# The maximum allowed alignment by the numpy code linked above is 4K.
# Most filesystems work with block sizes of 4K so in practice, the file
# size on the disk won't be larger.
np.lib.format.ARRAY_ALIGN = 4096
np.save(*args, **kwargs)
if has_array_align:
np.lib.format.ARRAY_ALIGN = default_alignment
def _read_torch_data(path):
return torch.load(path, weights_only=False)
def _read_numpy_data(path, in_memory=True):
if in_memory:
return torch.from_numpy(np.load(path))
return torch.as_tensor(np.load(path, mmap_mode="r+"))
def read_data(path, fmt, in_memory=True):
"""Read data from disk."""
if fmt == "torch":
return _read_torch_data(path)
elif fmt == "numpy":
return _read_numpy_data(path, in_memory=in_memory)
else:
raise RuntimeError(f"Unsupported format: {fmt}")
def save_data(data, path, fmt):
"""Save data into disk."""
# Make sure the directory exists.
os.makedirs(os.path.dirname(path), exist_ok=True)
if fmt not in ["numpy", "torch"]:
raise RuntimeError(f"Unsupported format: {fmt}")
# Perform necessary conversion.
if fmt == "numpy" and isinstance(data, torch.Tensor):
data = data.cpu().numpy()
elif fmt == "torch" and isinstance(data, np.ndarray):
data = torch.from_numpy(data).cpu()
# Save the data.
if fmt == "numpy":
if not data.flags["C_CONTIGUOUS"]:
Warning(
"The ndarray saved to disk is not contiguous, "
"so it will be copied to contiguous memory."
)
data = np.ascontiguousarray(data)
numpy_save_aligned(path, data)
elif fmt == "torch":
if not data.is_contiguous():
Warning(
"The tensor saved to disk is not contiguous, "
"so it will be copied to contiguous memory."
)
data = data.contiguous()
torch.save(data, path)
def get_npy_dim(npy_path):
"""Get the dim of numpy file."""
with open(npy_path, "rb") as f:
# For the read_array_header API provided by numpy will only read the
# length of the header, it will cause parsing failure and error if
# first 8 bytes which contains magin string and version are not read
# ahead of time. So, we need to make sure we have skipped these 8
# bytes.
f.seek(8, 0)
try:
shape, _, _ = read_array_header_1_0(f)
except ValueError:
try:
shape, _, _ = read_array_header_2_0(f)
except ValueError:
raise ValueError("Invalid file format")
return len(shape)
def _to_int32(data):
if isinstance(data, torch.Tensor):
return data.to(torch.int32)
elif isinstance(data, np.ndarray):
return data.astype(np.int32)
else:
raise TypeError(
"Unsupported input type. Please provide a torch tensor or numpy array."
)
def copy_or_convert_data(
input_path,
output_path,
input_format,
output_format="numpy",
in_memory=True,
is_feature=False,
within_int32=False,
):
"""Copy or convert the data from input_path to output_path."""
assert (
output_format == "numpy"
), "The output format of the data should be numpy."
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# We read the data always in case we need to cast its type.
data = read_data(input_path, input_format, in_memory)
if within_int32:
data = _to_int32(data)
if input_format == "numpy":
# If dim of the data is 1, reshape it to n * 1 and save it to output_path.
if is_feature and get_npy_dim(input_path) == 1:
data = data.reshape(-1, 1)
# If the data does not need to be modified, just copy the file.
elif not within_int32 and data.numpy().flags["C_CONTIGUOUS"]:
shutil.copyfile(input_path, output_path)
return
else:
# If dim of the data is 1, reshape it to n * 1 and save it to output_path.
if is_feature and data.dim() == 1:
data = data.reshape(-1, 1)
save_data(data, output_path, output_format)
def read_edges(dataset_dir, edge_fmt, edge_path):
"""Read egde data from numpy or csv."""
assert edge_fmt in [
"numpy",
"csv",
], f"`numpy` or `csv` is expected when reading edges but got `{edge_fmt}`."
if edge_fmt == "numpy":
edge_data = read_data(
os.path.join(dataset_dir, edge_path),
edge_fmt,
)
assert (
edge_data.shape[0] == 2 and len(edge_data.shape) == 2
), f"The shape of edges should be (2, N), but got {edge_data.shape}."
src, dst = edge_data.numpy()
else:
edge_data = pd.read_csv(
os.path.join(dataset_dir, edge_path),
names=["src", "dst"],
)
src, dst = edge_data["src"].to_numpy(), edge_data["dst"].to_numpy()
return (src, dst)
def calculate_file_hash(file_path, hash_algo="md5"):
"""Calculate the hash value of a file."""
hash_algos = ["md5", "sha1", "sha224", "sha256", "sha384", "sha512"]
if hash_algo in hash_algos:
hash_obj = getattr(hashlib, hash_algo)()
else:
raise ValueError(
f"Hash algorithm must be one of: {hash_algos}, but got `{hash_algo}`."
)
with open(file_path, "rb") as file:
for chunk in iter(lambda: file.read(4096), b""):
hash_obj.update(chunk)
return hash_obj.hexdigest()
def calculate_dir_hash(
dir_path, hash_algo="md5", ignore: Union[str, List[str]] = None
):
"""Calculte the hash values of all files under the directory."""
hashes = {}
for dirpath, _, filenames in os.walk(dir_path):
for filename in filenames:
if ignore and filename in ignore:
continue
filepath = os.path.join(dirpath, filename)
file_hash = calculate_file_hash(filepath, hash_algo=hash_algo)
hashes[filepath] = file_hash
return hashes
def check_dataset_change(dataset_dir, processed_dir):
"""Check whether dataset has been changed by checking its hash value."""
hash_value_file = "dataset_hash_value.txt"
hash_value_file_path = os.path.join(
dataset_dir, processed_dir, hash_value_file
)
if not os.path.exists(hash_value_file_path):
return True
with open(hash_value_file_path, "r") as f:
oringinal_hash_value = json.load(f)
present_hash_value = calculate_dir_hash(dataset_dir, ignore=hash_value_file)
if oringinal_hash_value == present_hash_value:
force_preprocess = False
else:
force_preprocess = True
return force_preprocess
+371
View File
@@ -0,0 +1,371 @@
"""Miscallenous internal utils."""
import functools
import hashlib
import os
import platform
import warnings
from collections.abc import Mapping, Sequence
import requests
import torch
from tqdm.auto import tqdm
try:
from packaging import version # pylint: disable=unused-import
except ImportError:
# If packaging isn't installed, try and use the vendored copy in setuptools
from setuptools.extern.packaging import version
@functools.lru_cache(maxsize=None)
def is_wsl(v: str = platform.uname().release) -> int:
"""Detects if Python is running in WSL"""
if v.endswith("-Microsoft"):
return 1
elif v.endswith("microsoft-standard-WSL2"):
return 2
return 0
# pylint: disable=invalid-name
_default_formatwarning = warnings.formatwarning
def built_with_cuda():
"""Returns whether GraphBolt was built with CUDA support."""
# This op is defined if graphbolt is built with CUDA support.
return hasattr(torch.ops.graphbolt, "set_max_uva_threads")
class GBWarning(UserWarning):
"""GraphBolt Warning class."""
# pylint: disable=unused-argument
def gb_warning_format(message, category, filename, lineno, line=None):
"""Format GraphBolt warnings."""
if isinstance(category, GBWarning):
return "GraphBolt Warning: {}\n".format(message)
else:
return _default_formatwarning(
message, category, filename, lineno, line=None
)
def gb_warning(message, category=GBWarning, stacklevel=2):
"""GraphBolt warning wrapper that defaults to ``GBWarning`` instead of
``UserWarning`` category.
"""
return warnings.warn(message, category=category, stacklevel=stacklevel)
warnings.formatwarning = gb_warning_format
def is_listlike(data):
"""Return if the data is a sequence but not a string."""
return isinstance(data, Sequence) and not isinstance(data, str)
def recursive_apply(data, fn, *args, **kwargs):
"""Recursively apply a function to every element in a container.
If the input data is a list or any sequence other than a string, returns a list
whose elements are the same elements applied with the given function.
If the input data is a dict or any mapping, returns a dict whose keys are the same
and values are the elements applied with the given function.
If the input data is a nested container, the result will have the same nested
structure where each element is transformed recursively.
The first argument of the function will be passed with the individual elements from
the input data, followed by the arguments in :attr:`args` and :attr:`kwargs`.
Parameters
----------
data : any
Any object.
fn : callable
Any function.
args, kwargs :
Additional arguments and keyword-arguments passed to the function.
Examples
--------
Applying a ReLU function to a dictionary of tensors:
>>> h = {k: torch.randn(3) for k in ['A', 'B', 'C']}
>>> h = recursive_apply(h, torch.nn.functional.relu)
>>> assert all((v >= 0).all() for v in h.values())
"""
if isinstance(data, Mapping):
return {
k: recursive_apply(v, fn, *args, **kwargs) for k, v in data.items()
}
elif isinstance(data, tuple):
return tuple(recursive_apply(v, fn, *args, **kwargs) for v in data)
elif is_listlike(data):
return [recursive_apply(v, fn, *args, **kwargs) for v in data]
else:
return fn(data, *args, **kwargs)
def recursive_apply_reduce_all(data, fn, *args, **kwargs):
"""Recursively apply a function to every element in a container and reduce
the boolean results with all.
If the input data is a list or any sequence other than a string, returns
True if and only if the given function returns True for all elements.
If the input data is a dict or any mapping, returns True if and only if the
given function returns True for values.
If the input data is a nested container, the result will be reduced over the
nested structure where each element is tested recursively.
The first argument of the function will be passed with the individual elements from
the input data, followed by the arguments in :attr:`args` and :attr:`kwargs`.
Parameters
----------
data : any
Any object.
fn : callable
Any function returning a boolean.
args, kwargs :
Additional arguments and keyword-arguments passed to the function.
"""
if isinstance(data, Mapping):
return all(
recursive_apply_reduce_all(v, fn, *args, **kwargs)
for v in data.values()
)
elif isinstance(data, tuple) or is_listlike(data):
return all(
recursive_apply_reduce_all(v, fn, *args, **kwargs) for v in data
)
else:
return fn(data, *args, **kwargs)
def get_nonproperty_attributes(_obj) -> list:
"""Get attributes of the class except for the properties."""
attributes = [
attribute
for attribute in dir(_obj)
if not attribute.startswith("__")
and (
not hasattr(type(_obj), attribute)
or not isinstance(getattr(type(_obj), attribute), property)
)
and not callable(getattr(_obj, attribute))
]
return attributes
def get_attributes(_obj) -> list:
"""Get attributes of the class."""
attributes = [
attribute
for attribute in dir(_obj)
if not attribute.startswith("__")
and not callable(getattr(_obj, attribute))
]
return attributes
def download(
url,
path=None,
overwrite=True,
sha1_hash=None,
retries=5,
verify_ssl=True,
log=True,
):
"""Download a given URL.
Codes borrowed from mxnet/gluon/utils.py
Parameters
----------
url : str
URL to download.
path : str, optional
Destination path to store downloaded file. By default stores to the
current directory with the same name as in url.
overwrite : bool, optional
Whether to overwrite the destination file if it already exists.
By default always overwrites the downloaded file.
sha1_hash : str, optional
Expected sha1 hash in hexadecimal digits. Will ignore existing file when hash is specified
but doesn't match.
retries : integer, default 5
The number of times to attempt downloading in case of failure or non 200 return codes.
verify_ssl : bool, default True
Verify SSL certificates.
log : bool, default True
Whether to print the progress for download
Returns
-------
str
The file path of the downloaded file.
"""
if path is None:
fname = url.split("/")[-1]
# Empty filenames are invalid
assert fname, (
"Can't construct file-name from this URL. "
"Please set the `path` option manually."
)
else:
path = os.path.expanduser(path)
if os.path.isdir(path):
fname = os.path.join(path, url.split("/")[-1])
else:
fname = path
assert retries >= 0, "Number of retries should be at least 0"
if not verify_ssl:
warnings.warn(
"Unverified HTTPS request is being made (verify_ssl=False). "
"Adding certificate verification is strongly advised."
)
if (
overwrite
or not os.path.exists(fname)
or (sha1_hash and not check_sha1(fname, sha1_hash))
):
dirname = os.path.dirname(os.path.abspath(os.path.expanduser(fname)))
if not os.path.exists(dirname):
os.makedirs(dirname)
while retries + 1 > 0:
# Disable pyling too broad Exception
# pylint: disable=W0703
try:
if log:
print("Downloading %s from %s..." % (fname, url))
r = requests.get(url, stream=True, verify=verify_ssl)
if r.status_code != 200:
raise RuntimeError("Failed downloading url %s" % url)
# Get the total file size.
total_size = int(r.headers.get("content-length", 0))
with tqdm(
total=total_size, unit="B", unit_scale=True, desc=fname
) as progress_bar:
with open(fname, "wb") as f:
for chunk in r.iter_content(chunk_size=1024):
if chunk: # filter out keep-alive new chunks
f.write(chunk)
progress_bar.update(len(chunk))
if sha1_hash and not check_sha1(fname, sha1_hash):
raise UserWarning(
"File {} is downloaded but the content hash does not match."
" The repo may be outdated or download may be incomplete. "
'If the "repo_url" is overridden, consider switching to '
"the default repo.".format(fname)
)
break
except Exception as e:
retries -= 1
if retries <= 0:
raise e
if log:
print(
"download failed, retrying, {} attempt{} left".format(
retries, "s" if retries > 1 else ""
)
)
return fname
def check_sha1(filename, sha1_hash):
"""Check whether the sha1 hash of the file content matches the expected hash.
Codes borrowed from mxnet/gluon/utils.py
Parameters
----------
filename : str
Path to the file.
sha1_hash : str
Expected sha1 hash in hexadecimal digits.
Returns
-------
bool
Whether the file content matches the expected hash.
"""
sha1 = hashlib.sha1()
with open(filename, "rb") as f:
while True:
data = f.read(1048576)
if not data:
break
sha1.update(data)
return sha1.hexdigest() == sha1_hash
def extract_archive(file, target_dir, overwrite=True):
"""Extract archive file.
Parameters
----------
file : str
Absolute path of the archive file.
target_dir : str
Target directory of the archive to be uncompressed.
overwrite : bool, default True
Whether to overwrite the contents inside the directory.
By default always overwrites.
"""
if os.path.exists(target_dir) and not overwrite:
return
print("Extracting file to {}".format(target_dir))
if (
file.endswith(".tar.gz")
or file.endswith(".tar")
or file.endswith(".tgz")
):
import tarfile
with tarfile.open(file, "r") as archive:
def is_within_directory(directory, target):
abs_directory = os.path.abspath(directory)
abs_target = os.path.abspath(target)
prefix = os.path.commonprefix([abs_directory, abs_target])
return prefix == abs_directory
def safe_extract(
tar, path=".", members=None, *, numeric_owner=False
):
for member in tar.getmembers():
member_path = os.path.join(path, member.name)
if not is_within_directory(path, member_path):
raise Exception("Attempted Path Traversal in Tar File")
tar.extractall(path, members, numeric_owner=numeric_owner)
safe_extract(archive, path=target_dir)
elif file.endswith(".gz"):
import gzip
import shutil
with gzip.open(file, "rb") as f_in:
target_file = os.path.join(target_dir, os.path.basename(file)[:-3])
with open(target_file, "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
elif file.endswith(".zip"):
import zipfile
with zipfile.ZipFile(file, "r") as archive:
archive.extractall(path=target_dir)
else:
raise Exception("Unrecognized file type: " + file)
+636
View File
@@ -0,0 +1,636 @@
"""Item Sampler"""
from collections.abc import Mapping
from typing import Callable, Iterator, Optional, Union
import numpy as np
import torch
import torch.distributed as dist
from torch.utils.data import IterDataPipe
from .internal import calculate_range
from .internal_utils import gb_warning
from .itemset import HeteroItemSet, ItemSet
from .minibatch import MiniBatch
__all__ = ["ItemSampler", "DistributedItemSampler", "minibatcher_default"]
def minibatcher_default(batch, names):
"""Default minibatcher which maps a list of items to a `MiniBatch` with the
same names as the items. The names of items are supposed to be provided
and align with the data attributes of `MiniBatch`. If any unknown item name
is provided, exception will be raised. If the names of items are not
provided, the item list is returned as is and a warning will be raised.
Parameters
----------
batch : list
List of items.
names : Tuple[str] or None
Names of items in `batch` with same length. The order should align
with `batch`.
Returns
-------
MiniBatch
A minibatch.
"""
if names is None:
gb_warning(
"Failed to map item list to `MiniBatch` as the names of items are "
"not provided. Please provide a customized `MiniBatcher`. "
"The item list is returned as is."
)
return batch
if len(names) == 1:
# Handle the case of single item: batch = tensor([0, 1, 2, 3]), names =
# ("seeds",) as `zip(batch, names)` will iterate over the tensor
# instead of the batch.
init_data = {names[0]: batch}
else:
if isinstance(batch, Mapping):
init_data = {
name: {k: v[i] for k, v in batch.items()}
for i, name in enumerate(names)
}
else:
init_data = {name: item for item, name in zip(batch, names)}
minibatch = MiniBatch()
# TODO(#7254): Hacks for original `seed_nodes` and `node_pairs`, which need
# to be cleaned up later.
if "node_pairs" in names:
pos_seeds = init_data["node_pairs"]
# Build negative graph.
if "negative_srcs" in names and "negative_dsts" in names:
neg_srcs = init_data["negative_srcs"]
neg_dsts = init_data["negative_dsts"]
(
init_data["seeds"],
init_data["labels"],
init_data["indexes"],
) = _construct_seeds(
pos_seeds, neg_srcs=neg_srcs, neg_dsts=neg_dsts
)
elif "negative_srcs" in names:
neg_srcs = init_data["negative_srcs"]
(
init_data["seeds"],
init_data["labels"],
init_data["indexes"],
) = _construct_seeds(pos_seeds, neg_srcs=neg_srcs)
elif "negative_dsts" in names:
neg_dsts = init_data["negative_dsts"]
(
init_data["seeds"],
init_data["labels"],
init_data["indexes"],
) = _construct_seeds(pos_seeds, neg_dsts=neg_dsts)
else:
init_data["seeds"] = pos_seeds
for name, item in init_data.items():
if not hasattr(minibatch, name):
gb_warning(
f"Unknown item name '{name}' is detected and added into "
"`MiniBatch`. You probably need to provide a customized "
"`MiniBatcher`."
)
# TODO(#7254): Hacks for original `seed_nodes` and `node_pairs`, which
# need to be cleaned up later.
if name == "seed_nodes":
name = "seeds"
if name in ("node_pairs", "negative_srcs", "negative_dsts"):
continue
setattr(minibatch, name, item)
return minibatch
class ItemSampler(IterDataPipe):
"""A sampler to iterate over input items and create minibatches.
Input items could be node IDs, node pairs with or without labels, node
pairs with negative sources/destinations.
Note: This class `ItemSampler` is not decorated with
`torch.utils.data.functional_datapipe` on purpose. This indicates it
does not support function-like call. But any iterable datapipes from
`torch.utils.data.datapipes` can be further appended.
Parameters
----------
item_set : Union[ItemSet, HeteroItemSet]
Data to be sampled.
batch_size : int
The size of each batch.
minibatcher : Optional[Callable]
A callable that takes in a list of items and returns a `MiniBatch`.
drop_last : bool
Option to drop the last batch if it's not full.
shuffle : bool
Option to shuffle before sample.
seed: int
The seed for reproducible stochastic shuffling. If None, a random seed
will be generated.
Examples
--------
1. Node IDs.
>>> import torch
>>> from dgl import graphbolt as gb
>>> item_set = gb.ItemSet(torch.arange(0, 10), names="seeds")
>>> item_sampler = gb.ItemSampler(
... item_set, batch_size=4, shuffle=False, drop_last=False
... )
>>> next(iter(item_sampler))
MiniBatch(seeds=tensor([0, 1, 2, 3]), sampled_subgraphs=None,
node_features=None, labels=None, input_nodes=None,
indexes=None, edge_features=None, compacted_seeds=None,
blocks=None,)
2. Node pairs.
>>> item_set = gb.ItemSet(torch.arange(0, 20).reshape(-1, 2),
... names="seeds")
>>> item_sampler = gb.ItemSampler(
... item_set, batch_size=4, shuffle=False, drop_last=False
... )
>>> next(iter(item_sampler))
MiniBatch(seeds=tensor([[0, 1], [2, 3], [4, 5], [6, 7]]),
sampled_subgraphs=None, node_features=None, labels=None,
input_nodes=None, indexes=None, edge_features=None,
compacted_seeds=None, blocks=None,)
3. Node pairs and labels.
>>> item_set = gb.ItemSet(
... (torch.arange(0, 20).reshape(-1, 2), torch.arange(10, 20)),
... names=("seeds", "labels")
... )
>>> item_sampler = gb.ItemSampler(
... item_set, batch_size=4, shuffle=False, drop_last=False
... )
>>> next(iter(item_sampler))
MiniBatch(seeds=tensor([[0, 1], [2, 3], [4, 5], [6, 7]]),
sampled_subgraphs=None, node_features=None,
labels=tensor([10, 11, 12, 13]), input_nodes=None,
indexes=None, edge_features=None, compacted_seeds=None,
blocks=None,)
4. Node pairs, labels and indexes.
>>> seeds = torch.arange(0, 20).reshape(-1, 2)
>>> labels = torch.tensor([1, 1, 0, 0, 0, 0, 0, 0, 0, 0])
>>> indexes = torch.tensor([0, 1, 0, 0, 0, 0, 1, 1, 1, 1])
>>> item_set = gb.ItemSet((seeds, labels, indexes), names=("seeds",
... "labels", "indexes"))
>>> item_sampler = gb.ItemSampler(
... item_set, batch_size=4, shuffle=False, drop_last=False
... )
>>> next(iter(item_sampler))
MiniBatch(seeds=tensor([[0, 1], [2, 3], [4, 5], [6, 7]]),
sampled_subgraphs=None, node_features=None,
labels=tensor([1, 1, 0, 0]), input_nodes=None,
indexes=tensor([0, 1, 0, 0]), edge_features=None,
compacted_seeds=None, blocks=None,)
5. Further process batches with other datapipes such as
:class:`torch.utils.data.datapipes.iter.Mapper`.
>>> item_set = gb.ItemSet(torch.arange(0, 10))
>>> data_pipe = gb.ItemSampler(item_set, 4)
>>> def add_one(batch):
... return batch + 1
>>> data_pipe = data_pipe.map(add_one)
>>> list(data_pipe)
[tensor([1, 2, 3, 4]), tensor([5, 6, 7, 8]), tensor([ 9, 10])]
6. Heterogeneous node IDs.
>>> ids = {
... "user": gb.ItemSet(torch.arange(0, 5), names="seeds"),
... "item": gb.ItemSet(torch.arange(0, 6), names="seeds"),
... }
>>> item_set = gb.HeteroItemSet(ids)
>>> item_sampler = gb.ItemSampler(item_set, batch_size=4)
>>> next(iter(item_sampler))
MiniBatch(seeds={'user': tensor([0, 1, 2, 3])}, sampled_subgraphs=None,
node_features=None, labels=None, input_nodes=None, indexes=None,
edge_features=None, compacted_seeds=None, blocks=None,)
7. Heterogeneous node pairs.
>>> seeds_like = torch.arange(0, 10).reshape(-1, 2)
>>> seeds_follow = torch.arange(10, 20).reshape(-1, 2)
>>> item_set = gb.HeteroItemSet({
... "user:like:item": gb.ItemSet(
... seeds_like, names="seeds"),
... "user:follow:user": gb.ItemSet(
... seeds_follow, names="seeds"),
... })
>>> item_sampler = gb.ItemSampler(item_set, batch_size=4)
>>> next(iter(item_sampler))
MiniBatch(seeds={'user:like:item':
tensor([[0, 1], [2, 3], [4, 5], [6, 7]])}, sampled_subgraphs=None,
node_features=None, labels=None, input_nodes=None, indexes=None,
edge_features=None, compacted_seeds=None, blocks=None,)
8. Heterogeneous node pairs and labels.
>>> seeds_like = torch.arange(0, 10).reshape(-1, 2)
>>> labels_like = torch.arange(0, 5)
>>> seeds_follow = torch.arange(10, 20).reshape(-1, 2)
>>> labels_follow = torch.arange(5, 10)
>>> item_set = gb.HeteroItemSet({
... "user:like:item": gb.ItemSet((seeds_like, labels_like),
... names=("seeds", "labels")),
... "user:follow:user": gb.ItemSet((seeds_follow, labels_follow),
... names=("seeds", "labels")),
... })
>>> item_sampler = gb.ItemSampler(item_set, batch_size=4)
>>> next(iter(item_sampler))
MiniBatch(seeds={'user:like:item':
tensor([[0, 1], [2, 3], [4, 5], [6, 7]])}, sampled_subgraphs=None,
node_features=None, labels={'user:like:item': tensor([0, 1, 2, 3])},
input_nodes=None, indexes=None, edge_features=None,
compacted_seeds=None, blocks=None,)
9. Heterogeneous node pairs, labels and indexes.
>>> seeds_like = torch.arange(0, 10).reshape(-1, 2)
>>> labels_like = torch.tensor([1, 1, 0, 0, 0])
>>> indexes_like = torch.tensor([0, 1, 0, 0, 1])
>>> seeds_follow = torch.arange(20, 30).reshape(-1, 2)
>>> labels_follow = torch.tensor([1, 1, 0, 0, 0])
>>> indexes_follow = torch.tensor([0, 1, 0, 0, 1])
>>> item_set = gb.HeteroItemSet({
... "user:like:item": gb.ItemSet((seeds_like, labels_like,
... indexes_like), names=("seeds", "labels", "indexes")),
... "user:follow:user": gb.ItemSet((seeds_follow,labels_follow,
... indexes_follow), names=("seeds", "labels", "indexes")),
... })
>>> item_sampler = gb.ItemSampler(item_set, batch_size=4)
>>> next(iter(item_sampler))
MiniBatch(seeds={'user:like:item':
tensor([[0, 1], [2, 3], [4, 5], [6, 7]])}, sampled_subgraphs=None,
node_features=None, labels={'user:like:item': tensor([1, 1, 0, 0])},
input_nodes=None, indexes={'user:like:item': tensor([0, 1, 0, 0])},
edge_features=None, compacted_seeds=None, blocks=None,)
"""
def __init__(
self,
item_set: Union[ItemSet, HeteroItemSet],
batch_size: int,
minibatcher: Optional[Callable] = minibatcher_default,
drop_last: Optional[bool] = False,
shuffle: Optional[bool] = False,
seed: Optional[int] = None,
) -> None:
super().__init__()
self._item_set = item_set
self._names = item_set.names
self._batch_size = batch_size
self._minibatcher = minibatcher
self._drop_last = drop_last
self._shuffle = shuffle
self._distributed = False
self._drop_uneven_inputs = False
self._world_size = None
self._rank = None
# For the sake of reproducibility, the seed should be allowed to be
# manually set by the user.
if seed is None:
self._seed = np.random.randint(0, np.iinfo(np.int32).max)
else:
self._seed = seed
# The attribute `self._epoch` is added to make shuffling work properly
# across multiple epochs. Otherwise, the same ordering will always be
# used in every epoch.
self._epoch = 0
def __iter__(self) -> Iterator:
worker_info = torch.utils.data.get_worker_info()
if worker_info is not None:
num_workers = worker_info.num_workers
worker_id = worker_info.id
else:
num_workers = 1
worker_id = 0
total = len(self._item_set)
start_offset, assigned_count, output_count = calculate_range(
self._distributed,
total,
self._world_size,
self._rank,
num_workers,
worker_id,
self._batch_size,
self._drop_last,
self._drop_uneven_inputs,
)
if self._shuffle:
g = torch.Generator().manual_seed(self._seed + self._epoch)
permutation = torch.randperm(total, generator=g)
indices = permutation[start_offset : start_offset + assigned_count]
else:
indices = torch.arange(start_offset, start_offset + assigned_count)
for i in range(0, assigned_count, self._batch_size):
if output_count <= 0:
break
yield self._minibatcher(
self._item_set[
indices[i : i + min(self._batch_size, output_count)]
],
self._names,
)
output_count -= self._batch_size
self._epoch += 1
class DistributedItemSampler(ItemSampler):
"""A sampler to iterate over input items and create subsets distributedly.
This sampler creates a distributed subset of items from the given data set,
which can be used for training with PyTorch's Distributed Data Parallel
(DDP). The items can be node IDs, node pairs with or without labels, node
pairs with negative sources/destinations, DGLGraphs, or heterogeneous
counterparts. The original item set is split such that each replica
(process) receives an exclusive subset.
Note: The items will be first split onto each replica, then get shuffled
(if needed) and batched. Therefore, each replica will always get a same set
of items.
Note: This class `DistributedItemSampler` is not decorated with
`torch.utils.data.functional_datapipe` on purpose. This indicates it
does not support function-like call. But any iterable datapipes from
`torch.utils.data.datapipes` can be further appended.
Parameters
----------
item_set : Union[ItemSet, HeteroItemSet]
Data to be sampled.
batch_size : int
The size of each batch.
minibatcher : Optional[Callable]
A callable that takes in a list of items and returns a `MiniBatch`.
drop_last : bool
Option to drop the last batch if it's not full.
shuffle : bool
Option to shuffle before sample.
num_replicas: int
The number of model replicas that will be created during Distributed
Data Parallel (DDP) training. It should be the same as the real world
size, otherwise it could cause errors. By default, it is retrieved from
the current distributed group.
drop_uneven_inputs : bool
Option to make sure the numbers of batches for each replica are the
same. If some of the replicas have more batches than the others, the
redundant batches of those replicas will be dropped. If the drop_last
parameter is also set to True, the last batch will be dropped before the
redundant batches are dropped.
Note: When using Distributed Data Parallel (DDP) training, the program
may hang or error if the a replica has fewer inputs. It is recommended
to use the Join Context Manager provided by PyTorch to solve this
problem. Please refer to
https://pytorch.org/tutorials/advanced/generic_join.html. However, this
option can be used if the Join Context Manager is not helpful for any
reason.
seed: int
The seed for reproducible stochastic shuffling. If None, a random seed
will be generated.
Examples
--------
0. Preparation: DistributedItemSampler needs multi-processing environment to
work. You need to spawn subprocesses and initialize processing group before
executing following examples. Due to randomness, the output is not always
the same as listed below.
>>> import torch
>>> from dgl import graphbolt as gb
>>> item_set = gb.ItemSet(torch.arange(15))
>>> num_replicas = 4
>>> batch_size = 2
>>> mp.spawn(...)
1. shuffle = False, drop_last = False, drop_uneven_inputs = False.
>>> item_sampler = gb.DistributedItemSampler(
>>> item_set, batch_size=2, shuffle=False, drop_last=False,
>>> drop_uneven_inputs=False
>>> )
>>> data_loader = gb.DataLoader(item_sampler)
>>> print(f"Replica#{proc_id}: {list(data_loader)})
Replica#0: [tensor([0, 1]), tensor([2, 3])]
Replica#1: [tensor([4, 5]), tensor([6, 7])]
Replica#2: [tensor([8, 9]), tensor([10, 11])]
Replica#3: [tensor([12, 13]), tensor([14])]
2. shuffle = False, drop_last = True, drop_uneven_inputs = False.
>>> item_sampler = gb.DistributedItemSampler(
>>> item_set, batch_size=2, shuffle=False, drop_last=True,
>>> drop_uneven_inputs=False
>>> )
>>> data_loader = gb.DataLoader(item_sampler)
>>> print(f"Replica#{proc_id}: {list(data_loader)})
Replica#0: [tensor([0, 1]), tensor([2, 3])]
Replica#1: [tensor([4, 5]), tensor([6, 7])]
Replica#2: [tensor([8, 9]), tensor([10, 11])]
Replica#3: [tensor([12, 13])]
3. shuffle = False, drop_last = False, drop_uneven_inputs = True.
>>> item_sampler = gb.DistributedItemSampler(
>>> item_set, batch_size=2, shuffle=False, drop_last=False,
>>> drop_uneven_inputs=True
>>> )
>>> data_loader = gb.DataLoader(item_sampler)
>>> print(f"Replica#{proc_id}: {list(data_loader)})
Replica#0: [tensor([0, 1]), tensor([2, 3])]
Replica#1: [tensor([4, 5]), tensor([6, 7])]
Replica#2: [tensor([8, 9]), tensor([10, 11])]
Replica#3: [tensor([12, 13]), tensor([14])]
4. shuffle = False, drop_last = True, drop_uneven_inputs = True.
>>> item_sampler = gb.DistributedItemSampler(
>>> item_set, batch_size=2, shuffle=False, drop_last=True,
>>> drop_uneven_inputs=True
>>> )
>>> data_loader = gb.DataLoader(item_sampler)
>>> print(f"Replica#{proc_id}: {list(data_loader)})
Replica#0: [tensor([0, 1])]
Replica#1: [tensor([4, 5])]
Replica#2: [tensor([8, 9])]
Replica#3: [tensor([12, 13])]
5. shuffle = True, drop_last = True, drop_uneven_inputs = False.
>>> item_sampler = gb.DistributedItemSampler(
>>> item_set, batch_size=2, shuffle=True, drop_last=True,
>>> drop_uneven_inputs=False
>>> )
>>> data_loader = gb.DataLoader(item_sampler)
>>> print(f"Replica#{proc_id}: {list(data_loader)})
(One possible output:)
Replica#0: [tensor([3, 2]), tensor([0, 1])]
Replica#1: [tensor([6, 5]), tensor([7, 4])]
Replica#2: [tensor([8, 10])]
Replica#3: [tensor([14, 12])]
6. shuffle = True, drop_last = True, drop_uneven_inputs = True.
>>> item_sampler = gb.DistributedItemSampler(
>>> item_set, batch_size=2, shuffle=True, drop_last=True,
>>> drop_uneven_inputs=True
>>> )
>>> data_loader = gb.DataLoader(item_sampler)
>>> print(f"Replica#{proc_id}: {list(data_loader)})
(One possible output:)
Replica#0: [tensor([1, 3])]
Replica#1: [tensor([7, 5])]
Replica#2: [tensor([11, 9])]
Replica#3: [tensor([13, 14])]
"""
def __init__(
self,
item_set: Union[ItemSet, HeteroItemSet],
batch_size: int,
minibatcher: Optional[Callable] = minibatcher_default,
drop_last: Optional[bool] = False,
shuffle: Optional[bool] = False,
drop_uneven_inputs: Optional[bool] = False,
seed: Optional[int] = None,
) -> None:
super().__init__(
item_set,
batch_size,
minibatcher,
drop_last,
shuffle,
seed,
)
self._distributed = True
self._drop_uneven_inputs = drop_uneven_inputs
if not dist.is_available():
raise RuntimeError(
"Distributed item sampler requires distributed package."
)
self._world_size = dist.get_world_size()
self._rank = dist.get_rank()
if self._world_size > 1:
# For the sake of reproducibility, the seed should be allowed to be
# manually set by the user.
self._align_seeds(src=0, seed=seed)
def _align_seeds(
self, src: Optional[int] = 0, seed: Optional[int] = None
) -> None:
"""Aligns seeds across distributed processes.
This method synchronizes seeds across distributed processes, ensuring
consistent randomness.
Parameters
----------
src: int, optional
The source process rank. Defaults to 0.
seed: int, optional
The seed value to synchronize. If None, a random seed will be
generated. Defaults to None.
"""
device = (
torch.cuda.current_device()
if torch.cuda.is_available() and dist.get_backend() == "nccl"
else "cpu"
)
if seed is None:
seed = np.random.randint(0, np.iinfo(np.int32).max)
if self._rank == src:
seed_tensor = torch.tensor(seed, dtype=torch.int32, device=device)
else:
seed_tensor = torch.empty([], dtype=torch.int32, device=device)
dist.broadcast(seed_tensor, src=src)
self._seed = seed_tensor.item()
def _construct_seeds(pos_seeds, neg_srcs=None, neg_dsts=None):
# For homogeneous graph.
if isinstance(pos_seeds, torch.Tensor):
negative_ratio = neg_srcs.size(1) if neg_srcs else neg_dsts.size(1)
neg_srcs = (
neg_srcs
if neg_srcs is not None
else pos_seeds[:, 0].repeat_interleave(negative_ratio)
).view(-1)
neg_dsts = (
neg_dsts
if neg_dsts is not None
else pos_seeds[:, 1].repeat_interleave(negative_ratio)
).view(-1)
neg_seeds = torch.cat((neg_srcs, neg_dsts)).view(2, -1).T
seeds = torch.cat((pos_seeds, neg_seeds))
pos_seeds_num = pos_seeds.size(0)
labels = torch.empty(seeds.size(0), device=pos_seeds.device)
labels[:pos_seeds_num] = 1
labels[pos_seeds_num:] = 0
pos_indexes = torch.arange(
0,
pos_seeds_num,
device=pos_seeds.device,
)
neg_indexes = pos_indexes.repeat_interleave(negative_ratio)
indexes = torch.cat((pos_indexes, neg_indexes))
# For heterogeneous graph.
else:
negative_ratio = (
list(neg_srcs.values())[0].size(1)
if neg_srcs
else list(neg_dsts.values())[0].size(1)
)
seeds = {}
labels = {}
indexes = {}
for etype in pos_seeds:
neg_src = (
neg_srcs[etype]
if neg_srcs is not None
else pos_seeds[etype][:, 0].repeat_interleave(negative_ratio)
).view(-1)
neg_dst = (
neg_dsts[etype]
if neg_dsts is not None
else pos_seeds[etype][:, 1].repeat_interleave(negative_ratio)
).view(-1)
seeds[etype] = torch.cat(
(
pos_seeds[etype],
torch.cat(
(
neg_src,
neg_dst,
)
)
.view(2, -1)
.T,
)
)
pos_seeds_num = pos_seeds[etype].size(0)
labels[etype] = torch.empty(
seeds[etype].size(0), device=pos_seeds[etype].device
)
labels[etype][:pos_seeds_num] = 1
labels[etype][pos_seeds_num:] = 0
pos_indexes = torch.arange(
0,
pos_seeds_num,
device=pos_seeds[etype].device,
)
neg_indexes = pos_indexes.repeat_interleave(negative_ratio)
indexes[etype] = torch.cat((pos_indexes, neg_indexes))
return seeds, labels, indexes
+454
View File
@@ -0,0 +1,454 @@
"""GraphBolt Itemset."""
import textwrap
from typing import Dict, Iterable, Tuple, Union
import torch
from .internal_utils import gb_warning
__all__ = ["ItemSet", "HeteroItemSet", "ItemSetDict"]
def is_scalar(x):
"""Checks if the input is a scalar."""
return (
len(x.shape) == 0 if isinstance(x, torch.Tensor) else isinstance(x, int)
)
class ItemSet:
r"""A wrapper of a tensor or tuple of tensors.
Parameters
----------
items: Union[int, torch.Tensor, Tuple[torch.Tensor]]
The tensors to be wrapped.
- If it is a single scalar (an integer or a tensor that holds a single
value), the item would be considered as a range_tensor created by
`torch.arange`.
- If it is a multi-dimensional tensor, the indexing will be performed
along the first dimension.
- If it is a tuple, each item in the tuple must be a tensor.
names: Union[str, Tuple[str]], optional
The names of the items. If it is a tuple, each name must corresponds to
an item in the `items` parameter. The naming is arbitrary, but in
general practice, the names should be chosen from ['labels', 'seeds',
'indexes'] to align with the attributes of class
`dgl.graphbolt.MiniBatch`.
Examples
--------
>>> import torch
>>> from dgl import graphbolt as gb
1. Integer: number of nodes.
>>> num = 10
>>> item_set = gb.ItemSet(num, names="seeds")
>>> list(item_set)
[tensor(0), tensor(1), tensor(2), tensor(3), tensor(4), tensor(5),
tensor(6), tensor(7), tensor(8), tensor(9)]
>>> item_set[:]
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> item_set.names
('seeds',)
2. Torch scalar: number of nodes. Customizable dtype compared to Integer.
>>> num = torch.tensor(10, dtype=torch.int32)
>>> item_set = gb.ItemSet(num, names="seeds")
>>> list(item_set)
[tensor(0, dtype=torch.int32), tensor(1, dtype=torch.int32),
tensor(2, dtype=torch.int32), tensor(3, dtype=torch.int32),
tensor(4, dtype=torch.int32), tensor(5, dtype=torch.int32),
tensor(6, dtype=torch.int32), tensor(7, dtype=torch.int32),
tensor(8, dtype=torch.int32), tensor(9, dtype=torch.int32)]
>>> item_set[:]
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], dtype=torch.int32)
>>> item_set.names
('seeds',)
3. Single tensor: seed nodes.
>>> node_ids = torch.arange(0, 5)
>>> item_set = gb.ItemSet(node_ids, names="seeds")
>>> list(item_set)
[tensor(0), tensor(1), tensor(2), tensor(3), tensor(4)]
>>> item_set[:]
tensor([0, 1, 2, 3, 4])
>>> item_set.names
('seeds',)
4. Tuple of tensors with same shape: seed nodes and labels.
>>> node_ids = torch.arange(0, 5)
>>> labels = torch.arange(5, 10)
>>> item_set = gb.ItemSet(
... (node_ids, labels), names=("seeds", "labels"))
>>> list(item_set)
[(tensor(0), tensor(5)), (tensor(1), tensor(6)), (tensor(2), tensor(7)),
(tensor(3), tensor(8)), (tensor(4), tensor(9))]
>>> item_set[:]
(tensor([0, 1, 2, 3, 4]), tensor([5, 6, 7, 8, 9]))
>>> item_set.names
('seeds', 'labels')
5. Tuple of tensors with different shape: seeds and labels.
>>> seeds = torch.arange(0, 10).reshape(-1, 2)
>>> labels = torch.tensor([1, 1, 0, 0, 0])
>>> item_set = gb.ItemSet(
... (seeds, labels), names=("seeds", "lables"))
>>> list(item_set)
[(tensor([0, 1]), tensor([1])),
(tensor([2, 3]), tensor([1])),
(tensor([4, 5]), tensor([0])),
(tensor([6, 7]), tensor([0])),
(tensor([8, 9]), tensor([0]))]
>>> item_set[:]
(tensor([[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]),
tensor([1, 1, 0, 0, 0]))
>>> item_set.names
('seeds', 'labels')
6. Tuple of tensors with different shape: hyperlink and labels.
>>> seeds = torch.arange(0, 10).reshape(-1, 5)
>>> labels = torch.tensor([1, 0])
>>> item_set = gb.ItemSet(
... (seeds, labels), names=("seeds", "lables"))
>>> list(item_set)
[(tensor([0, 1, 2, 3, 4]), tensor([1])),
(tensor([5, 6, 7, 8, 9]), tensor([0]))]
>>> item_set[:]
(tensor([[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]]),
tensor([1, 0]))
>>> item_set.names
('seeds', 'labels')
"""
def __init__(
self,
items: Union[int, torch.Tensor, Tuple[torch.Tensor]],
names: Union[str, Tuple[str]] = None,
) -> None:
if is_scalar(items):
self._length = int(items)
self._items = items
elif isinstance(items, tuple):
self._length = len(items[0])
if any(self._length != len(item) for item in items):
raise ValueError("Size mismatch between items.")
self._items = items
else:
self._length = len(items)
self._items = (items,)
self._num_items = (
len(self._items) if isinstance(self._items, tuple) else 1
)
if names is not None:
if isinstance(names, tuple):
self._names = names
else:
self._names = (names,)
assert self._num_items == len(self._names), (
f"Number of items ({self._num_items}) and "
f"names ({len(self._names)}) don't match."
)
else:
self._names = None
def __len__(self) -> int:
return self._length
def __getitem__(self, index: Union[int, slice, Iterable[int]]):
if is_scalar(self._items):
dtype = getattr(self._items, "dtype", torch.int64)
if isinstance(index, slice):
start, stop, step = index.indices(self._length)
return torch.arange(start, stop, step, dtype=dtype)
elif isinstance(index, int):
if index < 0:
index += self._length
if index < 0 or index >= self._length:
raise IndexError(
f"{type(self).__name__} index out of range."
)
return torch.tensor(index, dtype=dtype)
elif isinstance(index, torch.Tensor):
return index.to(dtype)
else:
raise TypeError(
f"{type(self).__name__} indices must be int, slice, or "
f"torch.Tensor, not {type(index)}."
)
elif self._num_items == 1:
return self._items[0][index]
else:
return tuple(item[index] for item in self._items)
@property
def names(self) -> Tuple[str]:
"""Return the names of the items."""
return self._names
@property
def num_items(self) -> int:
"""Return the number of the items."""
return self._num_items
def __repr__(self) -> str:
ret = (
f"{self.__class__.__name__}(\n"
f" items={self._items},\n"
f" names={self._names},\n"
f")"
)
return ret
class HeteroItemSet:
r"""A collection of itemsets, each associated with a unique type.
This class aims to assemble existing itemsets with different types, for
example, seed_nodes of different node types in a graph.
Parameters
----------
itemsets: Dict[str, ItemSet]
A dictionary whose keys are types and values are ItemSet instances.
Examples
--------
>>> import torch
>>> from dgl import graphbolt as gb
1. Each itemset is a single tensor: seed nodes.
>>> node_ids_user = torch.arange(0, 5)
>>> node_ids_item = torch.arange(5, 10)
>>> item_set = gb.HeteroItemSet({
... "user": gb.ItemSet(node_ids_user, names="seeds"),
... "item": gb.ItemSet(node_ids_item, names="seeds")})
>>> list(item_set)
[{"user": tensor(0)}, {"user": tensor(1)}, {"user": tensor(2)},
{"user": tensor(3)}, {"user": tensor(4)}, {"item": tensor(5)},
{"item": tensor(6)}, {"item": tensor(7)}, {"item": tensor(8)},
{"item": tensor(9)}}]
>>> item_set[:]
{"user": tensor([0, 1, 2, 3, 4]), "item": tensor([5, 6, 7, 8, 9])}
>>> item_set.names
('seeds',)
2. Each itemset is a tuple of tensors with same shape: seed nodes and
labels.
>>> node_ids_user = torch.arange(0, 2)
>>> labels_user = torch.arange(0, 2)
>>> node_ids_item = torch.arange(2, 5)
>>> labels_item = torch.arange(2, 5)
>>> item_set = gb.HeteroItemSet({
... "user": gb.ItemSet(
... (node_ids_user, labels_user),
... names=("seeds", "labels")),
... "item": gb.ItemSet(
... (node_ids_item, labels_item),
... names=("seeds", "labels"))})
>>> list(item_set)
[{"user": (tensor(0), tensor(0))}, {"user": (tensor(1), tensor(1))},
{"item": (tensor(2), tensor(2))}, {"item": (tensor(3), tensor(3))},
{"item": (tensor(4), tensor(4))}}]
>>> item_set[:]
{"user": (tensor([0, 1]), tensor([0, 1])),
"item": (tensor([2, 3, 4]), tensor([2, 3, 4]))}
>>> item_set.names
('seeds', 'labels')
3. Each itemset is a tuple of tensors with different shape: seeds and
labels.
>>> seeds_like = torch.arange(0, 4).reshape(-1, 2)
>>> labels_like = torch.tensor([1, 0])
>>> seeds_follow = torch.arange(0, 6).reshape(-1, 2)
>>> labels_follow = torch.tensor([1, 1, 0])
>>> item_set = gb.HeteroItemSet({
... "user:like:item": gb.ItemSet(
... (seeds_like, labels_like),
... names=("seeds", "labels")),
... "user:follow:user": gb.ItemSet(
... (seeds_follow, labels_follow),
... names=("seeds", "labels"))})
>>> list(item_set)
[{'user:like:item': (tensor([0, 1]), tensor(1))},
{'user:like:item': (tensor([2, 3]), tensor(0))},
{'user:follow:user': (tensor([0, 1]), tensor(1))},
{'user:follow:user': (tensor([2, 3]), tensor(1))},
{'user:follow:user': (tensor([4, 5]), tensor(0))}]
>>> item_set[:]
{'user:like:item': (tensor([[0, 1], [2, 3]]),
tensor([1, 0])),
'user:follow:user': (tensor([[0, 1], [2, 3], [4, 5]]),
tensor([1, 1, 0]))}
>>> item_set.names
('seeds', 'labels')
4. Each itemset is a tuple of tensors with different shape: hyperlink and
labels.
>>> first_seeds = torch.arange(0, 6).reshape(-1, 3)
>>> first_labels = torch.tensor([1, 0])
>>> second_seeds = torch.arange(0, 2).reshape(-1, 1)
>>> second_labels = torch.tensor([1, 0])
>>> item_set = gb.HeteroItemSet({
... "query:user:item": gb.ItemSet(
... (first_seeds, first_labels),
... names=("seeds", "labels")),
... "user": gb.ItemSet(
... (second_seeds, second_labels),
... names=("seeds", "labels"))})
>>> list(item_set)
[{'query:user:item': (tensor([0, 1, 2]), tensor(1))},
{'query:user:item': (tensor([3, 4, 5]), tensor(0))},
{'user': (tensor([0]), tensor(1))},
{'user': (tensor([1]), tensor(0))}]
>>> item_set[:]
{'query:user:item': (tensor([[0, 1, 2], [3, 4, 5]]),
tensor([1, 0])),
'user': (tensor([[0], [1]]),tensor([1, 0]))}
>>> item_set.names
('seeds', 'labels')
"""
def __init__(self, itemsets: Dict[str, ItemSet]) -> None:
self._itemsets = itemsets
self._names = next(iter(itemsets.values())).names
assert all(
self._names == itemset.names for itemset in itemsets.values()
), "All itemsets must have the same names."
offset = [0] + [len(itemset) for itemset in self._itemsets.values()]
self._offsets = torch.tensor(offset).cumsum(0)
self._length = int(self._offsets[-1])
self._keys = list(self._itemsets.keys())
def __len__(self) -> int:
return self._length
def __getitem__(self, index: Union[int, slice, Iterable[int]]):
if isinstance(index, int):
if index < 0:
index += self._length
if index < 0 or index >= self._length:
raise IndexError(f"{type(self).__name__} index out of range.")
offset_idx = torch.searchsorted(self._offsets, index, right=True)
offset_idx -= 1
index -= self._offsets[offset_idx]
key = self._keys[offset_idx]
return {key: self._itemsets[key][index]}
elif isinstance(index, slice):
start, stop, step = index.indices(self._length)
if step != 1:
return self.__getitem__(torch.arange(start, stop, step))
assert start < stop, "Start must be smaller than stop."
data = {}
offset_idx_start = max(
1, torch.searchsorted(self._offsets, start, right=False)
)
for offset_idx in range(offset_idx_start, len(self._offsets)):
key = self._keys[offset_idx - 1]
data[key] = self._itemsets[key][
max(0, start - self._offsets[offset_idx - 1]) : stop
- self._offsets[offset_idx - 1]
]
if stop <= self._offsets[offset_idx]:
break
return data
elif isinstance(index, Iterable):
if not isinstance(index, torch.Tensor):
index = torch.tensor(index)
assert torch.all((index >= 0) & (index < self._length))
key_indices = (
torch.searchsorted(self._offsets, index, right=True) - 1
)
data = {}
for key_id, key in enumerate(self._keys):
mask = (key_indices == key_id).nonzero().squeeze(1)
if len(mask) == 0:
continue
data[key] = self._itemsets[key][
index[mask] - self._offsets[key_id]
]
return data
else:
raise TypeError(
f"{type(self).__name__} indices must be int, slice, or "
f"iterable of int, not {type(index)}."
)
@property
def names(self) -> Tuple[str]:
"""Return the names of the items."""
return self._names
def __repr__(self) -> str:
ret = (
"{Classname}(\n"
" itemsets={itemsets},\n"
" names={names},\n"
")"
)
itemsets_str = textwrap.indent(
repr(self._itemsets), " " * len(" itemsets=")
).strip()
return ret.format(
Classname=self.__class__.__name__,
itemsets=itemsets_str,
names=self._names,
)
class ItemSetDict:
"""`ItemSetDict` is a deprecated class and will be removed in a future
version. Please use `HeteroItemSet` instead.
This class is an alias for `HeteroItemSet` and serves as a wrapper to
provide a smooth transition for users of the old class name. It issues a
deprecation warning upon instantiation and forwards all attribute access
and method calls to an instance of `HeteroItemSet`.
"""
def __init__(self, itemsets: Dict[str, ItemSet]) -> None:
gb_warning(
"ItemSetDict is deprecated and will be removed in the future. "
"Please use HeteroItemSet instead.",
category=DeprecationWarning,
)
self._new_instance = HeteroItemSet(itemsets)
def __getattr__(self, name: str):
return getattr(self._new_instance, name)
def __getitem__(self, index):
return self._new_instance[index]
def __len__(self) -> int:
return len(self._new_instance)
def __repr__(self) -> str:
ret = (
"{Classname}(\n"
" itemsets={itemsets},\n"
" names={names},\n"
")"
)
itemsets_str = textwrap.indent(
repr(self._itemsets), " " * len(" itemsets=")
).strip()
return ret.format(
Classname=self.__class__.__name__,
itemsets=itemsets_str,
names=self._names,
)
+417
View File
@@ -0,0 +1,417 @@
"""Unified data structure for input and ouput of all the stages in loading process."""
from dataclasses import dataclass
from typing import Dict, List, Tuple, Union
import torch
from .base import (
apply_to,
CSCFormatBase,
etype_str_to_tuple,
expand_indptr,
is_object_pinned,
)
from .internal_utils import (
get_attributes,
get_nonproperty_attributes,
recursive_apply,
)
from .sampled_subgraph import SampledSubgraph
__all__ = ["MiniBatch"]
@dataclass
class MiniBatch:
r"""A composite data class for data structure in the graphbolt.
It is designed to facilitate the exchange of data among different components
involved in processing data. The purpose of this class is to unify the
representation of input and output data across different stages, ensuring
consistency and ease of use throughout the loading process."""
labels: Union[torch.Tensor, Dict[str, torch.Tensor]] = None
"""
Labels associated with seeds in the graph.
- If `labels` is a tensor: It indicates the graph is homogeneous. The value
should be corresponding labels to given 'seeds'.
- If `labels` is a dictionary: The keys should be node or edge type and the
value should be corresponding labels to given 'seeds'.
"""
seeds: Union[
torch.Tensor,
Dict[str, torch.Tensor],
] = None
"""
Representation of seed items utilized in node classification tasks, link
prediction tasks and hyperlinks tasks.
- If `seeds` is a tensor: it indicates that the seeds originate from a
homogeneous graph. It can be either a 1-dimensional or 2-dimensional
tensor:
- 1-dimensional tensor: Each element directly represents a seed node
within the graph.
- 2-dimensional tensor: Each row designates a seed item, which can
encompass various entities such as edges, hyperlinks, or other graph
components depending on the specific context.
- If `seeds` is a dictionary: it indicates that the seeds originate from a
heterogeneous graph. The keys should be edge or node type, and the value
should be a tensor, which can be either a 1-dimensional or 2-dimensional
tensor:
- 1-dimensional tensor: Each element directly represents a seed node
of the given type within the graph.
- 2-dimensional tensor: Each row designates a seed item of the given
type, which can encompass various entities such as edges, hyperlinks,
or other graph components depending on the specific context.
"""
indexes: Union[torch.Tensor, Dict[str, torch.Tensor]] = None
"""
Indexes associated with seeds in the graph, which
indicates to which query a seeds belongs.
- If `indexes` is a tensor: It indicates the graph is homogeneous. The
value should be corresponding query to given 'seeds'.
- If `indexes` is a dictionary: It indicates the graph is heterogeneous.
The keys should be node or edge type and the value should be
corresponding query to given 'seeds'. For each key, indexes are
consecutive integers starting from zero.
"""
sampled_subgraphs: List[SampledSubgraph] = None
"""A list of 'SampledSubgraph's, each one corresponding to one layer,
representing a subset of a larger graph structure.
"""
input_nodes: Union[torch.Tensor, Dict[str, torch.Tensor]] = None
"""A representation of input nodes in the outermost layer. Conatins all nodes
in the 'sampled_subgraphs'.
- If `input_nodes` is a tensor: It indicates the graph is homogeneous.
- If `input_nodes` is a dictionary: The keys should be node type and the
value should be corresponding heterogeneous node id.
"""
node_features: Union[
Dict[str, torch.Tensor], Dict[Tuple[str, str], torch.Tensor]
] = None
"""A representation of node features.
- If keys are single strings: It means the graph is homogeneous, and the
keys are feature names.
- If keys are tuples: It means the graph is heterogeneous, and the keys
are tuples of '(node_type, feature_name)'.
"""
edge_features: List[
Union[Dict[str, torch.Tensor], Dict[Tuple[str, str], torch.Tensor]]
] = None
"""Edge features associated with the 'sampled_subgraphs'.
- If keys are single strings: It means the graph is homogeneous, and the
keys are feature names.
- If keys are tuples: It means the graph is heterogeneous, and the keys
are tuples of '(edge_type, feature_name)'. Note, edge type is single
string of format 'str:str:str'.
"""
compacted_seeds: Union[
torch.Tensor,
Dict[str, torch.Tensor],
] = None
"""
Representation of compacted seeds corresponding to 'seeds', where
all node ids inside are compacted.
"""
_blocks: list = None
"""
A list of `DGLBlock`s.
"""
def __repr__(self) -> str:
return _minibatch_str(self)
def node_ids(self) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
"""A representation of input nodes in the outermost layer. Contains all
nodes in the `sampled_subgraphs`.
- If `input_nodes` is a tensor: It indicates the graph is homogeneous.
- If `input_nodes` is a dictionary: The keys should be node type and the
value should be corresponding heterogeneous node id.
"""
return self.input_nodes
def num_layers(self) -> int:
"""Return the number of layers."""
if self.sampled_subgraphs is None:
return 0
return len(self.sampled_subgraphs)
def edge_ids(
self, layer_id: int
) -> Union[Dict[str, torch.Tensor], torch.Tensor]:
"""Get the edge ids of a layer."""
return self.sampled_subgraphs[layer_id].original_edge_ids
def set_node_features(
self,
node_features: Union[
Dict[str, torch.Tensor], Dict[Tuple[str, str], torch.Tensor]
],
) -> None:
"""Set node features."""
self.node_features = node_features
def set_edge_features(
self,
edge_features: List[
Union[Dict[str, torch.Tensor], Dict[Tuple[str, str], torch.Tensor]]
],
) -> None:
"""Set edge features."""
self.edge_features = edge_features
@property
def blocks(self) -> list:
"""DGL blocks extracted from `MiniBatch` containing graphical structures
and ID mappings.
"""
if not self.sampled_subgraphs:
return None
if self._blocks is None:
self._blocks = self.compute_blocks()
return self._blocks
def compute_blocks(self) -> list:
"""Extracts DGL blocks from `MiniBatch` to construct graphical
structures and ID mappings.
"""
from dgl.convert import create_block, EID, NID
is_heterogeneous = isinstance(
self.sampled_subgraphs[0].sampled_csc, Dict
)
# Casts to minimum dtype in-place and returns self.
def cast_to_minimum_dtype(v: CSCFormatBase):
# Checks if number of vertices and edges fit into an int32.
dtype = (
torch.int32
if max(v.indptr.size(0) - 2, v.indices.size(0))
<= torch.iinfo(torch.int32).max
else torch.int64
)
v.indptr = v.indptr.to(dtype)
v.indices = v.indices.to(dtype)
return v
blocks = []
for subgraph in self.sampled_subgraphs:
original_row_node_ids = subgraph.original_row_node_ids
assert (
original_row_node_ids is not None
), "Missing `original_row_node_ids` in sampled subgraph."
original_column_node_ids = subgraph.original_column_node_ids
assert (
original_column_node_ids is not None
), "Missing `original_column_node_ids` in sampled subgraph."
if is_heterogeneous:
node_types = set()
sampled_csc = {}
for v in subgraph.sampled_csc.values():
cast_to_minimum_dtype(v)
for etype, v in subgraph.sampled_csc.items():
etype_tuple = etype_str_to_tuple(etype)
node_types.add(etype_tuple[0])
node_types.add(etype_tuple[2])
sampled_csc[etype_tuple] = (
"csc",
(
v.indptr,
v.indices,
torch.arange(
0,
len(v.indices),
device=v.indptr.device,
dtype=v.indptr.dtype,
),
),
)
num_src_nodes = {
ntype: (
original_row_node_ids[ntype].size(0)
if original_row_node_ids.get(ntype) is not None
else 0
)
for ntype in node_types
}
num_dst_nodes = {
ntype: (
original_column_node_ids[ntype].size(0)
if original_column_node_ids.get(ntype) is not None
else 0
)
for ntype in node_types
}
else:
sampled_csc = cast_to_minimum_dtype(subgraph.sampled_csc)
sampled_csc = (
"csc",
(
sampled_csc.indptr,
sampled_csc.indices,
torch.arange(
0,
len(sampled_csc.indices),
device=sampled_csc.indptr.device,
dtype=sampled_csc.indptr.dtype,
),
),
)
num_src_nodes = original_row_node_ids.size(0)
num_dst_nodes = original_column_node_ids.size(0)
blocks.append(
create_block(
sampled_csc,
num_src_nodes=num_src_nodes,
num_dst_nodes=num_dst_nodes,
node_count_check=False,
)
)
if is_heterogeneous:
# Assign reverse node ids to the outermost layer's source nodes.
for node_type, reverse_ids in self.sampled_subgraphs[
0
].original_row_node_ids.items():
blocks[0].srcnodes[node_type].data[NID] = reverse_ids
# Assign reverse edges ids.
for block, subgraph in zip(blocks, self.sampled_subgraphs):
if subgraph.original_edge_ids is not None:
for (
edge_type,
reverse_ids,
) in subgraph.original_edge_ids.items():
block.edges[etype_str_to_tuple(edge_type)].data[
EID
] = reverse_ids
else:
blocks[0].srcdata[NID] = self.sampled_subgraphs[
0
].original_row_node_ids
# Assign reverse edges ids.
for block, subgraph in zip(blocks, self.sampled_subgraphs):
if subgraph.original_edge_ids is not None:
block.edata[EID] = subgraph.original_edge_ids
return blocks
def to_pyg_data(self):
"""Construct a PyG Data from `MiniBatch`. This function only supports
node classification task on a homogeneous graph and the number of
features cannot be more than one.
"""
from torch_geometric.data import Data
if self.sampled_subgraphs is None:
edge_index = None
else:
col_nodes = []
row_nodes = []
for subgraph in self.sampled_subgraphs:
if subgraph is None:
continue
sampled_csc = subgraph.sampled_csc
indptr = sampled_csc.indptr
indices = sampled_csc.indices
expanded_indptr = expand_indptr(
indptr, dtype=indices.dtype, output_size=len(indices)
)
col_nodes.append(expanded_indptr)
row_nodes.append(indices)
col_nodes = torch.cat(col_nodes)
row_nodes = torch.cat(row_nodes)
edge_index = torch.unique(
torch.stack((row_nodes, col_nodes)), dim=1
).long()
if self.node_features is None:
node_features = None
else:
assert (
len(self.node_features) == 1
), "`to_pyg_data` only supports single feature homogeneous graph."
node_features = next(iter(self.node_features.values()))
if self.seeds is not None:
if isinstance(self.seeds, Dict):
batch_size = len(next(iter(self.seeds.values())))
else:
batch_size = len(self.seeds)
else:
batch_size = None
pyg_data = Data(
x=node_features,
edge_index=edge_index,
y=self.labels,
batch_size=batch_size,
n_id=self.node_ids(),
)
return pyg_data
def to(
self, device: torch.device, non_blocking=False
): # pylint: disable=invalid-name
"""Copy `MiniBatch` to the specified device using reflection."""
copy_fn = lambda x: apply_to(x, device, non_blocking=non_blocking)
transfer_attrs = get_nonproperty_attributes(self)
for attr in transfer_attrs:
# Only copy member variables.
setattr(self, attr, recursive_apply(getattr(self, attr), copy_fn))
return self
def pin_memory(self):
"""Copy `MiniBatch` to the pinned memory using reflection."""
return self.to("pinned")
def is_pinned(self) -> bool:
"""Check whether `SampledSubgraph` is pinned using reflection."""
return is_object_pinned(self)
def _minibatch_str(minibatch: MiniBatch) -> str:
final_str = ""
# Get all attributes in the class except methods.
attributes = get_attributes(minibatch)
attributes.reverse()
# Insert key with its value into the string.
for name in attributes:
if name[0] == "_":
continue
val = getattr(minibatch, name)
def _add_indent(_str, indent):
lines = _str.split("\n")
lines = [lines[0]] + [
" " * (indent + 10) + line for line in lines[1:]
]
return "\n".join(lines)
# Let the variables in the list occupy one line each, and adjust the
# indentation on top of the original if the original data output has
# line feeds.
if isinstance(val, list):
val = [str(val_str) for val_str in val]
val = "[" + ",\n".join(val) + "]"
elif isinstance(val, tuple):
val = [str(val_str) for val_str in val]
val = "(" + ",\n".join(val) + ")"
else:
val = str(val)
final_str = (
final_str + f"{name}={_add_indent(val, len(name)+1)},\n" + " " * 10
)
return "MiniBatch(" + final_str[:-3] + ")"
@@ -0,0 +1,46 @@
"""Mini-batch transformer"""
from torch.utils.data import functional_datapipe
from torch.utils.data.datapipes.iter import Mapper
from .minibatch import MiniBatch
__all__ = [
"MiniBatchTransformer",
]
@functional_datapipe("transform")
class MiniBatchTransformer(Mapper):
"""A mini-batch transformer used to manipulate mini-batch.
Functional name: :obj:`transform`.
Parameters
----------
datapipe : DataPipe
The datapipe.
transformer:
The function applied to each minibatch which is responsible for
transforming the minibatch.
"""
def __init__(
self,
datapipe,
transformer=None,
):
super().__init__(datapipe, self._transformer)
self.transformer = transformer or self._identity
def _transformer(self, minibatch):
minibatch = self.transformer(minibatch)
assert isinstance(
minibatch, (MiniBatch,)
), "The transformer output should be an instance of MiniBatch"
return minibatch
@staticmethod
def _identity(minibatch):
return minibatch
+104
View File
@@ -0,0 +1,104 @@
"""Negative samplers."""
from _collections_abc import Mapping
from torch.utils.data import functional_datapipe
from .minibatch_transformer import MiniBatchTransformer
__all__ = [
"NegativeSampler",
]
@functional_datapipe("sample_negative")
class NegativeSampler(MiniBatchTransformer):
"""
A negative sampler used to generate negative samples and return
a mix of positive and negative samples.
Functional name: :obj:`sample_negative`.
Parameters
----------
datapipe : DataPipe
The datapipe.
negative_ratio : int
The proportion of negative samples to positive samples.
"""
def __init__(
self,
datapipe,
negative_ratio,
):
super().__init__(datapipe, self._sample)
assert negative_ratio > 0, "Negative_ratio should be positive Integer."
self.negative_ratio = negative_ratio
def _sample(self, minibatch):
"""
Generate a mix of positive and negative samples. If `seeds` in
minibatch is not None, `labels` and `indexes` will be constructed
after negative sampling, based on corresponding seeds.
Parameters
----------
minibatch : MiniBatch
An instance of 'MiniBatch' class requires the 'seeds' field. This
function is responsible for generating negative edges corresponding
to the positive edges defined by the 'seeds'.
Returns
-------
MiniBatch
An instance of 'MiniBatch' encompasses both positive and negative
samples.
"""
seeds = minibatch.seeds
if isinstance(seeds, Mapping):
if minibatch.indexes is None:
minibatch.indexes = {}
if minibatch.labels is None:
minibatch.labels = {}
for etype, pos_pairs in seeds.items():
(
minibatch.seeds[etype],
minibatch.labels[etype],
minibatch.indexes[etype],
) = self._sample_with_etype(pos_pairs, etype)
else:
(
minibatch.seeds,
minibatch.labels,
minibatch.indexes,
) = self._sample_with_etype(seeds)
return minibatch
def _sample_with_etype(self, seeds, etype=None):
"""Generate negative pairs for a given etype form positive pairs
for a given etype. If `seeds` is a 2D tensor, which represents
`seeds` is used in minibatch, corresponding labels and indexes will be
constructed.
Parameters
----------
seeds : Tensor, Tensor
A N*2 tensors that represent source-destination node pairs of
positive edges, where positive means the edge must exist in the
graph.
etype : str
Canonical edge type.
Returns
-------
Tensor
A collection of postive and negative node pairs.
Tensor
Corresponding labels. If label is True, corresponding edge is
positive. If label is False, corresponding edge is negative.
Tensor
Corresponding indexes, indicates to which query an edge belongs.
"""
raise NotImplementedError
+450
View File
@@ -0,0 +1,450 @@
"""Graphbolt sampled subgraph."""
# pylint: disable= invalid-name
from typing import Dict, NamedTuple, Tuple, Union
import torch
from .base import (
apply_to,
CSCFormatBase,
etype_str_to_tuple,
expand_indptr,
is_object_pinned,
isin,
)
from .internal_utils import recursive_apply
__all__ = ["SampledSubgraph"]
class _ExcludeEdgesWaiter:
def __init__(self, sampled_subgraph, index):
self.sampled_subgraph = sampled_subgraph
self.index = index
def wait(self):
"""Returns the stored value when invoked."""
sampled_subgraph = self.sampled_subgraph
index = self.index
# Ensure there is no memory leak.
self.sampled_subgraph = self.index = None
if isinstance(index, dict):
for k in list(index.keys()):
index[k] = index[k].wait()
else:
index = index.wait()
return type(sampled_subgraph)(*_slice_subgraph(sampled_subgraph, index))
class PyGLayerData(NamedTuple):
"""A named tuple class to represent homogenous inputs to a PyG model layer.
The fields are x (input features), edge_index and size
(source and destination sizes).
"""
x: torch.Tensor
edge_index: torch.Tensor
size: Tuple[int, int]
class PyGLayerHeteroData(NamedTuple):
"""A named tuple class to represent heterogenous inputs to a PyG model
layer. The fields are x (input features), edge_index and size
(source and destination sizes), and all fields are dictionaries.
"""
x: Dict[str, torch.Tensor]
edge_index: Dict[str, torch.Tensor]
size: Dict[str, Tuple[int, int]]
class SampledSubgraph:
r"""An abstract class for sampled subgraph. In the context of a
heterogeneous graph, each field should be of `Dict` type. Otherwise,
for homogeneous graphs, each field should correspond to its respective
value type."""
@property
def sampled_csc(
self,
) -> Union[CSCFormatBase, Dict[str, CSCFormatBase],]:
"""Returns the node pairs representing edges in csc format.
- If `sampled_csc` is a CSCFormatBase: It should be in the csc
format. `indptr` stores the index in the data array where each
column starts. `indices` stores the row indices of the non-zero
elements.
- If `sampled_csc` is a dictionary: The keys should be edge type and
the values should be corresponding node pairs. The ids inside is
heterogeneous ids.
Examples
--------
1. Homogeneous graph.
>>> import dgl.graphbolt as gb
>>> import torch
>>> sampled_csc = gb.CSCFormatBase(
... indptr=torch.tensor([0, 1, 2, 3]),
... indices=torch.tensor([0, 1, 2]))
>>> print(sampled_csc)
CSCFormatBase(indptr=tensor([0, 1, 2, 3]),
indices=tensor([0, 1, 2]),
)
2. Heterogeneous graph.
>>> sampled_csc = {"A:relation:B": gb.CSCFormatBase(
... indptr=torch.tensor([0, 1, 2, 3]),
... indices=torch.tensor([0, 1, 2]))}
>>> print(sampled_csc)
{'A:relation:B': CSCFormatBase(indptr=tensor([0, 1, 2, 3]),
indices=tensor([0, 1, 2]),
)}
"""
raise NotImplementedError
@property
def original_column_node_ids(
self,
) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
"""Returns corresponding reverse column node ids the original graph.
Column's reverse node ids in the original graph. A graph structure
can be treated as a coordinated row and column pair, and this is
the mapped ids of the column.
- If `original_column_node_ids` is a tensor: It represents the
original node ids.
- If `original_column_node_ids` is a dictionary: The keys should be
node type and the values should be corresponding original
heterogeneous node ids.
If present, it means column IDs are compacted, and `sampled_csc`
column IDs match these compacted ones.
"""
return None
@property
def original_row_node_ids(
self,
) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
"""Returns corresponding reverse row node ids the original graph.
Row's reverse node ids in the original graph. A graph structure
can be treated as a coordinated row and column pair, and this is
the mapped ids of the row.
- If `original_row_node_ids` is a tensor: It represents the original
node ids.
- If `original_row_node_ids` is a dictionary: The keys should be node
type and the values should be corresponding original heterogeneous
node ids.
If present, it means row IDs are compacted, and `sampled_csc`
row IDs match these compacted ones."""
return None
@property
def original_edge_ids(self) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
"""Returns corresponding reverse edge ids the original graph.
Reverse edge ids in the original graph. This is useful when edge
features are needed.
- If `original_edge_ids` is a tensor: It represents the original edge
ids.
- If `original_edge_ids` is a dictionary: The keys should be edge
type and the values should be corresponding original heterogeneous
edge ids.
"""
return None
def exclude_edges(
self,
edges: Union[
Dict[str, torch.Tensor],
torch.Tensor,
],
assume_num_node_within_int32: bool = True,
async_op: bool = False,
):
r"""Exclude edges from the sampled subgraph.
This function can be used with sampled subgraphs, regardless of
whether they have compacted row/column nodes or not. If the original
subgraph has compacted row or column nodes, the corresponding row or
column nodes in the returned subgraph will also be compacted.
Parameters
----------
self : SampledSubgraph
The sampled subgraph.
edges : Union[torch.Tensor, Dict[str, torch.Tensor]]
Edges to exclude. If sampled subgraph is homogeneous, then `edges`
should be a N*2 tensors representing the edges to exclude. If
sampled subgraph is heterogeneous, then `edges` should be a
dictionary of edge types and the corresponding edges to exclude.
assume_num_node_within_int32: bool
If True, assumes the value of node IDs in the provided `edges` fall
within the int32 range, which can significantly enhance computation
speed. Default: True
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
-------
SampledSubgraph
An instance of a class that inherits from `SampledSubgraph`.
Examples
--------
>>> import dgl.graphbolt as gb
>>> import torch
>>> sampled_csc = {"A:relation:B": gb.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
... )
>>> edges_to_exclude = {"A:relation:B": torch.tensor([[14, 11], [15, 12]])}
>>> result = subgraph.exclude_edges(edges_to_exclude)
>>> print(result.sampled_csc)
{'A:relation:B': CSCFormatBase(indptr=tensor([0, 1, 1, 1]),
indices=tensor([0]),
)}
>>> print(result.original_column_node_ids)
{'B': tensor([10, 11, 12])}
>>> print(result.original_row_node_ids)
{'A': tensor([13, 14, 15])}
>>> print(result.original_edge_ids)
{'A:relation:B': tensor([19])}
"""
# TODO: Add support for value > in32, then remove this line.
assert (
assume_num_node_within_int32
), "Values > int32 are not supported yet."
assert (isinstance(self.sampled_csc, CSCFormatBase)) == isinstance(
edges, torch.Tensor
), (
"The sampled subgraph and the edges to exclude should be both "
"homogeneous or both heterogeneous."
)
# Get type of calling class.
calling_class = type(self)
# Three steps to exclude edges:
# 1. Convert the node pairs to the original ids if they are compacted.
# 2. Exclude the edges and get the index of the edges to keep.
# 3. Slice the subgraph according to the index.
if isinstance(self.sampled_csc, CSCFormatBase):
reverse_edges = _to_reverse_ids(
self.sampled_csc,
self.original_row_node_ids,
self.original_column_node_ids,
)
index = _exclude_homo_edges(
reverse_edges, edges, assume_num_node_within_int32, async_op
)
else:
index = {}
for etype, pair in self.sampled_csc.items():
if etype not in edges:
# No edges need to be excluded.
index[etype] = None
continue
src_type, _, dst_type = etype_str_to_tuple(etype)
original_row_node_ids = (
None
if self.original_row_node_ids is None
else self.original_row_node_ids.get(src_type)
)
original_column_node_ids = (
None
if self.original_column_node_ids is None
else self.original_column_node_ids.get(dst_type)
)
reverse_edges = _to_reverse_ids(
pair,
original_row_node_ids,
original_column_node_ids,
)
index[etype] = _exclude_homo_edges(
reverse_edges,
edges[etype],
assume_num_node_within_int32,
async_op,
)
if async_op:
return _ExcludeEdgesWaiter(self, index)
else:
return calling_class(*_slice_subgraph(self, index))
def to_pyg(
self, x: Union[torch.Tensor, Dict[str, torch.Tensor]]
) -> Union[PyGLayerData, PyGLayerHeteroData]:
"""
Process layer inputs so that they can be consumed by a PyG model layer.
Parameters
----------
x : Union[torch.Tensor, Dict[str, torch.Tensor]]
The input node features to the GNN layer.
Returns
-------
Union[PyGLayerData, PyGLayerHeteroData]
A named tuple class with `x`, `edge_index` and `size` fields.
Typically, a PyG GNN layer's forward method will accept these as
arguments.
"""
if isinstance(x, torch.Tensor):
# Homogenous
src = self.sampled_csc.indices
dst = expand_indptr(
self.sampled_csc.indptr,
dtype=src.dtype,
output_size=src.size(0),
)
edge_index = torch.stack([src, dst], dim=0).long()
dst_size = self.sampled_csc.indptr.size(0) - 1
# h and h[:dst_size] correspond to source and destination features resp.
return PyGLayerData(
(x, x[:dst_size]), edge_index, (x.size(0), dst_size)
)
else:
# Heterogenous
x_dst_dict = {}
edge_index_dict = {}
sizes_dict = {}
for etype, sampled_csc in self.sampled_csc.items():
src = sampled_csc.indices
dst = expand_indptr(
sampled_csc.indptr,
dtype=src.dtype,
output_size=src.size(0),
)
edge_index = torch.stack([src, dst], dim=0).long()
dst_size = sampled_csc.indptr.size(0) - 1
# h and h[:dst_size] correspond to source and destination features resp.
src_ntype, _, dst_ntype = etype_str_to_tuple(etype)
x_dst_dict[dst_ntype] = x[dst_ntype][:dst_size]
edge_index_dict[etype] = edge_index
sizes_dict[etype] = (x[src_ntype].size(0), dst_size)
return PyGLayerHeteroData(
(x, x_dst_dict), edge_index_dict, sizes_dict
)
def to(
self, device: torch.device, non_blocking=False
) -> None: # pylint: disable=invalid-name
"""Copy `SampledSubgraph` to the specified device using reflection."""
for attr in dir(self):
# Only copy member variables.
if not callable(getattr(self, attr)) and not attr.startswith("__"):
setattr(
self,
attr,
recursive_apply(
getattr(self, attr),
apply_to,
device,
non_blocking=non_blocking,
),
)
return self
def pin_memory(self):
"""Copy `SampledSubgraph` to the pinned memory using reflection."""
return self.to("pinned")
def is_pinned(self) -> bool:
"""Check whether `SampledSubgraph` is pinned using reflection."""
return is_object_pinned(self)
def _to_reverse_ids(node_pair, original_row_node_ids, original_column_node_ids):
indptr = node_pair.indptr
indices = node_pair.indices
if original_row_node_ids is not None:
indices = torch.index_select(
original_row_node_ids, dim=0, index=indices
)
indptr = expand_indptr(
indptr, indices.dtype, original_column_node_ids, len(indices)
)
return (indices, indptr)
def _relabel_two_arrays(lhs_array, rhs_array):
"""Relabel two arrays into a consecutive range starting from 0."""
concated = torch.cat([lhs_array, rhs_array])
_, mapping = torch.unique(concated, return_inverse=True)
return mapping[: lhs_array.numel()], mapping[lhs_array.numel() :]
def _exclude_homo_edges(
edges: Tuple[torch.Tensor, torch.Tensor],
edges_to_exclude: torch.Tensor,
assume_num_node_within_int32: bool,
async_op: bool,
):
"""Return the indices of edges to be included."""
if assume_num_node_within_int32:
val = edges[0].long() << 32 | edges[1].long()
edges_to_exclude_trans = edges_to_exclude.T
val_to_exclude = (
edges_to_exclude_trans[0].long() << 32
| edges_to_exclude_trans[1].long()
)
else:
# TODO: Add support for value > int32.
raise NotImplementedError(
"Values out of range int32 are not supported yet"
)
if async_op:
return torch.ops.graphbolt.is_not_in_index_async(val, val_to_exclude)
else:
mask = ~isin(val, val_to_exclude)
return torch.nonzero(mask, as_tuple=True)[0]
def _slice_subgraph(subgraph: SampledSubgraph, index: torch.Tensor):
"""Slice the subgraph according to the index."""
def _index_select(obj, index):
if obj is None:
return None
if index is None:
return obj
if isinstance(obj, CSCFormatBase):
new_indices = obj.indices[index]
new_indptr = torch.searchsorted(index, obj.indptr)
return CSCFormatBase(
indptr=new_indptr,
indices=new_indices,
)
if isinstance(obj, torch.Tensor):
return obj[index]
# Handle the case when obj is a dictionary.
assert isinstance(obj, dict)
assert isinstance(index, dict)
ret = {}
for k, v in obj.items():
ret[k] = _index_select(v, index[k])
return ret
return (
_index_select(subgraph.sampled_csc, index),
subgraph.original_column_node_ids,
subgraph.original_row_node_ids,
_index_select(subgraph.original_edge_ids, index),
)
+86
View File
@@ -0,0 +1,86 @@
"""Sampling Graphs."""
from typing import Dict, Union
import torch
__all__ = ["SamplingGraph"]
class SamplingGraph:
r"""Class for sampling graph."""
def __init__(self):
pass
def __repr__(self) -> str:
"""Return a string representation of the graph.
Returns
-------
str
String representation of the graph.
"""
raise NotImplementedError
@property
def num_nodes(self) -> Union[int, Dict[str, int]]:
"""The number of nodes in the graph.
- If the graph is homogenous, returns an integer.
- If the graph is heterogenous, returns a dictionary.
Returns
-------
Union[int, Dict[str, int]]
The number of nodes. Integer indicates the total nodes number of a
homogenous graph; dict indicates nodes number per node types of a
heterogenous graph.
"""
raise NotImplementedError
@property
def num_edges(self) -> Union[int, Dict[str, int]]:
"""The number of edges in the graph.
- If the graph is homogenous, returns an integer.
- If the graph is heterogenous, returns a dictionary.
Returns
-------
Union[int, Dict[str, int]]
The number of edges. Integer indicates the total edges number of a
homogenous graph; dict indicates edges number per edge types of a
heterogenous graph.
"""
raise NotImplementedError
def copy_to_shared_memory(self, shared_memory_name: str) -> "SamplingGraph":
"""Copy the graph to shared memory.
Parameters
----------
shared_memory_name : str
Name of the shared memory.
Returns
-------
SamplingGraph
The copied SamplingGraph object on shared memory.
"""
raise NotImplementedError
# pylint: disable=invalid-name
def to(self, device: torch.device) -> "SamplingGraph":
"""Copy graph to the specified device.
Parameters
----------
device : torch.device
The destination device.
Returns
-------
SamplingGraph
The graph on the specified device.
"""
raise NotImplementedError
+591
View File
@@ -0,0 +1,591 @@
"""Subgraph samplers"""
from collections import defaultdict
from functools import partial
from typing import Dict
import torch
import torch.distributed as thd
from torch.utils.data import functional_datapipe
from .base import seed_type_str_to_ntypes
from .internal import compact_temporal_nodes, unique_and_compact
from .minibatch import MiniBatch
from .minibatch_transformer import MiniBatchTransformer
__all__ = [
"SubgraphSampler",
"all_to_all",
"convert_to_hetero",
"revert_to_homo",
]
class _NoOpWaiter:
def __init__(self, result):
self.result = result
def wait(self):
"""Returns the stored value when invoked."""
result = self.result
# Ensure there is no memory leak.
self.result = None
return result
def _shift(inputs: list, group=None):
cutoff = len(inputs) - thd.get_rank(group)
return inputs[cutoff:] + inputs[:cutoff]
def all_to_all(outputs, inputs, group=None, async_op=False):
"""Wrapper for thd.all_to_all that permuted outputs and inputs before
calling it. The arguments have the permutation
`rank, ..., world_size - 1, 0, ..., rank - 1` and we make it
`0, world_size - 1` before calling `thd.all_to_all`."""
shift_fn = partial(_shift, group=group)
outputs = shift_fn(list(outputs))
inputs = shift_fn(list(inputs))
if outputs[0].is_cuda:
return thd.all_to_all(outputs, inputs, group, async_op)
# gloo backend will be used.
outputs_single = torch.cat(outputs)
output_split_sizes = [o.size(0) for o in outputs]
handle = thd.all_to_all_single(
outputs_single,
torch.cat(inputs),
output_split_sizes,
[i.size(0) for i in inputs],
group,
async_op,
)
temp_outputs = outputs_single.split(output_split_sizes)
class _Waiter:
def __init__(self, handle, outputs, temp_outputs):
self.handle = handle
self.outputs = outputs
self.temp_outputs = temp_outputs
def wait(self):
"""Returns the stored value when invoked."""
handle = self.handle
outputs = self.outputs
temp_outputs = self.temp_outputs
# Ensure that there is no leak
self.handle = self.outputs = self.temp_outputs = None
if handle is not None:
handle.wait()
for output, temp_output in zip(outputs, temp_outputs):
output.copy_(temp_output)
post_processor = _Waiter(handle, outputs, temp_outputs)
return post_processor if async_op else post_processor.wait()
def revert_to_homo(d: dict):
"""Utility function to convert a dictionary that stores homogenous data."""
is_homogenous = len(d) == 1 and "_N" in d
return list(d.values())[0] if is_homogenous else d
def convert_to_hetero(item):
"""Utility function to convert homogenous data to heterogenous with a single
node type."""
is_heterogenous = isinstance(item, dict)
return item if is_heterogenous else {"_N": item}
@functional_datapipe("sample_subgraph")
class SubgraphSampler(MiniBatchTransformer):
"""A subgraph sampler used to sample a subgraph from a given set of nodes
from a larger graph.
Functional name: :obj:`sample_subgraph`.
This class is the base class of all subgraph samplers. Any subclass of
SubgraphSampler should implement either the :meth:`sample_subgraphs` method
or the :meth:`sampling_stages` method to define the fine-grained sampling
stages to take advantage of optimizations provided by the GraphBolt
DataLoader.
Parameters
----------
datapipe : DataPipe
The datapipe.
args : Non-Keyword Arguments
Arguments to be passed into sampling_stages.
kwargs : Keyword Arguments
Arguments to be passed into sampling_stages. Preprocessing stage makes
use of the `asynchronous` and `cooperative` parameters before they are
passed to the sampling stages.
"""
def __init__(
self,
datapipe,
*args,
**kwargs,
):
async_op = kwargs.get("asynchronous", False)
cooperative = kwargs.get("cooperative", False)
preprocess_fn = partial(
self._preprocess, cooperative=cooperative, async_op=async_op
)
datapipe = datapipe.transform(preprocess_fn)
if async_op:
fn = partial(self._wait_preprocess_future, cooperative=cooperative)
datapipe = datapipe.buffer().transform(fn)
if cooperative:
datapipe = datapipe.transform(self._seeds_cooperative_exchange_1)
datapipe = datapipe.buffer()
datapipe = datapipe.transform(
self._seeds_cooperative_exchange_1_wait_future
).buffer()
datapipe = datapipe.transform(self._seeds_cooperative_exchange_2)
datapipe = datapipe.buffer()
datapipe = datapipe.transform(self._seeds_cooperative_exchange_3)
datapipe = datapipe.buffer()
datapipe = datapipe.transform(self._seeds_cooperative_exchange_4)
datapipe = self.sampling_stages(datapipe, *args, **kwargs)
datapipe = datapipe.transform(self._postprocess)
super().__init__(datapipe)
@staticmethod
def _postprocess(minibatch):
delattr(minibatch, "_seed_nodes")
delattr(minibatch, "_seeds_timestamp")
return minibatch
@staticmethod
def _preprocess(minibatch, cooperative: bool, async_op: bool):
if minibatch.seeds is None:
raise ValueError(
f"Invalid minibatch {minibatch}: `seeds` should have a value."
)
rank = thd.get_rank() if cooperative else 0
world_size = thd.get_world_size() if cooperative else 1
results = SubgraphSampler._seeds_preprocess(
minibatch, rank, world_size, async_op
)
if async_op:
minibatch._preprocess_future = results
else:
(
minibatch._seed_nodes,
minibatch._seeds_timestamp,
minibatch.compacted_seeds,
offsets,
) = results
if cooperative:
minibatch._seeds_offsets = offsets
return minibatch
@staticmethod
def _wait_preprocess_future(minibatch, cooperative: bool):
(
minibatch._seed_nodes,
minibatch._seeds_timestamp,
minibatch.compacted_seeds,
offsets,
) = minibatch._preprocess_future.wait()
delattr(minibatch, "_preprocess_future")
if cooperative:
minibatch._seeds_offsets = offsets
return minibatch
@staticmethod
def _seeds_cooperative_exchange_1(minibatch):
rank = thd.get_rank()
world_size = thd.get_world_size()
seeds = minibatch._seed_nodes
is_homogeneous = not isinstance(seeds, dict)
if is_homogeneous:
seeds = {"_N": seeds}
if minibatch._seeds_offsets is None:
assert minibatch.compacted_seeds is None
minibatch._rank_sort_future = torch.ops.graphbolt.rank_sort_async(
list(seeds.values()), rank, world_size
)
return minibatch
@staticmethod
def _seeds_cooperative_exchange_1_wait_future(minibatch):
world_size = thd.get_world_size()
seeds = minibatch._seed_nodes
is_homogeneous = not isinstance(seeds, dict)
if is_homogeneous:
seeds = {"_N": seeds}
num_ntypes = len(seeds.keys())
if minibatch._seeds_offsets is None:
result = minibatch._rank_sort_future.wait()
delattr(minibatch, "_rank_sort_future")
sorted_seeds, sorted_compacted, sorted_offsets = {}, {}, {}
for i, (
seed_type,
(typed_sorted_seeds, typed_index, typed_offsets),
) in enumerate(zip(seeds.keys(), result)):
sorted_seeds[seed_type] = typed_sorted_seeds
sorted_compacted[seed_type] = typed_index
sorted_offsets[seed_type] = typed_offsets
minibatch._seed_nodes = sorted_seeds
minibatch.compacted_seeds = revert_to_homo(sorted_compacted)
minibatch._seeds_offsets = sorted_offsets
else:
minibatch._seeds_offsets = {"_N": minibatch._seeds_offsets}
counts_sent = torch.empty(world_size * num_ntypes, dtype=torch.int64)
for i, offsets in enumerate(minibatch._seeds_offsets.values()):
counts_sent[
torch.arange(i, world_size * num_ntypes, num_ntypes)
] = offsets.diff()
delattr(minibatch, "_seeds_offsets")
counts_received = torch.empty_like(counts_sent)
minibatch._counts_future = all_to_all(
counts_received.split(num_ntypes),
counts_sent.split(num_ntypes),
async_op=True,
)
minibatch._counts_sent = counts_sent
minibatch._counts_received = counts_received
return minibatch
@staticmethod
def _seeds_cooperative_exchange_2(minibatch):
world_size = thd.get_world_size()
seeds = minibatch._seed_nodes
minibatch._counts_future.wait()
delattr(minibatch, "_counts_future")
num_ntypes = len(seeds.keys())
seeds_received = {}
counts_sent = {}
counts_received = {}
for i, (ntype, typed_seeds) in enumerate(seeds.items()):
idx = torch.arange(i, world_size * num_ntypes, num_ntypes)
typed_counts_sent = minibatch._counts_sent[idx].tolist()
typed_counts_received = minibatch._counts_received[idx].tolist()
typed_seeds_received = typed_seeds.new_empty(
sum(typed_counts_received)
)
all_to_all(
typed_seeds_received.split(typed_counts_received),
typed_seeds.split(typed_counts_sent),
)
seeds_received[ntype] = typed_seeds_received
counts_sent[ntype] = typed_counts_sent
counts_received[ntype] = typed_counts_received
minibatch._seed_nodes = seeds_received
minibatch._counts_sent = revert_to_homo(counts_sent)
minibatch._counts_received = revert_to_homo(counts_received)
return minibatch
@staticmethod
def _seeds_cooperative_exchange_3(minibatch):
nodes = {
ntype: [typed_seeds]
for ntype, typed_seeds in minibatch._seed_nodes.items()
}
minibatch._unique_future = unique_and_compact(
nodes, 0, 1, async_op=True
)
return minibatch
@staticmethod
def _seeds_cooperative_exchange_4(minibatch):
unique_seeds, inverse_seeds, _ = minibatch._unique_future.wait()
delattr(minibatch, "_unique_future")
inverse_seeds = {
ntype: typed_inv[0] for ntype, typed_inv in inverse_seeds.items()
}
minibatch._seed_nodes = revert_to_homo(unique_seeds)
sizes = {
ntype: typed_seeds.size(0)
for ntype, typed_seeds in unique_seeds.items()
}
minibatch._seed_sizes = revert_to_homo(sizes)
minibatch._seed_inverse_ids = revert_to_homo(inverse_seeds)
return minibatch
def _sample(self, minibatch):
(
minibatch.input_nodes,
minibatch.sampled_subgraphs,
) = self.sample_subgraphs(
minibatch._seed_nodes, minibatch._seeds_timestamp
)
return minibatch
def sampling_stages(self, datapipe):
"""The sampling stages are defined here by chaining to the datapipe. The
default implementation expects :meth:`sample_subgraphs` to be
implemented. To define fine-grained stages, this method should be
overridden.
"""
return datapipe.transform(self._sample)
@staticmethod
def _seeds_preprocess(
minibatch: MiniBatch,
rank: int = 0,
world_size: int = 1,
async_op: bool = False,
):
"""Preprocess `seeds` in a minibatch to construct `unique_seeds`,
`node_timestamp` and `compacted_seeds` for further sampling. It
optionally incorporates timestamps for temporal graphs, organizing and
compacting seeds based on their types and timestamps. In heterogeneous
graph, `seeds` with same node type will be unqiued together.
Parameters
----------
minibatch: MiniBatch
The minibatch.
rank : int
The rank of the current process among cooperating processes.
world_size : int
The number of cooperating
(`arXiv:2210.13339<https://arxiv.org/abs/2310.12403>`__) processes.
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
-------
unique_seeds: torch.Tensor or Dict[str, torch.Tensor]
A tensor or a dictionary of tensors representing the unique seeds.
In heterogeneous graphs, seeds are returned for each node type.
nodes_timestamp: None or a torch.Tensor or Dict[str, torch.Tensor]
Containing timestamps for each seed. This is only returned if
`minibatch` includes timestamps and the graph is temporal.
compacted_seeds: torch.tensor or a Dict[str, torch.Tensor]
Representation of compacted seeds corresponding to 'seeds', where
all node ids inside are compacted.
offsets: None or torch.Tensor or Dict[src, torch.Tensor]
The unique nodes offsets tensor partitions the unique_nodes tensor.
Has size `world_size + 1` and
`unique_nodes[offsets[i]: offsets[i + 1]]` belongs to the rank
`(rank + i) % world_size`.
"""
use_timestamp = hasattr(minibatch, "timestamp")
assert (
not use_timestamp or world_size == 1
), "Temporal code path does not currently support Cooperative Minibatching"
seeds = minibatch.seeds
is_heterogeneous = isinstance(seeds, Dict)
if is_heterogeneous:
# Collect nodes from all types of input.
nodes = defaultdict(list)
nodes_timestamp = None
if use_timestamp:
nodes_timestamp = defaultdict(list)
for seed_type, typed_seeds in seeds.items():
# When typed_seeds is a one-dimensional tensor, it represents
# seed nodes, which does not need to do unique and compact.
if typed_seeds.ndim == 1:
nodes_timestamp = (
minibatch.timestamp
if hasattr(minibatch, "timestamp")
else None
)
result = _NoOpWaiter((seeds, nodes_timestamp, None, None))
break
result = None
assert typed_seeds.ndim == 2, (
"Only tensor with shape 1*N and N*M is "
+ f"supported now, but got {typed_seeds.shape}."
)
ntypes = seed_type_str_to_ntypes(
seed_type, typed_seeds.shape[1]
)
if use_timestamp:
negative_ratio = (
typed_seeds.shape[0]
// minibatch.timestamp[seed_type].shape[0]
- 1
)
neg_timestamp = minibatch.timestamp[
seed_type
].repeat_interleave(negative_ratio)
for i, ntype in enumerate(ntypes):
nodes[ntype].append(typed_seeds[:, i])
if use_timestamp:
nodes_timestamp[ntype].append(
minibatch.timestamp[seed_type]
)
nodes_timestamp[ntype].append(neg_timestamp)
class _Waiter:
def __init__(self, nodes, nodes_timestamp, seeds):
# Unique and compact the collected nodes.
if use_timestamp:
self.future = compact_temporal_nodes(
nodes, nodes_timestamp
)
else:
self.future = unique_and_compact(
nodes, rank, world_size, async_op
)
self.seeds = seeds
def wait(self):
"""Returns the stored value when invoked."""
if use_timestamp:
unique_seeds, nodes_timestamp, compacted = self.future
offsets = None
else:
unique_seeds, compacted, offsets = (
self.future.wait() if async_op else self.future
)
nodes_timestamp = None
seeds = self.seeds
# Ensure there is no memory leak.
self.future = self.seeds = None
compacted_seeds = {}
# Map back in same order as collect.
for seed_type, typed_seeds in seeds.items():
ntypes = seed_type_str_to_ntypes(
seed_type, typed_seeds.shape[1]
)
compacted_seed = []
for ntype in ntypes:
compacted_seed.append(compacted[ntype].pop(0))
compacted_seeds[seed_type] = (
torch.cat(compacted_seed).view(len(ntypes), -1).T
)
return (
unique_seeds,
nodes_timestamp,
compacted_seeds,
offsets,
)
# When typed_seeds is not a one-dimensional tensor
if result is None:
result = _Waiter(nodes, nodes_timestamp, seeds)
else:
# When seeds is a one-dimensional tensor, it represents seed nodes,
# which does not need to do unique and compact.
if seeds.ndim == 1:
nodes_timestamp = (
minibatch.timestamp
if hasattr(minibatch, "timestamp")
else None
)
result = _NoOpWaiter((seeds, nodes_timestamp, None, None))
else:
# Collect nodes from all types of input.
nodes = [seeds.view(-1)]
nodes_timestamp = None
if use_timestamp:
# Timestamp for source and destination nodes are the same.
negative_ratio = (
seeds.shape[0] // minibatch.timestamp.shape[0] - 1
)
neg_timestamp = minibatch.timestamp.repeat_interleave(
negative_ratio
)
seeds_timestamp = torch.cat(
(minibatch.timestamp, neg_timestamp)
)
nodes_timestamp = [
seeds_timestamp for _ in range(seeds.shape[1])
]
class _Waiter:
def __init__(self, nodes, nodes_timestamp, seeds):
# Unique and compact the collected nodes.
if use_timestamp:
self.future = compact_temporal_nodes(
nodes, nodes_timestamp
)
else:
self.future = unique_and_compact(
nodes, async_op=async_op
)
self.seeds = seeds
def wait(self):
"""Returns the stored value when invoked."""
if use_timestamp:
(
unique_seeds,
nodes_timestamp,
compacted,
) = self.future
offsets = None
else:
unique_seeds, compacted, offsets = (
self.future.wait() if async_op else self.future
)
nodes_timestamp = None
seeds = self.seeds
# Ensure there is no memory leak.
self.future = self.seeds = None
# Map back in same order as collect.
compacted_seeds = compacted[0].view(seeds.shape)
return (
unique_seeds,
nodes_timestamp,
compacted_seeds,
offsets,
)
result = _Waiter(nodes, nodes_timestamp, seeds)
return result if async_op else result.wait()
def sample_subgraphs(
self, seeds, seeds_timestamp, seeds_pre_time_window=None
):
"""Sample subgraphs from the given seeds, possibly with temporal constraints.
Any subclass of SubgraphSampler should implement this method.
Parameters
----------
seeds : Union[torch.Tensor, Dict[str, torch.Tensor]]
The seed nodes.
seeds_timestamp : Union[torch.Tensor, Dict[str, torch.Tensor]]
The timestamps of the seed nodes. If given, the sampled subgraphs
should not contain any nodes or edges that are newer than the
timestamps of the seed nodes. Default: None.
seeds_pre_time_window : Union[torch.Tensor, Dict[str, torch.Tensor]]
The time window of the nodes represents a period of time before
`seeds_timestamp`. If provided, only neighbors and related edges
whose timestamps fall within `[seeds_timestamp -
seeds_pre_time_window, seeds_timestamp]` will be filtered.
Returns
-------
Union[torch.Tensor, Dict[str, torch.Tensor]]
The input nodes.
List[SampledSubgraph]
The sampled subgraphs.
Examples
--------
>>> @functional_datapipe("my_sample_subgraph")
>>> class MySubgraphSampler(SubgraphSampler):
>>> def __init__(self, datapipe, graph, fanouts):
>>> super().__init__(datapipe)
>>> self.graph = graph
>>> self.fanouts = fanouts
>>> def sample_subgraphs(self, seeds):
>>> # Sample subgraphs from the given seeds.
>>> subgraphs = []
>>> subgraphs_nodes = []
>>> for fanout in reversed(self.fanouts):
>>> subgraph = self.graph.sample_neighbors(seeds, fanout)
>>> subgraphs.insert(0, subgraph)
>>> subgraphs_nodes.append(subgraph.nodes)
>>> seeds = subgraph.nodes
>>> subgraphs_nodes = torch.unique(torch.cat(subgraphs_nodes))
>>> return subgraphs_nodes, subgraphs
"""
raise NotImplementedError