Source code for gigl.utils.csr

"""Memory-lean COO -> CSR/CSC conversion.

``graphlearn_torch.utils.coo_to_csr`` delegates to ``torch_sparse.SparseTensor``, which builds a
composite sort key, sorts it, and gathers row and col through the permutation while the caller's
originals are still live. Seven full-size int64 arrays exist at the peak, measured at 7.25x one
int64 array on 400M edges. At a multi-billion-edge partition that is hundreds of GiB and the
conversion, not the graph, is what exhausts the host.

This implementation is a two-pass counting sort: degrees give the exact output layout up front,
so the output is allocated once and written in place, and the input is consumed in chunks so
transients are bounded by chunk size rather than by edge count::

    peak = row + col + indices + optional edge_ids + 2 x (num_rows + 1)
           + O(chunk + max_degree)

which is 1.5x one int64 array with int32 inputs when edge IDs are omitted, since ``indices``
then stays int32, or 2.5x when the int64 edge IDs are retained. The ``max_degree`` term comes
from within-row sorting; it matters when one row owns a meaningful fraction of the edges.

:class:`CompactTopology` wraps the result as a GLT ``Topology``.
"""

import gc
from dataclasses import dataclass
from typing import Literal, Optional

import torch
from graphlearn_torch.data import Topology

from gigl.common.logger import Logger
from gigl.utils.share_memory import allocate_preshared, is_disk_backed

[docs] logger = Logger()
# 16M edges of chunk transients is ~1 GB, small against any partition worth converting.
[docs] DEFAULT_CHUNK_SIZE = 1 << 24
# Reordering within a row holds four block-sized int64 arrays (expanded row ids, sort key, # permutation, gathered result). 32M edges is ~1 GB; larger blocks cost memory for no speed gain.
[docs] DEFAULT_SORT_BLOCK_EDGES = 1 << 25
# Destination window for the scatter when `indices` could not be placed in memory. Small enough that a band's pages stay cached while being written, large enough that the # number of input passes stays low.
[docs] DEFAULT_BAND_BYTES = 4 * 2**30
@dataclass(frozen=True)
[docs] class CsrBuildResult: """CSR arrays and optional COO-position edge IDs in CSR order."""
[docs] indptr: torch.Tensor
[docs] indices: torch.Tensor
[docs] edge_ids: Optional[torch.Tensor] = None
def _place_chunk( row_chunk: torch.Tensor, col_chunk: torch.Tensor, cursor: torch.Tensor, indices: torch.Tensor, edge_ids: Optional[torch.Tensor] = None, edge_id_chunk: Optional[torch.Tensor] = None, ) -> None: """Write one chunk of edges into ``indices`` and advance ``cursor`` past them. Args: row_chunk: ``[C]`` int64 row id of each edge, relative to ``cursor``'s first row. col_chunk: ``[C]`` column id of each edge. cursor: ``[R]`` the slot in ``indices`` where each row's next edge goes. Starts as ``indptr[:-1]`` and ends as ``indptr[1:]`` once every chunk is placed. indices: ``[E]`` the CSR column array being filled. edge_ids: Optional ``[E]`` CSR edge ID array being filled. edge_id_chunk: Optional ``[C]`` original COO positions for this chunk. Example: with ``cursor = [0, 2, 3]`` (row 0 owns slots 0-1, row 1 slot 2, row 2 slots 3-4), the chunk ``row_chunk = [2, 0, 2]``, ``col_chunk = [7, 8, 9]`` writes ``indices[0] = 8``, ``indices[3] = 7`` and ``indices[4] = 9``, and leaves ``cursor = [1, 2, 5]``. """ order = torch.argsort(row_chunk, stable=True) row_chunk = row_chunk[order] col_chunk = col_chunk[order] if edge_ids is not None: assert edge_id_chunk is not None edge_id_chunk = edge_id_chunk[order] del order # [U] distinct rows in this chunk, and [U] edges for each. unique_rows, counts = torch.unique_consecutive(row_chunk, return_counts=True) del row_chunk run_starts = torch.cumsum(counts, dim=0) - counts offset_within_run = torch.arange( col_chunk.numel(), dtype=torch.int64 ) - torch.repeat_interleave(run_starts, counts) del run_starts # [C] slot in `indices` for each edge: its row's cursor plus its rank within the row. destination = ( torch.repeat_interleave(cursor[unique_rows], counts) + offset_within_run ) del offset_within_run indices[destination] = col_chunk.to(indices.dtype) if edge_ids is not None: assert edge_id_chunk is not None edge_ids[destination] = edge_id_chunk # unique_rows are distinct, so this is a plain read-modify-write with no collisions. cursor[unique_rows] += counts del destination, col_chunk, unique_rows, counts def _scatter_in_bands( row: torch.Tensor, col: torch.Tensor, indptr: torch.Tensor, indices: torch.Tensor, chunk_size: int, band_bytes: Optional[int], edge_ids: Optional[torch.Tensor] = None, ) -> None: """Fill ``indices`` one band of rows at a time, scanning the whole input once per band. ``band_bytes=None`` is a single band, right for an in-memory destination. On disk a single pass writes all over the file, so every page is faulted in, dirtied by a few bytes, written back and evicted, then faulted again on the next chunk -- at a billion-edge shape that looks like a hang. Bands whose destination slice fits ``band_bytes`` keep the writes inside a window that stays cached, trading cheap passes over the in-memory input for one pass over the output. """ num_rows = indptr.numel() - 1 num_edges = row.numel() bytes_per_edge = indices.element_size() if edge_ids is not None: bytes_per_edge += edge_ids.element_size() band_elements = num_edges if band_bytes is not None: band_elements = max(band_bytes // bytes_per_edge, 1) placed = 0 bands = 0 first_row = 0 while first_row < num_rows: # The first row boundary past band_elements edges, clamped to at least one row so a row # with more edges than a band forms an oversized band of its own rather than looping # forever -- its destinations are contiguous anyway. last_row = int( torch.searchsorted(indptr, indptr[first_row] + band_elements).item() ) last_row = min(max(last_row, first_row + 1), num_rows) if int(indptr[last_row].item()) > int(indptr[first_row].item()): cursor = indptr[first_row:last_row].clone() for start in range(0, num_edges, chunk_size): row_chunk = row[start : start + chunk_size] in_band = (row_chunk >= first_row) & (row_chunk < last_row) if not bool(in_band.any()): del row_chunk, in_band continue selected_rows = row_chunk[in_band].to(torch.int64) - first_row if edge_ids is None: _place_chunk( row_chunk=selected_rows, col_chunk=col[start : start + chunk_size][in_band], cursor=cursor, indices=indices, ) else: # Keep input IDs scoped to the call so they do not survive the chunk write. _place_chunk( row_chunk=selected_rows, col_chunk=col[start : start + chunk_size][in_band], cursor=cursor, indices=indices, edge_ids=edge_ids, edge_id_chunk=torch.nonzero(in_band, as_tuple=True)[0] + start, ) placed += int(selected_rows.numel()) del row_chunk, in_band, selected_rows if not bool(torch.equal(cursor, indptr[first_row + 1 : last_row + 1])): raise ValueError( f"CSR scatter did not fill rows [{first_row}, {last_row}) exactly; " f"degrees disagree" ) del cursor bands += 1 first_row = last_row if placed != num_edges: raise ValueError( f"CSR scatter placed {placed:,} of {num_edges:,} edges; every edge must land in " f"exactly one band" ) if band_bytes is not None: logger.info(f"CSR scatter used {bands} bands over the file-backed destination") def _sort_within_rows( indptr: torch.Tensor, indices: torch.Tensor, block_edges: int, edge_ids: Optional[torch.Tensor] = None, ) -> None: """Sort each row's column slice ascending, in place. Gives parity with ``coo_to_csr``, which sorts on ``row * num_cols + col`` and so leaves columns ascending within each row. Deliberately does not build that composite key: ``row * num_cols`` overflows int64 once the row span times the column count exceeds 2**63, the row span of a block is not bounded by ``block_edges`` (a long run of empty rows lets one block cover arbitrarily many rows), and the failure is silent -- keys wrap, argsort succeeds, columns come out mis-ordered. Sorting by column and then stably by row preserves the column order established by the first pass, with no arithmetic on ids at all. ``block_edges`` caps both the edges and the rows in a block, so transients are bounded except for a single row larger than a block, which is sorted whole: O(degree) for that row. """ num_rows = indptr.numel() - 1 row_start = 0 while row_start < num_rows: row_end = int( torch.searchsorted(indptr, indptr[row_start] + block_edges).item() ) # Capping rows too keeps `counts` and `rows_expanded` bounded across long runs of # empty rows, which the edge budget alone does not. row_end = min(max(row_end, row_start + 1), row_start + block_edges, num_rows) edge_start = int(indptr[row_start].item()) edge_end = int(indptr[row_end].item()) if edge_end > edge_start: counts = indptr[row_start + 1 : row_end + 1] - indptr[row_start:row_end] rows_expanded = torch.repeat_interleave( torch.arange(row_end - row_start, dtype=torch.int64), counts ) del counts by_column = torch.argsort(indices[edge_start:edge_end]) columns = indices[edge_start:edge_end][by_column] if edge_ids is not None: sorted_edge_ids = edge_ids[edge_start:edge_end][by_column] rows_expanded = rows_expanded[by_column] del by_column by_row = torch.argsort(rows_expanded, stable=True) del rows_expanded indices[edge_start:edge_end] = columns[by_row] if edge_ids is not None: edge_ids[edge_start:edge_end] = sorted_edge_ids[by_row] del sorted_edge_ids del columns, by_row row_start = row_end
[docs] def build_csr_from_coo( row: torch.Tensor, col: torch.Tensor, num_rows: Optional[int] = None, chunk_size: int = DEFAULT_CHUNK_SIZE, sort_block_edges: int = DEFAULT_SORT_BLOCK_EDGES, band_bytes: int = DEFAULT_BAND_BYTES, retain_edge_ids: bool = True, ) -> CsrBuildResult: """Convert a COO edge index to CSR with bounded chunk and sort temporaries. Produces the same CSR arrays as ``graphlearn_torch.utils.coo_to_csr`` without its full-size sorting temporaries. When ``retain_edge_ids`` is true, write original COO positions in CSR order alongside columns. The final int64 vector is allocated directly; no full-size input ID vector is needed. Explicit edge IDs and edge weights are unsupported. To build CSC instead, pass the column indices as ``row`` and the row indices as ``col``. Args: row (torch.Tensor): 1-D row indices, int32 or int64. col (torch.Tensor): 1-D column indices, same length as ``row``. num_rows (Optional[int]): Size of the row dimension; ``indptr`` has ``num_rows + 1`` entries. Defaults to ``max(row) + 1``, as ``coo_to_csr`` does. chunk_size (int): Edges per scatter chunk. Bounds the transient working set. sort_block_edges (int): Edges per block of the within-row sort, which orders each row's columns ascending as ``coo_to_csr`` does. band_bytes (int): Destination window for the scatter, used only when ``indices`` could not be placed in memory. retain_edge_ids (bool): Retain original COO positions in CSR order by default. The int64 IDs cost 8 bytes per edge. Set to ``False`` when callers do not sample edge IDs. Returns: CsrBuildResult: ``indptr`` of shape ``[num_rows + 1]``, always int64, and ``indices`` of shape ``[num_edges]``, int32 when ``col`` is int32 and int64 otherwise, plus optional int64 ``edge_ids`` of shape ``[num_edges]`` in CSR order. Empty input returns an empty ID tensor when retention is enabled and ``None`` otherwise. Raises: ValueError: If ``row`` and ``col`` disagree in length, a row id is out of range, or the scatter does not exactly fill every row. """ if row.dim() != 1 or col.dim() != 1: raise ValueError(f"Expected 1-D row and col, got {row.shape} and {col.shape}") if row.numel() != col.numel(): raise ValueError( f"row and col must be the same length, got {row.numel()} and {col.numel()}" ) num_edges = row.numel() # The partitioners pass int32 when ids fit; GiGL's patched GLT build (#761) samples int32 columns. indices_dtype = torch.int32 if col.dtype == torch.int32 else torch.int64 row_max = -1 if num_edges: row_max = int(row.max().item()) if num_rows is None: num_rows = row_max + 1 elif row_max >= num_rows: raise ValueError(f"Row id {row_max} is out of range for num_rows={num_rows}") # Allocated so that a later Topology.share_memory_() cannot duplicate it -- see # allocate_preshared. indptr = allocate_preshared((num_rows + 1,), torch.int64) if num_edges == 0: # A rank can own no edges of some edge type. indptr.zero_() edge_ids = None if retain_edge_ids: edge_ids = torch.empty(0, dtype=torch.int64) return CsrBuildResult( indptr=indptr, indices=torch.empty(0, dtype=indices_dtype), edge_ids=edge_ids, ) indptr[0] = 0 torch.cumsum(torch.bincount(row, minlength=num_rows), dim=0, out=indptr[1:]) gc.collect() # random_access=True: the scatter writes all over the array, so memory is preferred; bands # take over if it lands on disk anyway. indices = allocate_preshared((num_edges,), indices_dtype, random_access=True) edge_ids = None if retain_edge_ids: edge_ids = allocate_preshared((num_edges,), torch.int64, random_access=True) scatter_band_bytes = None if is_disk_backed(indices) or (edge_ids is not None and is_disk_backed(edge_ids)): scatter_band_bytes = band_bytes _scatter_in_bands( row=row, col=col, indptr=indptr, indices=indices, chunk_size=chunk_size, band_bytes=scatter_band_bytes, edge_ids=edge_ids, ) gc.collect() _sort_within_rows(indptr, indices, sort_block_edges, edge_ids) logger.info( f"Built CSR for {num_edges:,} edges over {num_rows:,} rows " f"(indices {indices.numel() * indices.element_size() / 2**30:.1f} GiB as " f"{indices.dtype}, indptr {indptr.numel() * indptr.element_size() / 2**30:.1f} GiB)" ) return CsrBuildResult(indptr=indptr, indices=indices, edge_ids=edge_ids)
[docs] class CompactTopology(Topology): """A GLT ``Topology`` built by :func:`build_csr_from_coo`. ``Topology.__init__`` is not called: it would fabricate a full ``torch.arange(num_edges)`` and convert with ``coo_to_csr``. Instead, the counting sort writes original COO positions directly into final CSR order when retained. Edge features need explicit IDs and cannot use this class. Args: edge_index (torch.Tensor): ``[2, num_edges]`` COO. int32 and int64 are used as they are; anything else is cast to int64, as GLT's ``Topology`` does. layout (Literal["CSR", "CSC"]): The layout GLT samples from. num_rows (Optional[int]): Size of the compressed dimension. Defaults to the largest compressed id + 1, as GLT's ``Topology`` does. retain_edge_ids (bool): Retain COO-position IDs for edge sampling by default, at a cost of 8 bytes per edge. Use ``False`` only when callers sample without edge IDs. """ def __init__( self, edge_index: torch.Tensor, layout: Literal["CSR", "CSC"], num_rows: Optional[int] = None, *, retain_edge_ids: bool = True, ) -> None: # GLT's Topology casts any input to int64; integer inputs keep their width here. if edge_index.is_floating_point(): edge_index = edge_index.to(torch.int64) # CSC compresses destinations, so it is the CSR of the reversed edges. if layout == "CSR": compressed, other = 0, 1 else: compressed, other = 1, 0 self._layout = layout csr = build_csr_from_coo( row=edge_index[compressed], col=edge_index[other], num_rows=num_rows, retain_edge_ids=retain_edge_ids, ) self._indptr, self._indices, self._edge_ids = ( csr.indptr, csr.indices, csr.edge_ids, ) self._edge_weights = None