# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Adapted from: https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/deduplication/shuffle_utils/rapidsmpf_shuffler.py from __future__ import annotations from typing import TYPE_CHECKING, Any, Literal from packaging.version import parse as parse_version if TYPE_CHECKING: from collections.abc import Iterator import pylibcudf as plc def align_down_to_256(value: int) -> int: return (value >> 8) << 8 def get_device_free_memory() -> int | None: try: import pynvml import ray pynvml.nvmlInit() index = int(ray.get_gpu_ids()[0]) if ray.is_initialized() else 0 handle = pynvml.nvmlDeviceGetHandleByIndex(index) info = pynvml.nvmlDeviceGetMemoryInfo(handle) return int(info.free) except Exception: return None finally: pynvml.nvmlShutdown() def lazy_load() -> type[Any]: """Lazy load the BulkRapidsMPFShuffler class to allow for CPU-only environments. This is necessary because the BulkRapidsMPFShuffler inherits from BaseShufflingActor. """ import cudf import rapidsmpf import rmm.mr from rapidsmpf.integrations.cudf.partition import ( partition_and_pack, unpack_and_concat, unspill_partitions, ) from rapidsmpf.rmm_resource_adaptor import RmmResourceAdaptor from rapidsmpf.shuffler import Shuffler from rapidsmpf.utils.cudf import cudf_to_pylibcudf_table if parse_version(rapidsmpf.__version__) >= parse_version("26.4.0"): from rapidsmpf.integrations.ray import RapidsMPFActor as RapidsMPFActorBase else: from rapidsmpf.progress_thread import ProgressThread from rapidsmpf.utils.ray_utils import BaseShufflingActor as RapidsMPFActorBase if parse_version(rapidsmpf.__version__) >= parse_version("26.2.0"): from rapidsmpf.memory.buffer import MemoryType from rapidsmpf.memory.buffer_resource import ( BufferResource, LimitAvailableMemory, ) else: from rapidsmpf.buffer.buffer import MemoryType from rapidsmpf.buffer.resource import BufferResource, LimitAvailableMemory # Exempt this class from coverage is it's indirectly tested by the ShuffleStage which coverage tools don't pick up. class BulkRapidsMPFShuffler(RapidsMPFActorBase): # pragma: no cover """Class that performs a bulk shuffle operation. This class is compatible with Ray Actors communicating with each other using UCXX communication. Args: nranks: Number of ranks in the communication group. total_nparts: Total number of output partitions. shuffle_on: List of column names to shuffle on. output_path: Path to write output files. rmm_pool_size: Size of the RMM GPU memory pool in bytes. If "auto", the memory pool is set to 90% of the free GPU memory. If None, the memory pool is set to 50% of the free GPU memory that can expand if needed. spill_memory_limit: Device memory limit in bytes for spilling to host. If "auto", the limit is set to 80% of the RMM pool size. If None spilling is disabled. read_kwargs: Keyword arguments for cudf.read_parquet method. write_kwargs: Keyword arguments for cudf.to_parquet method. """ def __init__( # noqa: PLR0913 self, nranks: int, total_nparts: int, shuffle_on: list[str], output_path: str = "./", rmm_pool_size: int | Literal["auto"] | None = "auto", spill_memory_limit: int | Literal["auto"] | None = "auto", *, read_kwargs: dict[str, Any] | None = None, write_kwargs: dict[str, Any] | None = None, ): super().__init__(nranks) self.shuffle_on = shuffle_on self.output_path = output_path self.total_nparts = total_nparts if isinstance(rmm_pool_size, int): self.rmm_pool_size = align_down_to_256(rmm_pool_size) elif rmm_pool_size == "auto": free_memory = get_device_free_memory() self.rmm_pool_size = ( align_down_to_256(int(free_memory * 0.9)) if free_memory is not None else None ) elif rmm_pool_size is None: self.rmm_pool_size = None else: err_msg = f"Invalid rmm_pool_size: {rmm_pool_size}" raise ValueError(err_msg) if isinstance(spill_memory_limit, int): self.spill_memory_limit = align_down_to_256(spill_memory_limit) elif spill_memory_limit == "auto": self.spill_memory_limit = ( align_down_to_256(int(0.8 * self.rmm_pool_size)) if self.rmm_pool_size is not None else None ) elif spill_memory_limit is None: self.spill_memory_limit = None else: err_msg = f"Invalid spill_memory_limit: {spill_memory_limit}" raise ValueError(err_msg) self.read_kwargs = read_kwargs if read_kwargs is not None else {} self.write_kwargs = write_kwargs if write_kwargs is not None else {} def setup_worker(self, root_address_bytes: bytes) -> None: """Setup the UCXX communication and a shuffle operation. Args: root_address_bytes: Address of the root worker for UCXX initialization. """ super().setup_worker(root_address_bytes) # Initialize the RMM memory resource mr = RmmResourceAdaptor( rmm.mr.PoolMemoryResource( rmm.mr.CudaMemoryResource(), initial_pool_size=self.rmm_pool_size, maximum_pool_size=None, ) ) rmm.mr.set_current_device_resource(mr) # Create a buffer resource that limits device memory if spill_memory_limit is set memory_available = ( None if self.spill_memory_limit is None else { MemoryType.DEVICE: LimitAvailableMemory( mr, limit=self.spill_memory_limit ) } ) self.br = BufferResource(device_mr=mr, memory_available=memory_available) # Create a shuffler if parse_version(rapidsmpf.__version__) >= parse_version("26.4.0"): self.shuffler = Shuffler( self.comm, 0, total_num_partitions=self.total_nparts, br=self.br ) else: self.shuffler = Shuffler( self.comm, ProgressThread(self.comm), 0, self.total_nparts, self.br, ) def cleanup(self) -> None: """Cleanup the UCXX communication and the shuffle operation.""" if self.shuffler is not None: self.shuffler.shutdown() def insert_chunk( self, table: plc.Table | cudf.DataFrame, column_names: list[str] ) -> None: """Insert a pylibcudf Table or cuDF DataFrame into the shuffler. Args: table: The table or DataFrame to insert. column_names: The column names of the table. """ from rmm.pylibrmm.stream import DEFAULT_STREAM if isinstance(table, cudf.DataFrame): table = cudf_to_pylibcudf_table(table) columns_to_hash = tuple(column_names.index(val) for val in self.shuffle_on) packed_inputs = partition_and_pack( table=table, br=self.br, columns_to_hash=columns_to_hash, num_partitions=self.total_nparts, stream=DEFAULT_STREAM, ) self.shuffler.insert_chunks(packed_inputs) def insert_finished(self) -> None: """Tell the shuffler that we are done inserting data.""" for pid in range(self.total_nparts): self.shuffler.insert_finished(pid) self.comm.logger.info("Insert finished") def extract(self) -> Iterator[tuple[int, plc.Table]]: """Extract shuffled partitions as they become ready. Partitions are yielded in completion order (via ``wait_any()``), not partition order. Callers are responsible for reordering. Yields: tuple[int, plc.Table]: ``(partition_id, partition)`` tuples. """ from rmm.pylibrmm.stream import DEFAULT_STREAM first_time = True while not self.shuffler.finished() or first_time: first_time = False partition_id = self.shuffler.wait_any() packed_chunks = self.shuffler.extract(partition_id) partition = unpack_and_concat( unspill_partitions( packed_chunks, br=self.br, allow_overbooking=True, ), br=self.br, stream=DEFAULT_STREAM, ) yield partition_id, partition return BulkRapidsMPFShuffler _BulkRapidsMPFShuffler = None def __getattr__(name: str) -> type: global _BulkRapidsMPFShuffler if name == "BulkRapidsMPFShuffler": if _BulkRapidsMPFShuffler is None: _BulkRapidsMPFShuffler = lazy_load() return _BulkRapidsMPFShuffler raise AttributeError(f"module {__name__!r} has no attribute {name!r}")