Source code for gigl.common.utils.feature_quantization.lossless

"""Lossless narrow-integer packing of feature columns, the lossless half of feature compression.

Many columns of a typical feature set are categorical ids (``nn.Embedding`` indices) that need one
to four bytes, not four. ``FeatureCodec`` packs a set of such columns into one ``uint8`` row of
fixed width. Rows are stored in the packed sidecar that feature quantization already uses
(``FeatureQuantizationMetadata``): GLT's feature store, RPC, stitch, shared-memory channel and
host-to-device copy move opaque byte rows unchanged, and ``decode`` rebuilds the float32 columns on
the consumer's device.

Each column has one encoding, which says how the codec stores that column. ``encode`` raises on a value its column's encoding cannot represent:

* ``float32``: exact real values. Also the encoding of a column that preprocessing already quantized.
* ``bfloat16``: real values, lossy. ``from_stats`` never picks it.
* ``int8``: ids in ``[-128, 127]``.
* ``uint8``: ids in ``[0, 255]``.
* ``uint8_bias1``: ids in ``[-1, 254]``, stored as ``id + 1``. Fits an out-of-vocabulary id of -1.
* ``int16``: ids in ``[-32768, 32767]``.
* ``int32``: ids in ``[-2**31, 2**31 - 128]``. The upper bound is the largest float32 below ``2**31``.
* ``bit``: exactly ``0`` or ``1`` (for example a one-hot column), stored in one bit.

Layout: columns are grouped by encoding and the groups are laid out from widest to
narrowest (4-byte, 2-byte, 1-byte) so every group starts at an offset aligned to its element
size and can be read with ``Tensor.view(dtype)`` on a byte slice. The ``bit`` group comes last: its
columns are packed eight per byte, column ``j`` of the group in bit ``j % 8`` of byte ``j // 8``
(least significant bit first), and the last byte is zero-padded. The row is zero-padded to a
multiple of four bytes so that row strides stay aligned for any row subset.

Which columns are stored as narrow integers is inferred from the data: ``ColumnStats`` scans the columns and
``FeatureCodec.from_stats`` picks the narrowest encoding that holds every value of an integral column. A column that is not
integral, does not fit any integer encoding, or holds a ``-0.0`` stays float32 (``encode`` itself writes ``-0.0`` as ``0``
in an integer encoding). A codec from ``from_stats`` is therefore lossless and bit-exact for the data it was built from.

Example:
    Declare the codec of a node type with five columns: an ``int16`` id, an ``int8`` id, a
    ``uint8_bias1`` id, an ``int32`` id and a 0/1 ``bit`` column.

    >>> import torch
    >>> from gigl.common.utils.feature_quantization.lossless import FeatureCodec
    >>> codec = FeatureCodec(("int16", "int8", "uint8_bias1", "int32", "bit"))
    >>> features = torch.tensor(
    ...     [[300.0, -3.0, -1.0, 100000.0, 1.0], [-2.0, 127.0, 254.0, -7.0, 0.0]]
    ... )

    In the declared column order the columns need 2, 1, 1, 4 bytes and 1 bit. ``encode`` lays the
    groups out widest first: ``int32`` 4 bytes, ``int16`` 2, ``int8`` 1, ``uint8_bias1`` 1, then one
    byte for the ``bit`` group. That is 9 bytes, padded to 12, and each row is stored as ``uint8``.

    >>> rows = codec.encode(features)
    >>> rows.dtype, tuple(rows.shape)
    (torch.uint8, (2, 12))

    ``decode`` returns float32 columns equal to the input.

    >>> decoded = codec.decode(rows)
    >>> decoded.dtype
    torch.float32
    >>> bool(torch.equal(decoded, features))
    True

    A value outside a column's range is rejected instead of silently wrapping.

    >>> codec.encode(torch.tensor([[0.0, 128.0, 0.0, 0.0, 0.0]]))
    Traceback (most recent call last):
        ...
    ValueError: Column 1 holds a value that is not an integer in [-128, 127] (encoding int8); the packed layout cannot represent it
"""

from dataclasses import dataclass
from functools import cached_property
from typing import Final, Iterable, NamedTuple, Optional

import torch
from jaxtyping import Bool, Float, Float32, Float64, Shaped, UInt8


@dataclass(frozen=True)
class _ColumnEncoding:
    dtype: torch.dtype
    # Added to a column value before the cast to ``dtype`` and subtracted after decoding.
    bias: int
    # Inclusive range of representable column values; (0, 0) and unused for the dense encodings.
    value_range: tuple[int, int]


_ENCODINGS: Final[dict[str, _ColumnEncoding]] = {
    "float32": _ColumnEncoding(torch.float32, 0, (0, 0)),
    "int32": _ColumnEncoding(torch.int32, 0, (-(2**31), 2**31 - 128)),
    "bfloat16": _ColumnEncoding(torch.bfloat16, 0, (0, 0)),
    "int16": _ColumnEncoding(torch.int16, 0, (-(2**15), 2**15 - 1)),
    "int8": _ColumnEncoding(torch.int8, 0, (-(2**7), 2**7 - 1)),
    "uint8": _ColumnEncoding(torch.uint8, 0, (0, 2**8 - 1)),
    "uint8_bias1": _ColumnEncoding(torch.uint8, 1, (-1, 2**8 - 2)),
    # Eight columns per byte; ``dtype`` is the type of one packed byte.
    "bit": _ColumnEncoding(torch.uint8, 0, (0, 1)),
}
_DENSE_ENCODINGS: Final[tuple[str, ...]] = ("float32", "bfloat16")
# Widest first (dict order), so each group's byte offset is a multiple of its element size.
_ENCODING_ORDER: Final[tuple[str, ...]] = tuple(_ENCODINGS)
# Narrowest first, so the first encoding that holds a range is the cheapest.
_INTEGER_ENCODINGS_NARROWEST_FIRST: Final[tuple[str, ...]] = (
    "bit",
    "int8",
    "uint8",
    "uint8_bias1",
    "int16",
    "int32",
)
# A chunk and its temporaries should fit in CPU cache. A 65536-row chunk of a 512-column float32
# matrix is 128 MiB, which made every scan step stream through main memory; a sweep showed
# about 2x faster scans at 1 thread with chunks of this size.
_CHUNK_BYTES: Final[int] = 8 << 20


# Signed integer type of the same width as each float type, to read a float's bit pattern.
_SIGN_BIT_INTEGER_DTYPES: Final[dict[torch.dtype, torch.dtype]] = {
    torch.float16: torch.int16,
    torch.bfloat16: torch.int16,
    torch.float32: torch.int32,
    torch.float64: torch.int64,
}


[docs] def chunk_rows(num_columns: int, element_size: int) -> int: """Rows per chunk so that one chunk of the input is about ``_CHUNK_BYTES``.""" return max(1, _CHUNK_BYTES // max(1, num_columns * element_size))
[docs] def narrowest_integer_encoding(low: float, high: float) -> Optional[str]: """The narrowest integer encoding that holds every integer in ``[low, high]``, if any. Infinite bounds are held by no encoding. """ for encoding in _INTEGER_ENCODINGS_NARROWEST_FIRST: encoding_low, encoding_high = _ENCODINGS[encoding].value_range if encoding_low <= low and high <= encoding_high: return encoding return None
class _ColumnRun(NamedTuple): """Columns that are next to each other both in the original matrix and in their group. Decode copies a run as one slice, not one column at a time. """ # Original column index of the first column of the run. first_column: int # Position of that column within its group. first_position: int # Number of columns in the run. length: int @dataclass(frozen=True)
[docs] class ColumnStats: """Per-column facts about a float ``[N, D]`` matrix that decide which columns can be packed. Attributes: minimum: ``[D]`` column minimums in float64, so a float64 input keeps its exact values (NaN if the column holds a NaN). maximum: ``[D]`` column maximums in float64 (NaN if the column holds a NaN). integral: ``[D]`` bool, True where every value is a whole number (False for fractional values and NaN). has_negative_zero: ``[D]`` bool, True where some value is ``-0.0``. """
[docs] minimum: Float64[torch.Tensor, "width"]
[docs] maximum: Float64[torch.Tensor, "width"]
[docs] integral: Bool[torch.Tensor, "width"]
[docs] has_negative_zero: Bool[torch.Tensor, "width"]
@classmethod
[docs] def empty(cls, width: int) -> "ColumnStats": """Statistics of zero rows, the identity of ``merge``.""" return cls( torch.full((width,), float("inf"), dtype=torch.float64), torch.full((width,), float("-inf"), dtype=torch.float64), torch.ones(width, dtype=torch.bool), torch.zeros(width, dtype=torch.bool), )
@classmethod
[docs] def from_features( cls, features: Float[torch.Tensor, "rows features_dim"] ) -> "ColumnStats": """Scans ``features`` in row chunks, in its own dtype, so the scan needs no full-size temporaries.""" if features.dim() != 2: raise ValueError(f"Expected a [N, D] matrix, got {tuple(features.shape)}") stats = cls.empty(features.shape[1]) minimum, maximum = stats.minimum, stats.maximum integral, has_negative_zero = stats.integral, stats.has_negative_zero rows_per_chunk = chunk_rows(features.shape[1], features.element_size()) # -0.0 is the one value whose bit pattern is only the sign bit, which as a signed integer is its minimum. sign_bit_dtype = _SIGN_BIT_INTEGER_DTYPES.get(features.dtype) for start in range(0, features.shape[0], rows_per_chunk): chunk = features[start : start + rows_per_chunk] # Separate amin and amax are several times faster than the fused aminmax. minimum = torch.minimum(minimum, chunk.amin(dim=0).to(torch.float64)) maximum = torch.maximum(maximum, chunk.amax(dim=0).to(torch.float64)) integral &= (chunk == chunk.floor()).all(dim=0) if sign_bit_dtype is None: has_negative_zero |= ((chunk == 0) & torch.signbit(chunk)).any(dim=0) else: has_negative_zero |= ( chunk.view(sign_bit_dtype) == torch.iinfo(sign_bit_dtype).min ).any(dim=0) return cls(minimum, maximum, integral, has_negative_zero)
@classmethod
[docs] def merge(cls, stats: Iterable[Optional["ColumnStats"]]) -> "ColumnStats": """Combines the stats of several partitions (``None`` entries, e.g. empty partitions, are skipped).""" present = [entry for entry in stats if entry is not None] if not present: raise ValueError("Cannot merge zero ColumnStats") merged = present[0] for entry in present[1:]: merged = cls( torch.minimum(merged.minimum, entry.minimum), torch.maximum(merged.maximum, entry.maximum), merged.integral & entry.integral, merged.has_negative_zero | entry.has_negative_zero, ) return merged
@dataclass(frozen=True) class _Group: encoding: str dtype: torch.dtype bias: int byte_offset: int columns: tuple[int, ...] runs: tuple[_ColumnRun, ...] @property def num_bytes(self) -> int: if self.encoding == "bit": return (len(self.columns) + 7) // 8 return len(self.columns) * self.dtype.itemsize @dataclass(frozen=True)
[docs] class FeatureCodec: """Packing of a float32 ``[N, D]`` matrix into ``uint8`` ``[N, row_bytes]`` rows. Args: column_encodings: Encoding of every original column; the module docstring lists the encodings. """
[docs] column_encodings: tuple[str, ...]
def __post_init__(self) -> None: unknown = set(self.column_encodings) - set(_ENCODINGS) if unknown: raise ValueError(f"Unknown column encodings {sorted(unknown)}") @classmethod
[docs] def from_stats(cls, stats: ColumnStats) -> "FeatureCodec": """Picks the narrowest lossless encoding of every column from the data's statistics. A column that is integral, holds no ``-0.0`` and fits an integer encoding gets the narrowest such encoding (``bit`` for a column of only 0 and 1). Every other column is ``float32``. Args: stats: Statistics of the data that will be packed, merged over every partition. Example: >>> import torch >>> features = torch.tensor([[0.0, 3.0, 0.5], [1.0, 200.0, 2.0]]) >>> FeatureCodec.from_stats(ColumnStats.from_features(features)).column_encodings ('bit', 'uint8', 'float32') """ encodings: list[str] = [] for low, high, integral, has_negative_zero in zip( stats.minimum.tolist(), stats.maximum.tolist(), stats.integral.tolist(), stats.has_negative_zero.tolist(), ): encoding = None if integral and not has_negative_zero: encoding = narrowest_integer_encoding(low, high) if encoding is None: encoding = "float32" encodings.append(encoding) return cls(tuple(encodings))
@property
[docs] def num_columns(self) -> int: return len(self.column_encodings)
@cached_property def _groups(self) -> tuple[_Group, ...]: groups: list[_Group] = [] offset = 0 for encoding in _ENCODING_ORDER: columns = tuple( column for column, column_encoding in enumerate(self.column_encodings) if column_encoding == encoding ) if not columns: continue encoding_spec = _ENCODINGS[encoding] # Split the group's columns into runs. Positions within a group always go up by one, so a column # extends the last run if it comes right after that run's last original column. # For example, group columns (2, 3, 4, 7) give two runs: original columns 2-4 at group # positions 0-2, and original column 7 at group position 3. runs: list[_ColumnRun] = [] for position, column in enumerate(columns): if runs and runs[-1].first_column + runs[-1].length == column: runs[-1] = runs[-1]._replace(length=runs[-1].length + 1) else: runs.append( _ColumnRun( first_column=column, first_position=position, length=1 ) ) group = _Group( encoding, encoding_spec.dtype, encoding_spec.bias, offset, columns, tuple(runs), ) groups.append(group) offset += group.num_bytes return tuple(groups) @cached_property
[docs] def row_bytes(self) -> int: """Bytes per packed row, padded up to a multiple of four.""" used = sum(group.num_bytes for group in self._groups) return (used + 3) // 4 * 4
[docs] def encode( self, features: Float[torch.Tensor, "n _num_columns"], *, validate: bool = True, ) -> UInt8[torch.Tensor, "n {self.row_bytes}"]: """Packs float32 ``[N, D]`` features into ``uint8`` ``[N, row_bytes]`` rows. Args: features: The ``[N, D]`` matrix to pack. validate: Whether to check that every id column holds integers its encoding can store. Pass ``False`` only when the statistics this codec was built from cover ``features``, because they already prove that every value fits. Raises: ValueError: If the width is not ``D``, if a ``float32`` or ``bfloat16`` column is declared and ``features`` is not float32 (the cast would silently lose precision), or if ``validate`` is set and an id column holds a non-integral or out-of-range value (including NaN). """ if features.dim() != 2 or features.shape[1] != self.num_columns: raise ValueError( f"Expected features of shape [N, {self.num_columns}], got {tuple(features.shape)}" ) if features.dtype != torch.float32 and any( group.encoding in _DENSE_ENCODINGS for group in self._groups ): raise ValueError( f"Dense encodings require float32 features, got {features.dtype}; " "casting would silently lose precision" ) out = torch.zeros( features.shape[0], self.row_bytes, dtype=torch.uint8, device=features.device ) rows_per_chunk = chunk_rows(features.shape[1], features.element_size()) for start in range(0, features.shape[0], rows_per_chunk): chunk = features[start : start + rows_per_chunk] for group in self._groups: index = torch.tensor( group.columns, dtype=torch.long, device=chunk.device ) values = chunk.index_select(1, index) if group.encoding in _DENSE_ENCODINGS: values = values.to(torch.float32) else: if validate: # Check before any narrowing cast so float64 ids are not rounded into range. self._check_ids(values, group) if group.bias: values = values + group.bias if group.encoding == "bit": stored = self._pack_bits(values) else: stored = values.to(group.dtype).contiguous().view(torch.uint8) out[ start : start + chunk.shape[0], group.byte_offset : group.byte_offset + group.num_bytes, ] = stored return out
@staticmethod def _pack_bits( values: Float[torch.Tensor, "n columns"], ) -> UInt8[torch.Tensor, "n packed_bytes"]: """Packs 0/1 columns eight per byte, column ``j`` in bit ``j % 8`` of byte ``j // 8``.""" num_rows, num_columns = values.shape num_bytes = (num_columns + 7) // 8 padded = torch.zeros( num_rows, num_bytes * 8, dtype=torch.uint8, device=values.device ) padded[:, :num_columns] = values.to(torch.uint8) shifts = torch.arange(8, dtype=torch.uint8, device=values.device) return (padded.view(num_rows, num_bytes, 8) << shifts).sum( dim=-1, dtype=torch.uint8 ) @staticmethod def _unpack_bits( packed: UInt8[torch.Tensor, "n packed_bytes"], num_columns: int ) -> Float32[torch.Tensor, "n {num_columns}"]: """Inverse of ``_pack_bits``.""" shifts = torch.arange(8, dtype=torch.uint8, device=packed.device) bits = (packed.unsqueeze(-1) >> shifts) & 1 return bits.reshape(packed.shape[0], packed.shape[1] * 8)[:, :num_columns].to( torch.float32 ) @staticmethod def _check_ids(values: torch.Tensor, group: _Group) -> None: low, high = _ENCODINGS[group.encoding].value_range ok = (values >= low) & (values <= high) & (values == values.floor()) if not bool(ok.all()): bad_column = group.columns[int((~ok).any(dim=0).nonzero()[0])] raise ValueError( f"Column {bad_column} holds a value that is not an integer in [{low}, {high}] " f"(encoding {group.encoding}); the packed layout cannot represent it" )
[docs] def decode( self, rows: Shaped[torch.Tensor, "n _row_bytes"] ) -> Float32[torch.Tensor, "n {self.num_columns}"]: """Unpacks ``uint8`` ``[N, row_bytes]`` rows (on any device) to float32 ``[N, D]``.""" if ( rows.dtype != torch.uint8 or rows.dim() != 2 or rows.shape[1] != self.row_bytes ): raise ValueError( f"Expected uint8 rows of shape [N, {self.row_bytes}], got " f"{rows.dtype} {tuple(rows.shape)}" ) if rows.stride(0) % 4 or rows.stride(1) != 1 or rows.storage_offset() % 4: # ``view(dtype)`` needs unit column stride and element-aligned strides and offsets; a copy restores them. rows = rows.clone(memory_format=torch.contiguous_format) out = torch.empty( rows.shape[0], self.num_columns, dtype=torch.float32, device=rows.device ) for group in self._groups: part = rows[:, group.byte_offset : group.byte_offset + group.num_bytes] if group.encoding == "bit": part = self._unpack_bits(part, len(group.columns)) else: part = part.view(group.dtype).to(torch.float32) if group.bias: part = part - group.bias for first_column, first_position, length in group.runs: out[:, first_column : first_column + length] = part[ :, first_position : first_position + length ] return out