Source code for gigl.transforms.sequence_layout

"""Shared pieces for building per-anchor token sequences.

``select_sequences`` picks each anchor's tokens from its candidates into a padded
``[num_sequences, max_seq_len]`` grid, whatever generated the candidates (k-hop
expansion, PPR edges). ``PackedLayout`` holds the offsets
``gigl.nn.packed_attention`` needs to run on real tokens only, and
``pack_sequences`` computes it from the grid's sequence lengths.
"""

from typing import NamedTuple, Optional

import torch
import torch.nn.functional as F
from jaxtyping import Int, Int32, Int64
from torch import Tensor


[docs] class PackedLayout(NamedTuple): """Where each sequence lives in the packed token buffer. Attributes: cu_seqlens: ``[num_sequences + 1]`` int32 cumulative sequence lengths, on the same device as the tokens. Sequence ``i`` occupies ``[cu_seqlens[i], cu_seqlens[i + 1])``. The attention kernel requires int32, so the total token count must fit in int32. max_seq_len: Python int upper bound on any sequence length. It must be at least the longest sequence, or the attention kernel skips rows. The attention clamps it to the token count. """
[docs] cu_seqlens: Int32[Tensor, "num_sequences_plus_one"]
[docs] max_seq_len: int
[docs] class SelectedSequences(NamedTuple): """The tokens ``select_sequences`` picked for each anchor. Attributes: nodes: ``[num_anchors, max_seq_len]`` long node ids, the anchor in column 0 if ``include_anchor``, -1 from the sequence length on. source: ``[num_anchors, max_seq_len]`` long column of ``candidates`` each token came from, so a per-candidate payload follows with ``payload.gather(1, source.clamp(min=0))``. It is -1 at the anchor and at padding. lengths: ``[num_anchors]`` long count of valid tokens, anchor included. It is at most ``max_seq_len``, except that it counts the anchor even when ``max_seq_len`` is 0. """
[docs] nodes: Int64[Tensor, "num_anchors max_seq_len"]
[docs] source: Int64[Tensor, "num_anchors max_seq_len"]
[docs] lengths: Int64[Tensor, "num_anchors"]
[docs] def pack_sequences( lengths: Int64[Tensor, "num_sequences"], max_seq_len: int ) -> tuple[Int64[Tensor, "num_tokens"], PackedLayout]: """Computes what is needed to drop the padding from a padded grid of sequences. Sequence ``i`` is valid in its first ``lengths[i]`` positions. Any ``[num_sequences, max_seq_len]`` per-position tensor gathers into packed order with ``grid.flatten()[flat_index]``, and ``cu_seqlens`` marks where each packed sequence starts. This costs one host read, of the total token count; it is why the packed buffer holds exactly the real tokens. A length above ``max_seq_len`` is clamped to it, because the grid has no positions beyond that. Checking for it would cost another host read. Example: >>> flat_index, packed = pack_sequences(torch.tensor([2, 1, 3]), max_seq_len=3) >>> flat_index.tolist() [0, 1, 3, 6, 7, 8] >>> packed.cu_seqlens.tolist() [0, 2, 3, 6] Args: max_seq_len: Width of the padded grid. Returns: - ``[num_tokens]`` long indices of the valid positions in the flattened grid, sequence by sequence. - The packed layout. """ lengths = lengths.clamp(max=max_seq_len) valid = torch.arange(max_seq_len, device=lengths.device) < lengths.unsqueeze(1) cu_seqlens = torch.cat([lengths.new_zeros(1), torch.cumsum(lengths, 0)]) flat_index = valid.flatten().nonzero().squeeze(1) return flat_index, PackedLayout(cu_seqlens.to(torch.int32), max_seq_len)
# Bits of a sort key that hold the candidate's column; a node id takes the bits # above. A fixed width keeps shapes, not the column count, out of the arithmetic, so # ``torch.compile`` does not recompile when the candidate count changes. _COLUMN_BITS = 26 _COLUMN_MASK = (1 << _COLUMN_BITS) - 1
[docs] def select_sequences( anchor_indices: Int[Tensor, "num_anchors"], candidates: Int64[Tensor, "num_anchors num_candidates"], max_seq_len: int, priority: Optional[Tensor] = None, ties_by_column: bool = False, include_anchor: bool = True, ) -> SelectedSequences: """Picks each anchor's tokens from its candidates, with fixed shapes. Every anchor's sequence is the anchor, then its distinct candidate nodes other than the anchor, ordered by ``priority`` (ties by ascending node id, or by column with ``ties_by_column``) and cut at ``max_seq_len``. A node that appears in several columns keeps the lowest column, so put the preferred copy of a node first. This is the selection step shared by the k-hop and PPR sequence builders: they differ in how they generate the candidates and in what ``priority`` ranks. Example: Two anchors, 0 and 1. Node 3 is a duplicate candidate of anchor 0, anchor 0 is among its own candidates, and -1 is padding:: >>> anchors = torch.tensor([0, 1]) >>> candidates = torch.tensor([[5, 3, 0, 3, -1], [4, 1, 6, 4, 2]]) >>> nodes, source, lengths = select_sequences(anchors, candidates, 4) >>> nodes.tolist() [[0, 3, 5, -1], [1, 2, 4, 6]] >>> lengths.tolist() [3, 4] Gather a per-candidate payload with ``source``, zeroing the anchor and padding tokens:: >>> payload = torch.arange(10, 20).reshape(2, 5) >>> (payload.gather(1, source.clamp(min=0)) * (source >= 0)).tolist() [[0, 11, 10, 0], [0, 19, 15, 17]] A ``priority`` ranks the candidates (smaller first); equal priorities go by node id, or by column with ``ties_by_column``:: >>> priority = torch.tensor([[0, 1, 0, 1, 0], [2, 9, 0, 5, 0]]) >>> select_sequences(anchors, candidates, 4, priority).nodes.tolist() [[0, 5, 3, -1], [1, 2, 6, 4]] >>> select_sequences( ... anchors, candidates, 4, priority, ties_by_column=True ... ).nodes.tolist() [[0, 5, 3, -1], [1, 6, 2, 4]] Without ``include_anchor`` the candidates are the whole sequence, so anchor 0 stays in its sequence as the candidate it is:: >>> select_sequences(anchors, candidates, 4, include_anchor=False).source.tolist() [[2, 1, 0, -1], [1, 4, 0, 2]] Args: anchor_indices: ``[num_anchors]`` node ids of the anchors. candidates: ``[num_anchors, num_candidates]`` long candidate node ids, -1 at padding. The anchor itself may appear. There can be at most ``2 ** 26`` columns. max_seq_len: Tokens per sequence, anchor included. priority: ``[num_anchors, num_candidates]`` integer or float rank of each candidate; smaller comes first. If None, candidates are ordered by ascending node id, which costs one sort instead of two. Pass the column index to keep the candidates' own order. ties_by_column: Whether candidates with equal ``priority`` stay in column order instead of ascending node id. It has no effect without ``priority``. include_anchor: Whether column 0 holds the anchor, which is then dropped from the candidates. If False, the sequence is only candidates, and the anchor stays in it if it is one of them. Returns: The ``SelectedSequences``: ``nodes``, ``source`` and ``lengths``. """ anchor_indices = anchor_indices.long() num_candidates = candidates.size(1) padding_key = torch.iinfo(torch.long).max # Sort ``(node << _COLUMN_BITS) | column``: each node's copies are adjacent # with the lowest column first, and padding sorts last. columns = torch.arange(num_candidates, device=candidates.device) keys = torch.where( candidates >= 0, (candidates << _COLUMN_BITS) | columns, padding_key ) keys = torch.sort(keys, dim=1).values nodes = keys >> _COLUMN_BITS keep = (keys != padding_key) & F.pad(nodes.diff(dim=1) != 0, (1, 0), value=True) if include_anchor: keep = keep & (nodes != anchor_indices.unsqueeze(1)) if priority is not None: if ties_by_column: # Put the kept candidates back in column order, so the stable sort # below leaves priority ties there. by_column = torch.sort( torch.where(keep, keys & _COLUMN_MASK, num_candidates), dim=1 ).indices keys = keys.gather(1, by_column) keep = keep.gather(1, by_column) # Dropped candidates rank last, so the kept ones come first, in # ``priority`` order and then ascending node id. Padding decodes to an # out-of-range column; its priority is never used. columns = (keys & _COLUMN_MASK).clamp(max=num_candidates - 1) if priority.is_floating_point(): worst = torch.inf else: worst = torch.iinfo(priority.dtype).max order = torch.sort( torch.where(keep, priority.gather(1, columns), worst), dim=1, stable=True, ).indices keys = keys.gather(1, order) keep = keep.gather(1, order) # With the anchor, it takes column 0 and the kept candidates are numbered from # 1. The kept candidates are cut at ``max_seq_len``. first_position = int(include_anchor) positions = torch.cumsum(keep, dim=1) - 1 + first_position keep = keep & (positions < max_seq_len) # Dropped candidates scatter into an extra last column, which is sliced off. packed = torch.full( (anchor_indices.numel(), max_seq_len + 1), -1, dtype=torch.long, device=keys.device, ) packed.scatter_(1, torch.where(keep, positions, max_seq_len), keys) packed = packed[:, :max_seq_len] if include_anchor and max_seq_len > 0: packed[:, 0] = anchor_indices << _COLUMN_BITS source = torch.where(packed >= 0, packed & _COLUMN_MASK, -1) if include_anchor and max_seq_len > 0: source[:, 0] = -1 return SelectedSequences( packed >> _COLUMN_BITS, source, first_position + keep.sum(dim=1) )