Source code for data_juicer.ops.op_fusion

from typing import List, Optional

import numpy as np
from loguru import logger

from data_juicer.ops.base_op import OP, OPERATORS, Filter, Mapper
from data_juicer.ops.fused_batch_executor import (
    GENERAL_FUSED_EXECUTION_POLICY,
    execute_sequential_batch,
)
from data_juicer.ops.load import load_ops
from data_juicer.utils.constant import Fields, InterVars
from data_juicer.utils.lazy_loader import LazyLoader
from data_juicer.utils.registry import Registry

# Type of intermediate vars
# text
INTER_LINES = Registry(InterVars.lines)
INTER_WORDS = Registry(InterVars.words)

# images
LOADED_IMAGES = Registry(InterVars.loaded_images)

# audios
LOADED_AUDIOS = Registry(InterVars.loaded_audios)

# videos
LOADED_VIDEOS = Registry(InterVars.loaded_videos)
INTER_SAMPLED_FRAMES = Registry(InterVars.sampled_frames)

# all
ALL_INTER_VARS = [INTER_LINES, INTER_WORDS, LOADED_AUDIOS, LOADED_IMAGES, LOADED_VIDEOS, INTER_SAMPLED_FRAMES]

# supported fusion strategies
FUSION_STRATEGIES = {"greedy", "probe"}
MAPPER_FUSION_SAFE_ATTR = "_fused_sequential_batch_op_safe"


[docs] def fuse_operators(ops, probe_res=None, mapper_fusion=True, mapper_fusion_vram_limit=0.9): """ Fuse the input ops list and return the fused ops list. :param ops: the corresponding list of op objects. :param probe_res: the probed speed for each OP from Monitor. :param mapper_fusion: whether to fuse consecutive independent GPU Mappers into FusedSequentialBatchOp for single-stage execution. Only effective when op_fusion is true. :param mapper_fusion_vram_limit: max aggregate GPU memory budget (fraction of one GPU) for a fused mapper group. Default 0.9. :return: a list of fused op objects. """ if probe_res is None: probe_res = [None for _ in range(len(ops))] # detect filter groups and try to fuse them fused_ops = [] filter_group = [] for op, op_probe in zip(ops, probe_res): if isinstance(op, Filter): filter_group.append((op, op_probe)) else: if filter_group: # got a filter group, try to fuse them fused_ops.extend(fuse_filter_group(filter_group)) filter_group = [] # and add the current non-filter op into fused_ops fused_ops.append(op) # the final filter group, try to fuse them if filter_group: fused_ops.extend(fuse_filter_group(filter_group)) # Phase 2: fuse consecutive GPU Mappers if mapper_fusion: fused_ops = fuse_consecutive_mappers(fused_ops, vram_limit=mapper_fusion_vram_limit) return fused_ops
[docs] def fuse_filter_group(original_filter_group): """ Fuse single filter group and return the fused filter group. :param original_filter_group: the original filter group, including op definitions and objects. :return: the fused definitions and objects of the input filter group. """ fused_group = [] group_speed = [] all_intermediate_vars = ALL_INTER_VARS all_fused_filters = {inter_vars: [] for inter_vars in all_intermediate_vars} # group these filters by their intermediate vars for op, probe_res in original_filter_group: op_name = op._name for inter_vars in all_intermediate_vars: if op_name in inter_vars.modules: all_fused_filters[inter_vars].append((op, probe_res)) break else: # first apply other filters to decrease the number of samples, so # we add them into the fused_group list directly fused_group.append(op) group_speed.append(probe_res["speed"] if probe_res else 0) # try to fuse ops for each type of intermediate vars for inter_vars in all_intermediate_vars: inter_vars_filter = all_fused_filters[inter_vars] if len(inter_vars_filter) == 0: # no ops include this type of intermediate var pass elif len(inter_vars_filter) > 1: # more than 1 ops share the same intermediate var, try to fuse them ops, probe_res_list = zip(*inter_vars_filter) # new definition: new name and a definition list of fused op list fused_filter_name = "OpFusion:(%s)" % ",".join([op._name for op in ops]) logger.info(f"Ops are fused into one op " f"{fused_filter_name}.") # use these ops to create a FusedFilter object, and add the fused # definition and op into the fused group fused_filter = FusedFilter(fused_filter_name, ops) fused_filter._op_cfg = {fused_filter_name: [op._op_cfg for op in ops]} fused_filter_speed = sum([1.0 / probe_res["speed"] for probe_res in probe_res_list if probe_res]) if fused_filter_speed > 0: fused_filter_speed = 1.0 / fused_filter_speed fused_group.append(fused_filter) group_speed.append(fused_filter_speed) else: # only 1 op for this type of intermediate var, add it to the fused # group directly without fusion fused_group.append(inter_vars_filter[0][0]) probe_res = inter_vars_filter[0][1] group_speed.append(probe_res["speed"] if probe_res else 0) # reorder according to the probed speed results in group_speed # 'greedy': all speed data in group_speed will be 0, which will keep the # current order of fused group # 'probe': OPs in fused group will be reordered according to the speed data # in group_speed in descending order fused_group = [op for op, _ in sorted(zip(fused_group, group_speed), key=lambda it: it[1], reverse=True)] return fused_group
[docs] class FusedFilter(Filter): """A fused operator for filters.""" _batched_op = True
[docs] def __init__(self, name: str, fused_filters: List): """ Initialization method. :param fused_filters: a list of filters to be fused. """ self._name = name super().__init__() self.fused_filters = fused_filters # set accelerator to 'cuda' if there exists any ops whose accelerator # is 'cuda' accelerator_methods = set([op.accelerator for op in self.fused_filters]) if "cuda" in accelerator_methods: self.accelerator = "cuda" # update num_proc with the min num_proc of all fusible filters self.num_proc = min([op.runtime_np() for op in self.fused_filters])
[docs] def compute_stats_batched(self, samples, rank=None): from data_juicer.utils.video_utils import setup_av av = LazyLoader("av", post_import=setup_av) # context for the intermediate vars num_samples = len(samples[Fields.stats]) samples[Fields.context] = [{} for _ in range(num_samples)] for op in self.fused_filters: # open the context for these fused ops if op.accelerator == "cuda": samples = op.compute_stats_batched(samples, rank=rank, context=True) else: samples = op.compute_stats_batched(samples, context=True) # clean up the contexts after processing # check if there are containers that need to be closed for ctx in samples[Fields.context]: for context_key in ctx: if isinstance(ctx[context_key], av.container.InputContainer): ctx[context_key].streams.video[0].close() ctx[context_key].close() _ = samples.pop(Fields.context) return samples
[docs] def process_batched(self, samples): # Only return True when all filters return True res = None for op in self.fused_filters: this_res = np.array(list(op.process_batched(samples))) if res is not None: res = np.logical_and(res, this_res) else: res = this_res return res
[docs] @OPERATORS.register_module("general_fused_op") class GeneralFusedOP(Mapper): """An explicitly fused operator designed to execute multiple sequential operations (OPs) on the same batch, enabling fine-grained control over data processing. This operator allows for the chaining of multiple data processing steps, such as mappers and filters, into a single pass. It processes each batch of samples sequentially through the defined operations, ensuring that all specified transformations are applied in order. The operator supports both mappers, which transform data, and filters, which remove or keep samples based on computed statistics. Context variables can be passed between operations if needed. The accelerator is set to 'cuda' if any of the fused operations use it. The number of processes is determined by the minimum value among all fused operations. After processing, any temporary context variables, such as those used for video containers, are cleaned up.""" _batched_op = True
[docs] def __init__(self, batch_size: int = 1, fused_op_list: Optional[List] = None, *args, **kwargs): """ Initialization. :param batch_size: the batch size of the input samples. :param fused_op_list: a list of OPs to be fused. """ super().__init__(*args, **kwargs) self.batch_size = batch_size if fused_op_list is None: fused_op_list = [] self.fused_ops = load_ops(fused_op_list) self._name = "GeneralFusedOP:(%s)" % ",".join([op._name for op in self.fused_ops]) # set accelerator to 'cuda' if there exists any ops whose accelerator # is 'cuda' accelerator_methods = set([op.accelerator for op in self.fused_ops]) if "cuda" in accelerator_methods: self.accelerator = "cuda" # update num_proc with the min num_proc of all fusible filters self.num_proc = min([op.runtime_np() for op in self.fused_ops]) if self.fused_ops else 1
[docs] def process_batched(self, samples, rank=None): return execute_sequential_batch( samples, self.fused_ops, rank=rank, owner_name=self._name, policy=GENERAL_FUSED_EXECUTION_POLICY, )
[docs] def run(self, dataset, *, exporter=None, tracer=None): # prepare the dataset from data_juicer.core.data import NestedDataset if not isinstance(dataset, NestedDataset): dataset = NestedDataset(dataset) if not self.fused_ops: return dataset # initialize for different kinds of datasets for op in self.fused_ops: dataset = OP.run(op, dataset) new_dataset = dataset.map( self.process_batched, num_proc=self.num_proc, with_rank=self.use_cuda(), batch_size=self.batch_size, desc=self._name + "_process", ) return new_dataset
def _is_gpu_mapper(op) -> bool: """Check if an op is a Mapper that requires GPU.""" return isinstance(op, Mapper) and (getattr(op, "num_gpus", 0) or 0) > 0 def _is_fusible_gpu_mapper(op) -> bool: """Check whether a GPU Mapper explicitly opts into stage fusion.""" return _is_gpu_mapper(op) and bool(getattr(op, MAPPER_FUSION_SAFE_ATTR, False)) def _estimated_vram_fraction(op) -> Optional[float]: value = getattr(op, "estimated_vram_fraction", None) if value is None: return None try: value = float(value) except (TypeError, ValueError) as exc: raise ValueError(f"Mapper [{op._name}] has invalid estimated_vram_fraction [{value}].") from exc if not 0 < value <= 1: raise ValueError(f"Mapper [{op._name}] estimated_vram_fraction must be in (0, 1], " f"but got [{value}].") return value def _runtime_envs_compatible(ops: list) -> bool: if not ops: return True first_runtime_env = getattr(ops[0], "runtime_env", None) return all(getattr(op, "runtime_env", None) == first_runtime_env for op in ops[1:]) def _are_ops_independent(ops: list) -> bool: """Check if ops write to disjoint output keys and have no local dependencies.""" produced_cols = set() for op in ops: op_reads = set(getattr(op, "_input_columns", []) or []) op_writes = set(getattr(op, "_output_columns", []) or []) if not op_writes: logger.debug(f"Mapper fusion: op [{op._name}] does not define " f"_output_columns; skipping stage fusion") return False if op_writes & produced_cols: logger.debug( f"Mapper fusion: op [{op._name}] writes columns already " f"produced by the current group: {sorted(op_writes & produced_cols)}" ) return False if op_reads & produced_cols: logger.debug( f"Mapper fusion: op [{op._name}] reads columns produced " f"by the current group: {sorted(op_reads & produced_cols)}" ) return False produced_cols.update(op_writes) return True def _mapper_group_blocker(mapper_group: list, vram_limit: float) -> Optional[str]: if not 0 < vram_limit <= 1: raise ValueError(f"mapper_fusion_vram_limit must be in (0, 1], but got [{vram_limit}].") if not all(_is_fusible_gpu_mapper(op) for op in mapper_group): return "at least one op has not explicitly opted into sequential batch fusion" if not _are_ops_independent(mapper_group): return "ops are not independent (shared column dependencies)" if not _runtime_envs_compatible(mapper_group): return "ops require different Ray runtime environments" vram_fractions = [_estimated_vram_fraction(op) for op in mapper_group] missing_estimates = [op._name for op, fraction in zip(mapper_group, vram_fractions) if fraction is None] if missing_estimates: return "ops do not declare estimated_vram_fraction: " f"{missing_estimates}" total_vram_fraction = sum(vram_fractions) if total_vram_fraction > vram_limit: return f"aggregate estimated VRAM ({total_vram_fraction:.2f}) exceeds " f"limit ({vram_limit:.2f})" return None
[docs] def fuse_mapper_group(mapper_group: list, vram_limit: float = 0.9) -> list: """Fuse consecutive independent GPU Mappers into FusedSequentialBatchOp. Safety rules: - All ops must be Mapper instances with num_gpus > 0 - All ops must explicitly opt in with _fused_sequential_batch_op_safe = True - Ops must be independent (disjoint declared output columns) - All ops must declare estimated_vram_fraction - Aggregate estimated VRAM should not exceed vram_limit - All ops must use the same Ray runtime environment Returns a list with either the original ops (if not fuseable) or a single FusedSequentialBatchOp wrapping the group. """ from data_juicer.ops.fused_sequential_batch_op import FusedSequentialBatchOp if not mapper_group: return [] blocker = _mapper_group_blocker(mapper_group, vram_limit) if blocker: logger.info(f"Mapper fusion: skipping group {[op._name for op in mapper_group]} " f"because {blocker}") return list(mapper_group) group_name = "fused:" + ",".join(op._name for op in mapper_group) num_proc = min(op.runtime_np() for op in mapper_group) batch_size = min(getattr(op, "batch_size", 1) or 1 for op in mapper_group) num_cpus_values = [getattr(op, "num_cpus", None) for op in mapper_group] num_cpus_values = [value for value in num_cpus_values if value is not None] num_cpus = max(num_cpus_values) if num_cpus_values else None fused = FusedSequentialBatchOp( fused_ops=mapper_group, group_name=group_name, accelerator="cuda", num_gpus=1.0, num_cpus=num_cpus, num_proc=num_proc, batch_size=batch_size, auto_op_parallelism=False, runtime_env=getattr(mapper_group[0], "runtime_env", None), ) fused._op_cfg = {group_name: [getattr(op, "_op_cfg", {op._name: {}}) for op in mapper_group]} logger.info( f"Ops are fused into FusedSequentialBatchOp '{group_name}' " f"({len(mapper_group)} ops, num_gpus={fused.num_gpus}, " f"num_proc={fused.num_proc}, batch_size={fused.batch_size})" ) return [fused]
[docs] def fuse_consecutive_mappers(ops: list, vram_limit: float = 0.9) -> list: """Scan op list and fuse consecutive GPU Mapper groups. Groups are delimited by non-Mapper ops or CPU ops. Each group of >= 2 consecutive GPU Mappers is fused into a FusedSequentialBatchOp. Single GPU Mappers pass through. """ if not 0 < vram_limit <= 1: raise ValueError(f"mapper_fusion_vram_limit must be in (0, 1], but got [{vram_limit}].") result = [] mapper_group = [] def flush_group(): nonlocal mapper_group if len(mapper_group) >= 2: result.extend(fuse_mapper_group(mapper_group, vram_limit=vram_limit)) else: result.extend(mapper_group) mapper_group = [] for op in ops: if _is_fusible_gpu_mapper(op): candidate_group = mapper_group + [op] if mapper_group and _mapper_group_blocker(candidate_group, vram_limit): flush_group() mapper_group.append(op) else: flush_group() result.append(op) flush_group() return result