Files
2026-07-13 13:17:40 +08:00

417 lines
15 KiB
Python

import logging
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
Iterator,
List,
Optional,
Tuple,
Union,
)
import numpy as np
if TYPE_CHECKING:
import jax
logger = logging.getLogger(__name__)
_GLOBAL_MESH_1D_AXIS = "data"
NumpyBatch = Union[np.ndarray, Dict[str, np.ndarray]]
JaxBatch = Union["jax.Array", Dict[str, "jax.Array"]]
DTypeLikeSpec = Union["jax.typing.DTypeLike", Dict[str, "jax.typing.DTypeLike"]]
Scalar = Union[int, float, bool]
PaddingsSpec = Union[Scalar, Dict[str, Scalar]]
def _get_column_value(mapping_or_value: Any, key: str) -> Any:
"""Get the value for a specific column from a mapping or a single value."""
if isinstance(mapping_or_value, dict):
return mapping_or_value[key]
return mapping_or_value
def _unwrap_single_column_value(mapping_or_value: Any, name: str) -> Any:
"""Unwrap a single value from a mapping if it's a dictionary."""
if isinstance(mapping_or_value, dict):
if len(mapping_or_value) != 1:
raise ValueError(
f"When constructing a single-tensor batch, only a single {name} "
f"should be given, instead got: {mapping_or_value}"
)
return next(iter(mapping_or_value.values()))
return mapping_or_value
def _create_sharding_1d(axis_name: str) -> "jax.sharding.Sharding":
"""Create a 1D JAX sharding, preferably using topology-aware mesh_utils."""
import jax
from jax.sharding import Mesh, NamedSharding, PartitionSpec
devices = None
try:
from jax.experimental import mesh_utils
# Attempt to create a topology-aware mesh (e.g. for TPU/GPU interconnects)
devices = mesh_utils.create_device_mesh((jax.device_count(),))
except Exception as e:
logger.debug(f"Failed to use jax.experimental.mesh_utils: {e}")
if devices is None:
# Fallback to default device ordering if mesh_utils fails or is unavailable
devices = np.array(jax.devices())
physical_mesh = Mesh(devices, (axis_name,))
return NamedSharding(physical_mesh, PartitionSpec(axis_name))
def _convert_ndarray_to_jax_array(
ndarray: np.ndarray,
sharding: "jax.sharding.Sharding", # noqa: F821
dtype: Optional["jax.typing.DTypeLike"] = None,
) -> "jax.Array": # noqa: F821
import jax
local_batch_size = ndarray.shape[0]
host_count = jax.process_count()
# Global shape assumes each host gets the exact same local batch size.
global_shape = (local_batch_size * host_count,) + ndarray.shape[1:]
# Construct the globally aware 1D array from process-local data.
# This automatically shards the local ndarray across the local devices
# assigned to this process by the provided sharding.
physical_array = jax.make_array_from_process_local_data(
sharding, ndarray, global_shape
)
if dtype is not None:
physical_array = physical_array.astype(dtype)
return physical_array
def _convert_batch(
ndarrays: NumpyBatch,
sharding: "jax.sharding.Sharding",
dtypes: Optional[DTypeLikeSpec] = None,
) -> JaxBatch:
"""Convert a NumPy ndarray batch to a globally sharded JAX Array batch.
Args:
ndarrays: A single NumPy ndarray or dictionary of NumPy ndarrays.
sharding: The JAX sharding to use for the conversion.
dtypes: A single JAX dtype or dictionary of JAX dtypes for the created arrays.
Returns:
A globally sharded JAX Array (or dictionary of arrays) residing
in TPU/GPU memory.
"""
if isinstance(ndarrays, np.ndarray):
dtype = _unwrap_single_column_value(dtypes, "dtype")
jax_batch = _convert_ndarray_to_jax_array(ndarrays, sharding, dtype=dtype)
else:
jax_batch = {}
for col_name, col_ndarray in ndarrays.items():
dtype = _get_column_value(dtypes, col_name)
try:
jax_batch[col_name] = _convert_ndarray_to_jax_array(
col_ndarray, sharding, dtype=dtype
)
except ValueError as e:
raise ValueError(
f"JAX Array Conversion Error for column '{col_name}'"
) from e
return jax_batch
def _get_batch_size(batch: NumpyBatch) -> int:
"""Get the batch size of a NumPy ndarray or dictionary of NumPy ndarrays."""
if isinstance(batch, dict):
# Use the first column to determine the batch size
try:
return len(next(iter(batch.values())))
except StopIteration:
return 0
return len(batch)
def _pad_array(arr: np.ndarray, target_size: int, pad_value: Scalar) -> np.ndarray:
"""Pad a single array to target_size using pad_value."""
current_size = len(arr)
if current_size == target_size:
return arr
padding_shape = (target_size - current_size,) + arr.shape[1:]
padding = np.full(padding_shape, pad_value, dtype=arr.dtype)
return np.concatenate([arr, padding], axis=0)
def _dummy_array(arr: np.ndarray, target_size: int, pad_value: Scalar) -> np.ndarray:
"""Create a dummy array of target_size filled with pad_value."""
shape = (target_size,) + arr.shape[1:]
return np.full(shape, pad_value, dtype=arr.dtype)
def _pad_batch(
batch: NumpyBatch,
target_size: int,
paddings: PaddingsSpec,
) -> NumpyBatch:
"""Pad a batch to target_size using paddings."""
if isinstance(batch, dict):
return {
k: _pad_array(v, target_size, _get_column_value(paddings, k))
for k, v in batch.items()
}
return _pad_array(
batch,
target_size,
_unwrap_single_column_value(paddings, "padding"),
)
def _create_dummy_batch(
template_batch: NumpyBatch,
target_size: int,
paddings: PaddingsSpec,
) -> NumpyBatch:
"""Create a dummy batch of target_size filled with paddings."""
if isinstance(template_batch, dict):
return {
k: _dummy_array(v, target_size, _get_column_value(paddings, k))
for k, v in template_batch.items()
}
return _dummy_array(
template_batch,
target_size,
_unwrap_single_column_value(paddings, "padding"),
)
def _yield_batches_no_sync(
iterator: Iterator[NumpyBatch],
sharding: "jax.sharding.Sharding",
num_local_devices: int,
batch_size: int,
paddings: Optional[PaddingsSpec],
dtypes: Optional[DTypeLikeSpec] = None,
) -> Iterator[JaxBatch]:
"""Yield batches without multi-host synchronization."""
for batch in iterator:
local_batch_size = _get_batch_size(batch)
if local_batch_size == 0:
continue
if paddings is not None:
if local_batch_size < batch_size:
batch = _pad_batch(batch, batch_size, paddings)
elif local_batch_size % num_local_devices != 0:
# Without padding, batch size must be divisible by num_local_devices
raise ValueError(
f"The local batch size ({local_batch_size}) must be evenly "
f"divisible by the number of local JAX devices "
f"({num_local_devices}) on this host. "
f"To safely truncate or pad the batch, "
f"set `drop_last=True` or provide a `paddings` in `iter_jax_batches()`."
)
yield _convert_batch(batch, sharding, dtypes=dtypes)
def _fetch_lookahead_batches(
iterator: Iterator[NumpyBatch],
lookahead: int,
) -> Tuple[List[Optional[NumpyBatch]], List[int], Optional[NumpyBatch]]:
"""Fetch a window of batches and prepare synchronization info."""
local_batches = []
local_infos = []
template_batch: Optional[NumpyBatch] = None
for _ in range(lookahead):
try:
batch = next(iterator)
has_batch = True
local_batch_size = _get_batch_size(batch)
if template_batch is None:
template_batch = batch
except StopIteration:
batch = None
has_batch = False
local_batch_size = 0
local_batches.append(batch)
local_infos.extend([int(has_batch), local_batch_size])
if not has_batch:
break
return local_batches, local_infos, template_batch
def _yield_batches_with_sync(
iterator: Iterator[NumpyBatch],
sharding: "jax.sharding.Sharding",
num_local_devices: int,
drop_last: bool,
batch_size: int,
paddings: Optional[PaddingsSpec],
synchronize_lookahead: int,
dtypes: Optional[DTypeLikeSpec] = None,
) -> Iterator[JaxBatch]:
"""Yield batches with multi-host synchronization."""
import jax.numpy as jnp
from jax.experimental.multihost_utils import process_allgather
template_batch: Optional[NumpyBatch] = None
while True:
local_batches, local_infos, window_template = _fetch_lookahead_batches(
iterator, synchronize_lookahead
)
if template_batch is None:
template_batch = window_template
# Pad local_infos to 2 * synchronize_lookahead
padding_needed = 2 * synchronize_lookahead - len(local_infos)
if padding_needed > 0:
local_infos.extend([0] * padding_needed)
gathered = process_allgather(jnp.array(local_infos, dtype=jnp.int32))
for i in range(synchronize_lookahead):
h = gathered[:, 2 * i]
s = gathered[:, 2 * i + 1]
all_have_batch = bool(h.all())
any_have_batch = bool(h.any())
min_batch_size = int(s.min())
max_batch_size = int(s.max())
if not any_have_batch:
return
if not all_have_batch:
# Some workers have exhausted their data while others have more.
if drop_last:
# If drop_last=True, we stop as soon as any worker is exhausted.
return
elif paddings is not None:
# If paddings is set, we continue until all workers are exhausted.
# Workers that are already exhausted will yield dummy batches.
pass
else:
raise ValueError(
"Uneven number of batches detected across JAX workers. "
"To safely drop orphaned batches without hanging, "
"set `drop_last=True` or provide a `paddings` in `iter_jax_batches()`."
)
if paddings is not None:
batch = local_batches[i]
if batch is None:
if template_batch is None:
raise ValueError(
"Cannot create dummy batches for synchronization because this "
"JAX host has not received any data batches to use as a "
"template. This usually happens if one JAX host's dataset "
"shard is completely empty while others have data. "
"Ensure that all JAX hosts have at least one batch of data, "
"or use `drop_last=True` to avoid yielding dummy batches."
)
batch = _create_dummy_batch(template_batch, batch_size, paddings)
else:
local_batch_size = _get_batch_size(batch)
if local_batch_size < batch_size:
batch = _pad_batch(batch, batch_size, paddings)
assert batch is not None
yield _convert_batch(batch, sharding, dtypes=dtypes)
else:
if max_batch_size > min_batch_size:
raise ValueError(
"Uneven batch sizes detected across JAX workers. "
f"Host batch sizes range from {min_batch_size} to {max_batch_size}. "
"To handle uneven batch sizes, provide a `paddings` in `iter_jax_batches()`."
)
if min_batch_size % num_local_devices != 0:
raise ValueError(
f"The globally minimum batch size ({min_batch_size}) must be evenly "
f"divisible by the number of local JAX devices "
f"({num_local_devices}) on this host. "
f"To safely truncate or pad the batch, "
f"set `drop_last=True` or provide a `paddings` in `iter_jax_batches()`."
)
batch = local_batches[i]
assert batch is not None
yield _convert_batch(batch, sharding, dtypes=dtypes)
def jax_sync_generator(
batch_iterable: Iterable[NumpyBatch],
drop_last: bool,
batch_size: int = 256,
paddings: Optional[PaddingsSpec] = None,
dtypes: Optional[DTypeLikeSpec] = None,
synchronize_batches: bool = False,
synchronize_lookahead: int = 10,
) -> Iterator[JaxBatch]:
"""A generator that synchronizes and shards batches across JAX workers.
This generator wraps a locally yielded batch iterable and ensures that all JAX
workers within a multi-host training setup receive the exact same number of batches
and identical batch shapes, which is required for JAX's SPMD execution.
Args:
batch_iterable: An iterable yielding local data batches (either a NumPy ndarray
or a dictionary of NumPy ndarrays).
drop_last: Whether to drop partial or uneven batches.
batch_size: The target batch size for each host.
paddings: The value to use for padding uneven batches to `batch_size`.
If a dictionary is provided, it must map column names to padding values.
If None, padding is disabled.
dtypes: A single JAX dtype or dictionary of JAX dtypes for the created arrays.
synchronize_batches: Whether to synchronize batch shapes across all hosts.
Setting this to False can improve performance if you guarantee that all
hosts produce identical batch shapes and counts beforehand.
synchronize_lookahead: The number of batches to look ahead and synchronize at
once. Increasing this value reduces synchronization overhead but may
increase memory usage as more batches are buffered locally.
Yields:
JaxBatch: Globally sharded batches.
"""
import jax
# Physical Sharding (1D across the _GLOBAL_MESH_1D_AXIS dimension)
# The sharding is created once for the lifetime of this generator and reused
# across all batches.
sharding = _create_sharding_1d(_GLOBAL_MESH_1D_AXIS)
num_local_devices = jax.local_device_count()
iterator = iter(batch_iterable)
if not synchronize_batches or jax.process_count() == 1:
yield from _yield_batches_no_sync(
iterator,
sharding,
num_local_devices,
batch_size,
paddings,
dtypes=dtypes,
)
else:
yield from _yield_batches_with_sync(
iterator,
sharding,
num_local_devices,
drop_last,
batch_size,
paddings,
synchronize_lookahead,
dtypes=dtypes,
)