Source code for gigl.utils.csr_parallel

"""Multi-threaded kernels for :func:`gigl.utils.csr.build_csr_from_coo`.

Each thread owns a range of rows, scans the whole edge list and writes only its own rows, so there
are no atomics or write collisions. Columns match the torch path exactly; edge ids of duplicate
edges keep input order, which the torch path does not guarantee. Ranges are balanced by edge count
through ``indptr``.

The kernels run on numba's OpenMP threading layer, which is not fork safe: a process that has run a
kernel must not ``fork()`` a child that runs one. GiGL builds datasets in ``mp.spawn`` children.
"""

from typing import Optional, no_type_check

import numba
import numpy as np
import torch
from numba import njit, prange

# @no_type_check keeps the unit launcher's jaxtyping hook from wrapping the kernels: numba's cache
# cannot pickle the wrapper's closure, so every cached call would fail under `make unit_test_py`.


@no_type_check
@njit(cache=True, parallel=True)
def _degrees_kernel(row: np.ndarray, num_rows: int, num_ranges: int) -> np.ndarray:
    # Equal-width row ranges: there is no indptr yet to balance by.
    counts = np.zeros(num_rows, dtype=np.int64)
    per_range = (num_rows + num_ranges - 1) // num_ranges
    for range_index in prange(num_ranges):
        low = range_index * per_range
        high = min(low + per_range, num_rows)
        for edge in range(row.shape[0]):
            row_id = row[edge]
            if low <= row_id < high:
                counts[row_id] += 1
    return counts


@no_type_check
@njit(cache=True, parallel=True)
def _scatter_kernel(
    row: np.ndarray,
    col: np.ndarray,
    indptr: np.ndarray,
    indices: np.ndarray,
    edge_ids: np.ndarray,
    row_bounds: np.ndarray,
    write_edge_ids: bool,
) -> np.ndarray:
    # Returns edges placed per range, -1 where degrees and edges disagree. The check runs
    # before each write: an overfull row's cursor points into the next range's slice.
    num_ranges = row_bounds.shape[0] - 1
    placed = np.zeros(num_ranges, dtype=np.int64)
    for range_index in prange(num_ranges):
        low = row_bounds[range_index]
        high = row_bounds[range_index + 1]
        cursor = indptr[low:high].copy()
        count = 0
        valid = True
        for edge in range(row.shape[0]):
            row_id = row[edge]
            if valid and low <= row_id < high:
                slot = cursor[row_id - low]
                if slot >= indptr[row_id + 1]:
                    valid = False
                else:
                    indices[slot] = col[edge]
                    if write_edge_ids:
                        edge_ids[slot] = edge
                    cursor[row_id - low] += 1
                    count += 1
        for local_row in range(high - low):
            if cursor[local_row] != indptr[low + local_row + 1]:
                valid = False
        placed[range_index] = count if valid else -1
    return placed


@no_type_check
@njit(cache=True, parallel=True)
def _sort_rows_kernel(
    indptr: np.ndarray,
    indices: np.ndarray,
    edge_ids: np.ndarray,
    row_bounds: np.ndarray,
    write_edge_ids: bool,
) -> None:
    for range_index in prange(row_bounds.shape[0] - 1):
        for row_id in range(row_bounds[range_index], row_bounds[range_index + 1]):
            start = indptr[row_id]
            end = indptr[row_id + 1]
            if end - start < 2:
                continue
            if write_edge_ids:
                order = np.argsort(indices[start:end], kind="mergesort")
                indices[start:end] = indices[start:end][order]
                edge_ids[start:end] = edge_ids[start:end][order]
            else:
                indices[start:end].sort()


def _edge_balanced_row_bounds(indptr: np.ndarray, num_ranges: int) -> np.ndarray:
    # int64 keys: float keys would make searchsorted copy all of indptr to compare.
    keys = np.arange(num_ranges + 1, dtype=np.int64) * int(indptr[-1]) // num_ranges
    bounds = np.searchsorted(indptr, keys)
    bounds[0] = 0
    bounds[-1] = indptr.shape[0] - 1
    return np.unique(bounds)


[docs] def parallel_degrees(row: torch.Tensor, num_rows: int) -> torch.Tensor: """``torch.bincount(row, minlength=num_rows)`` across all cores, as int64.""" return torch.from_numpy( _degrees_kernel(row.numpy(), num_rows, numba.get_num_threads()) )
[docs] def parallel_scatter( row: torch.Tensor, col: torch.Tensor, indptr: torch.Tensor, indices: torch.Tensor, edge_ids: Optional[torch.Tensor], ) -> None: """Place every edge in its row of ``indices`` (and its COO position in ``edge_ids``). Args: row: Source node id of each edge. col: Destination node id of each edge. indptr: CSR row pointers built from the degrees of ``row``. indices: Output column ids, one slot per edge; written in place. edge_ids: Output COO position of each edge, written in place, or ``None`` to skip them. Raises: ValueError: If the edges do not fill every row exactly. """ indptr_np = indptr.numpy() placed = _scatter_kernel( row.numpy(), col.numpy(), indptr_np, indices.numpy(), np.empty(0, dtype=np.int64) if edge_ids is None else edge_ids.numpy(), _edge_balanced_row_bounds(indptr_np, numba.get_num_threads()), edge_ids is not None, ) if int(placed.min()) < 0 or int(placed.sum()) != row.numel(): raise ValueError("CSR scatter did not fill every row exactly; degrees disagree")
[docs] def parallel_sort_within_rows( indptr: torch.Tensor, indices: torch.Tensor, edge_ids: Optional[torch.Tensor] ) -> None: """Sort each row's columns ascending in place, permuting ``edge_ids`` alongside. Args: indptr: CSR row pointers. indices: Column ids to sort within each row; sorted in place. edge_ids: Edge ids to permute with ``indices``, or ``None``. A stable sort keeps duplicate edges in input order. """ indptr_np = indptr.numpy() # More ranges than threads: long rows sort slower, and finer ranges balance them. _sort_rows_kernel( indptr_np, indices.numpy(), np.empty(0, dtype=np.int64) if edge_ids is None else edge_ids.numpy(), _edge_balanced_row_bounds(indptr_np, numba.get_num_threads() * 8), edge_ids is not None, )