Coverage for / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / core / shard / _op_dispatch.py: 82%
391 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-08-04 05:18 +0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-08-04 05:18 +0800
1# Copyright 2025-2026 Huawei Technologies Co., Ltd
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14# ============================================================================
15"""_op_dispatch"""
16import atexit
17import glob
18import importlib
19import logging
20import os
21import sys
22import warnings
23from contextvars import ContextVar
24from itertools import chain
25from typing import Any, Dict, FrozenSet, List, Optional
27import yaml
29from hyper_parallel.core.shard.ops.parallel_ops_register import get_distributed_op
30from hyper_parallel.core.dtensor.dtensor import DTensor
31from hyper_parallel.core.dtensor.random import OffsetBasedRNGTracker, is_rng_supported_mesh
32from hyper_parallel.core.dtensor.debug._dispatch_logger import log_dispatch_enter, log_dispatch_exit
33from hyper_parallel.platform import get_platform
34from hyper_parallel.platform.platform import PlatformType
36from hyper_parallel.core.tensor_parallel._ce_op_registry import is_loss_parallel_op, is_decomposed_ce_op
37from hyper_parallel.core.tensor_parallel.loss_parallel import is_loss_parallel_active
38from hyper_parallel.core.tensor_parallel.loss_parallel_ops_common import _is_shard_on_last_dim
40platform = get_platform()
41Tensor = platform.Tensor
43logger = logging.getLogger(__name__)
46def _apply_shard_offset_to_rng_args(args, offset_incr):
47 """Apply per-shard offset increment to seed/offset tensors in MindSpore random op args.
49 MindSpore random ops (e.g. ``randn_like_``) receive ``(seed, offset)`` as
50 explicit int64 scalar tensors from ``default_generator._step()`` in the
51 Python wrapper *before* the C++ dispatch triggers ``__fallback__``. By the
52 time ``_dispatch_random_op`` is called, the kernel will use whatever
53 ``(seed, offset)`` values are in the args—it does **not** read the
54 generator again. This function finds the offset tensor and adds the
55 per-rank offset increment so each shard gets a unique random stream.
57 The (seed, offset) pair is identified as the last two consecutive int64
58 0-dim tensors in *args* (scanning from the end to skip trailing dtype /
59 device arguments).
61 Args:
62 args: The list of local args for the random op.
63 offset_incr (int): Per-shard offset increment.
65 Returns:
66 list: Modified args with the offset tensor adjusted.
67 """
68 int64_dtype = platform.tensor_dtype.int64
69 last_int64_idx = -1
70 for i in range(len(args) - 1, -1, -1):
71 arg = args[i]
72 if isinstance(arg, Tensor) and arg.dtype == int64_dtype and arg.ndim == 0:
73 if last_int64_idx == i + 1:
74 offset_idx = i + 1
75 new_args = list(args)
76 new_offset = int(new_args[offset_idx].item()) + offset_incr
77 new_args[offset_idx] = platform.tensor([new_offset], dtype=int64_dtype).reshape(())
78 return new_args
79 last_int64_idx = i
80 return args
82_dtensor_dispatch_disabled: ContextVar[bool] = ContextVar('_dtensor_dispatch_disabled', default=False)
83_no_skip_ops: ContextVar[FrozenSet[str]] = ContextVar('_no_skip_ops', default=frozenset())
84_debug_mode_observer: ContextVar = ContextVar('_debug_mode_observer', default=None)
87def get_no_skip_ops() -> FrozenSet[str]:
88 """Return the set of op names that are exempt from SkipDTensorDispatch."""
89 return _no_skip_ops.get()
92def get_dtensor_dispatch() -> bool:
93 """
94 Get the current DTensor dispatch status.
96 Returns:
97 bool: True if DTensor dispatch is enabled, False otherwise.
98 """
99 return not _dtensor_dispatch_disabled.get()
102class LayoutCacheKey:
103 """Immutable layout cache key."""
104 __slots__ = ('_tuple', '_hash')
106 def __init__(self, layout_ids: List[str]):
107 self._tuple = tuple(layout_ids)
108 self._hash = hash(self._tuple)
110 @classmethod
111 def from_cache_values(cls, cache_values: list) -> "LayoutCacheKey":
112 """Build a LayoutCacheKey from a cache_values list.
114 Args:
115 cache_values (list): Mixed list of Layout objects (with compact_str) and raw scalars.
117 Returns:
118 LayoutCacheKey: Immutable key derived from the string representation of each value.
119 """
120 # Read the cached ``_compact_str`` attribute directly instead of going through
121 # the ``compact_str`` property getter (one fewer Python frame per Layout), and
122 # build the key in one comprehension. The resulting tuple is byte-for-byte the
123 # legacy string key, so eq/hash semantics are unchanged.
124 # NOTE: the key stays a string tuple by design (cross-checked against manually
125 # built legacy keys in the UTs); CPython caches each compact_str's hash on the
126 # string object, so this is not the bottleneck a full integer key would target.
127 return cls([cs if (cs := getattr(v, '_compact_str', None)) is not None else str(v)
128 for v in cache_values])
130 def __eq__(self, other):
131 if not isinstance(other, LayoutCacheKey):
132 return False
133 return self._tuple == other._tuple
135 def __hash__(self):
136 return self._hash
138 def __repr__(self):
139 return f"LayoutCacheKey({self._tuple})"
142class LayoutCacheManager:
143 """
144 Cache layout in infer layout.
146 A singleton class that manages layout caches for distributed operations.
147 It caches the inferred layouts and operation implementations to avoid
148 redundant computation during repeated calls with the same input layouts.
149 """
150 _instance = None
152 def __init__(self):
153 self.layout_cache: Dict[str, Dict[LayoutCacheKey, Any]] = {}
154 atexit.register(self.clear_cache)
156 @classmethod
157 def get_instance(cls) -> "LayoutCacheManager":
158 """
159 Get the singleton instance of LayoutCacheManager.
161 Returns:
162 LayoutCacheManager: The singleton instance.
163 """
164 if cls._instance is None:
165 cls._instance = LayoutCacheManager()
166 return cls._instance
168 def get_layout_cache(self) -> Dict[str, Dict[LayoutCacheKey, Any]]:
169 """
170 Get the layout cache dictionary.
172 Returns:
173 Dict[str, Dict[LayoutCacheKey, Any]]: The nested dictionary mapping
174 operation names to their layout caches.
175 """
176 return self.layout_cache
178 @staticmethod
179 def distributed_op(op_name: str) -> Any:
180 """
181 Get the distributed operation implementation by name.
183 Args:
184 op_name (str): The name of the distributed operation.
186 Returns:
187 Any: The distributed operation class or implementation.
188 """
189 op = get_distributed_op(op_name)
190 return op
192 def clear_cache(self) -> None:
193 """
194 Clear all cached layouts.
196 This method is automatically registered with atexit to ensure
197 cache is cleared when the program exits.
198 """
199 self.layout_cache.clear()
202class OpDispatcher:
203 """
204 OpDispatcher
205 """
207 # Whitelisted ops that mutate args[0]'s storage in place. The dispatch bypass
208 # must return the original DTensor self for these, not the unwrapped local
209 # result, or it demotes a DTensor accumulator to a plain Tensor and breaks the
210 # next op that adds a DTensor to it (e.g. grad-accumulation `loss += micro_loss`).
211 # Class-level so it stays available on instances built via __new__ (e.g. tests).
212 _INPLACE_BYPASS_OPS = frozenset(
213 {"InplaceAddExt", "InplaceSubExt", "InplaceMul", "InplaceDiv"})
215 # MindSpore random kernels that always mutate an existing tensor in place.
216 # Out-of-place random kernels belong in _random_ms_ops only, not here.
217 _RANDOM_INPLACE_MS_OPS = frozenset({
218 "InplaceBernoulliScalar",
219 "InplaceBernoulliTensor",
220 "InplaceNormal",
221 "InplaceRandom",
222 "InplaceUniform",
223 })
225 def __init__(self):
226 self._env_yaml_dir: Optional[str] = os.environ.get("HYPER_PARALLEL_OPS_YAML_DIR")
227 self._env_python_path: Optional[str] = os.environ.get("HYPER_PARALLEL_OPS_PYTHON_PATH")
228 # The following attributes are initialized in _setup_yaml_dir()
229 self.work_dir = "" # Initialized in _setup_yaml_dir()
230 self.yaml_dir = "" # Initialized in _setup_yaml_dir()
232 self._setup_paths_from_env()
234 self.layout_infer_ops = self.safe_load_yaml_from_dir()
235 # frozenset for O(1) membership (checked on every dispatch's bypass test).
236 self.whitelist = frozenset({"typeof", "DistCommIsend",
237 "DistCommIrecv", "DistCommBroadcast", "DistCommAllReduce", "DistCommAllGather",
238 "DistCommBatchIsendIrecv",
239 "DistCommReduceScatter", "requires_grad_", "item", "__get__", "__set__",
240 "register_hook",
241 "is_complex", "chunk", "__bool__", "__len__", "__format__", "dim",
242 "_has_compatible_shallow_copy_type", "is_floating_point", "is_contiguous"})
244 # Ops requiring args unpacking for layout inference (packed as prim, name, real_args).
245 # frozenset so the aclop-normalization gate in _dispatch_layout_infer is O(1).
246 self.unpack_ops = frozenset({"ScatterUpdate", "Mod", "GatherNd", "StopGradient"})
248 self._random_ops = {
249 "normal_", "uniform_", "bernoulli", "bernoulli_",
250 "native_dropout", "rand", "rand_like", "randn",
251 "randn_like", "randint_like", "kaiming_uniform_",
252 "multinomial",
253 }
254 # Only mint random op support
255 # MindSpore use the actual kernel name.
256 self._random_ms_ops = {
257 "BernoulliExt", "MultinomialExt",
258 "InplaceBernoulliScalar", "InplaceBernoulliTensor",
259 "InplaceNormal", "InplaceRandom", "InplaceUniform",
260 "NormalFloatFloat", "NormalFloatTensor", "NormalTensorFloat", "NormalTensorTensor",
261 "RandpermExt", "Randn", "RandLikeExt", "RandnLike", "RandInt", "RandIntLike", "RandExt",
262 "FuncDropoutExt", "UniformExt",
263 }
264 self._rng_tracker: Optional[OffsetBasedRNGTracker] = None
265 # Op names proven to be loss/CE-irrelevant (both is_loss_parallel_op and
266 # is_decomposed_ce_op are False). For these the loss_parallel / decomposed-CE
267 # guards in dispatch() are always no-ops regardless of context, so we skip
268 # them (and their is_loss_parallel_active() contextvar reads) on later calls.
269 self._non_loss_ops: set = set()
271 self._register_distributed_ops()
273 def _setup_paths_from_env(self):
274 """
275 Setup YAML directory and Python path from environment variables.
277 This method initializes the YAML directory and extends sys.path based on
278 environment variables HYPER_PARALLEL_OPS_YAML_DIR and HYPER_PARALLEL_OPS_PYTHON_PATH.
279 """
280 self._setup_yaml_dir(self._env_yaml_dir)
281 self._extend_sys_path(self._env_python_path)
283 def _setup_yaml_dir(self, env_yaml_dir: Optional[str]):
284 """
285 Feature: Configure yaml_dir/work_dir for OpDispatcher
286 Description: Resolve the YAML directory used to load distributed op definitions.
287 If env_yaml_dir is an absolute path, use it directly; otherwise treat it
288 as a path relative to the project work_dir. If env_yaml_dir is not set,
289 fall back to the default 'shard/ops/yaml' under work_dir.
290 Expectation: self.yaml_dir and self.work_dir are set to valid values used later by
291 safe_load_yaml_from_dir(); no functional behavior is changed.
292 """
293 if env_yaml_dir:
294 if os.path.isabs(env_yaml_dir):
295 self.yaml_dir = env_yaml_dir
296 self.work_dir = ""
297 else:
298 self.work_dir = os.path.normpath(
299 os.path.join(os.path.dirname(os.path.realpath(__file__)), "../")
300 )
301 self.yaml_dir = env_yaml_dir
302 else:
303 self.yaml_dir = "shard/ops/yaml"
304 self.work_dir = os.path.normpath(
305 os.path.join(os.path.dirname(os.path.realpath(__file__)), "../")
306 )
308 @staticmethod
309 def _extend_sys_path(env_python_path: Optional[str]):
310 if not env_python_path:
311 return
312 python_paths = env_python_path.split(":")
313 for path in python_paths:
314 if path and os.path.isdir(path) and path not in sys.path:
315 sys.path.append(path)
317 def _register_distributed_ops(self):
318 for op_name, config in self.layout_infer_ops.items():
319 self._register_single_distributed_op(op_name, config)
321 def _register_single_distributed_op(self, op_name: str, config: dict):
322 """
323 Feature: Register a single distributed op implementation
324 Description: Import the distributed op class specified by config and instantiate it
325 with op_name to trigger registration in the distributed op registry.
326 Prefer 'distributed_op_module' when provided; otherwise import from
327 built-in module prefix 'hyper_parallel.core.shard.ops.' plus
328 'distributed_op_file'. If import fails and an external python path is
329 provided via env, fall back to importing 'distributed_op_file' directly.
330 Expectation: The distributed op class is imported and instantiated successfully,
331 or the original import error is raised; no functional behavior is changed.
332 """
333 class_name = config["distributed_op_class"]
335 if "distributed_op_module" in config:
336 module_name = config["distributed_op_module"]
337 module = importlib.import_module(module_name)
338 op_class = getattr(module, class_name)
339 _ = op_class(op_name)
340 return
342 module_file = config["distributed_op_file"]
343 try:
344 module_name = "hyper_parallel.core.shard.ops." + module_file
345 module = importlib.import_module(module_name)
346 op_class = getattr(module, class_name)
347 _ = op_class(op_name)
348 except (ModuleNotFoundError, ImportError):
349 if self._env_python_path:
350 module = importlib.import_module(module_file)
351 op_class = getattr(module, class_name)
352 _ = op_class(op_name)
353 else:
354 raise
356 @staticmethod
357 def _merge_default(config: dict):
358 """Apply __default__ values to all ops in this YAML file."""
359 if "__default__" not in config:
360 return config
362 default_cfg = config["__default__"]
363 merged = {}
365 for op_name, op_cfg in config.items():
366 if op_name == "__default__":
367 continue
369 new_cfg = default_cfg.copy()
370 new_cfg.update(op_cfg)
371 merged[op_name] = new_cfg
373 return merged
375 def safe_load_yaml_from_dir(self) -> dict:
376 """
377 Load yaml dictionary from directory.
379 Returns:
380 dict: Merged dictionary of all operator configurations loaded from YAML files.
381 """
382 yaml_dict = {}
383 yaml_path = os.path.join(self.work_dir, self.yaml_dir) if self.work_dir else self.yaml_dir
384 if not os.path.isdir(yaml_path):
385 raise ValueError(f"Invalid yaml directory path: {yaml_path}")
387 for yaml_file_path in glob.glob(os.path.join(yaml_path, '*.yaml')):
388 with open(yaml_file_path, 'r', encoding="utf-8") as f:
389 yaml_data = yaml.safe_load(f)
391 yaml_data = OpDispatcher._merge_default(yaml_data)
392 for name, data in yaml_data.items():
393 if name in yaml_dict:
394 raise ValueError(f"Duplicate yaml object with name '{name}'.")
395 yaml_dict[name] = data
397 return yaml_dict
399 def _dispatch_random_op(self, op_name: str, op_call: callable, args, kwargs):
400 """Handle dispatch for random ops that operate on DTensors."""
401 first_arg = next(
402 (x for x in chain(args, kwargs.values()) if isinstance(x, DTensor)),
403 None,
404 )
405 # Fall back to the default op if no DTensor is found.
406 if first_arg is None:
407 return op_call(*args, **kwargs)
409 local_args = [arg.to_local() if isinstance(arg, DTensor) else arg for arg in args]
410 local_kwargs = {k: v.to_local() if isinstance(v, DTensor) else v for k, v in kwargs.items()}
411 first_local_arg = first_arg.to_local()
413 if self._rng_tracker is None and is_rng_supported_mesh(first_arg.device_mesh):
414 self._rng_tracker = OffsetBasedRNGTracker()
416 maybe_user_generator = local_kwargs.pop("generator", None)
417 if (
418 self._rng_tracker is not None
419 and not first_local_arg.is_meta
420 and self._rng_tracker.distribute_region_enabled
421 ):
422 # pylint: disable=W0212
423 with self._rng_tracker._distribute_region(
424 device_mesh=first_arg.device_mesh,
425 placements=first_arg.placements,
426 global_shape=first_arg.shape,
427 generator=maybe_user_generator,
428 ):
429 # MindSpore random ops (e.g. mint.randn_like) extract (seed, offset)
430 # from default_generator._step() in the Python wrapper *before* the
431 # C++ dispatch triggers __fallback__. The callback reuses these
432 # pre-fetched tensor args, so set_rng_state inside _distribute_region
433 # has no effect on the kernel. Fix: apply the per-shard offset
434 # increment directly to the offset tensor in the args.
435 if platform.platform_type == PlatformType.MINDSPORE:
436 offset_incr = self._rng_tracker.compute_offset_incr(
437 first_arg.device_mesh, first_arg.placements, first_arg.shape,
438 )
439 local_args = _apply_shard_offset_to_rng_args(local_args, offset_incr)
440 local_results = op_call(*local_args, **local_kwargs)
441 else:
442 if maybe_user_generator is not None:
443 local_kwargs["generator"] = maybe_user_generator
444 local_results = op_call(*local_args, **local_kwargs)
446 return self._wrap_random_result(op_name, local_results, first_arg, args, kwargs)
448 @staticmethod
449 def _func_dropout_ext_inplace(args, kwargs) -> bool:
450 """Return True when FuncDropoutExt is invoked with inplace=True."""
451 # Kernel signature: (input, p, training, inplace, seed, offset).
452 if len(args) >= 4:
453 return bool(args[3])
454 return bool(kwargs.get("inplace", False))
456 @staticmethod
457 def _random_op_returns_self(op_name: str, args, kwargs) -> bool:
458 """Return True when a random op mutates an existing DTensor in place."""
459 if op_name in OpDispatcher._RANDOM_INPLACE_MS_OPS:
460 return True
461 if op_name == "FuncDropoutExt":
462 return OpDispatcher._func_dropout_ext_inplace(args, kwargs)
463 # Torch random inplace ops follow the ATen '_' suffix convention.
464 return op_name.endswith('_')
466 @staticmethod
467 def _wrap_random_result(op_name, local_results, first_arg, args, kwargs):
468 """Wrap a random op's local result(s) back into DTensor(s).
470 In-place ops return the input DTensor itself. Torch random inplace ops use
471 the ATen '_' suffix; MindSpore inplace random kernels are listed in
472 ``_RANDOM_INPLACE_MS_OPS``. ``FuncDropoutExt`` is handled separately
473 because the same kernel serves both modes via its ``inplace`` argument.
474 """
475 if OpDispatcher._random_op_returns_self(op_name, args, kwargs):
476 return first_arg
477 mesh = first_arg.device_mesh
478 placements = first_arg.layout.alias_placements
479 # Some ops return tuple/list, e.g. native_dropout returns (output, mask).
480 if isinstance(local_results, (tuple, list)):
481 return tuple(
482 DTensor.from_local(r, mesh, placements) if isinstance(r, Tensor) else r
483 for r in local_results
484 )
485 if isinstance(local_results, Tensor):
486 return DTensor.from_local(local_results, mesh, placements)
487 # Fallback: return as-is for non-Tensor results (currently unreachable with existing _random_ops).
488 return local_results
490 @staticmethod
491 def _unwrap_value(value: object) -> object:
492 """Replace DTensor with its local tensor; pass scalars and plain tensors through.
494 Args:
495 value (object): A single argument value from an op call.
497 Returns:
498 object: The local tensor if value is a DTensor, otherwise value unchanged.
499 """
500 if isinstance(value, DTensor):
501 return value.to_local()
502 if isinstance(value, tuple):
503 return tuple(OpDispatcher._unwrap_value(e) for e in value)
504 if isinstance(value, list):
505 return [OpDispatcher._unwrap_value(e) for e in value]
506 return value
508 @staticmethod
509 def _unwrap_args(args: tuple) -> list:
510 """Strip DTensor wrappers from args, preserving tuple/list container structure.
512 Args:
513 args: Op call positional arguments, may contain DTensor instances.
515 Returns:
516 List of args with DTensor replaced by their local tensors.
517 """
518 return [OpDispatcher._unwrap_value(arg) for arg in args]
520 @staticmethod
521 def _unwrap_kwargs(kwargs: dict) -> dict:
522 """Strip DTensor wrappers from kwargs values, preserving tuple/list container structure.
524 Args:
525 kwargs: Op call keyword arguments, values may contain DTensor instances.
527 Returns:
528 Dict of kwargs with DTensor values replaced by their local tensors.
529 """
530 return {k: OpDispatcher._unwrap_value(v) for k, v in kwargs.items()}
532 @staticmethod
533 def _gather_dtensors_to_full(args: tuple, kwargs: dict) -> tuple:
534 """Gather all DTensor arguments to full tensors for fallback execution.
536 Used when an operator has no parallel layout implementation. All DTensor
537 arguments are gathered to full tensors before calling the standard operator.
539 Args:
540 args: Op call positional arguments, may contain DTensor instances.
541 kwargs: Op call keyword arguments, may contain DTensor instances.
543 Returns:
544 Tuple of (unwrapped_args, unwrapped_kwargs) with DTensor values
545 replaced by their full tensor representations.
547 Warning:
548 This fallback performs all-gather which may consume significant memory.
549 Operators without layout implementations should be registered properly.
550 """
551 def gather(value: object) -> object:
552 if isinstance(value, DTensor):
553 return value.full_tensor()
554 if isinstance(value, tuple):
555 return tuple(gather(e) for e in value)
556 if isinstance(value, list):
557 return [gather(e) for e in value]
558 return value
560 gathered_args = [gather(arg) for arg in args]
561 gathered_kwargs = {k: gather(v) for k, v in kwargs.items()}
563 warnings.warn(
564 "Operator has no distributed layout implementation. "
565 "Falling back to all-gather which may consume significant memory. "
566 "Consider registering a proper distributed operator.",
567 UserWarning,
568 stacklevel=4
569 )
571 return gathered_args, gathered_kwargs
573 def _should_bypass_dispatch(self, op_name: str) -> bool:
574 """Return True if the op should bypass DTensor dispatch and run locally.
576 Args:
577 op_name: Canonical operator name from platform.get_op_name().
579 Returns:
580 True when the op is whitelisted or DTensor dispatch is globally disabled.
581 """
582 # Cheap O(1) frozenset checks first, short-circuit before the ContextVar
583 # read (get_dtensor_dispatch) which is the priciest part of this guard.
584 if op_name in self.whitelist or op_name in self._INPLACE_BYPASS_OPS:
585 return True
586 return get_dtensor_dispatch() is False and op_name not in get_no_skip_ops()
588 @staticmethod
589 def _validate_inplace_partial_inputs(op_name: str, args: tuple, kwargs: dict) -> None:
590 """Reject local in-place add/sub when Partial contributions need gating."""
591 if op_name not in {"InplaceAddExt", "InplaceSubExt"} or not args:
592 return
593 first = args[0]
594 if len(args) >= 2:
595 second = args[1]
596 elif "other" in kwargs:
597 second = kwargs["other"]
598 else:
599 return
600 if not isinstance(first, DTensor):
601 return
602 mesh_ndim = len(first.layout.partial)
603 first_partial = tuple(first.layout.partial)
604 if isinstance(second, DTensor):
605 second_partial = tuple(second.layout.partial)
606 if len(second_partial) != mesh_ndim:
607 raise ValueError(
608 f"For {op_name}, in-place input mesh dimensions must match, "
609 f"but got {mesh_ndim} and {len(second_partial)}."
610 )
611 else:
612 second_partial = (None,) * mesh_ndim
613 if first_partial != second_partial:
614 raise ValueError(
615 f"For {op_name}, input Partial placements must be identical for "
616 f"local in-place execution, but got {first_partial} and {second_partial}."
617 )
619 def _should_dispatch_loss_parallel(self, op_name: str) -> bool:
620 """Check if should dispatch through loss_parallel path.
622 Args:
623 op_name: Canonical operator name from platform.get_op_name().
625 Returns:
626 True when in loss_parallel context and op is a CE entry point.
627 """
628 return is_loss_parallel_active() and is_loss_parallel_op(op_name)
630 def _check_decomposed_ce_op_in_loss_parallel(self, op_name: str, args: tuple, kwargs: dict):
631 """Check if decomposed CE ops are called in loss_parallel context.
633 Args:
634 op_name: Canonical operator name.
635 args: Positional arguments for op_call.
636 kwargs: Keyword arguments for op_call.
638 Raises:
639 ValueError: If decomposed CE op is called in loss_parallel context
640 with vocab-sharded DTensor input.
641 """
642 if not is_loss_parallel_active() or not is_decomposed_ce_op(op_name):
643 return
645 has_vocab_sharded_dtensor = False
646 for arg in args:
647 if isinstance(arg, DTensor) and _is_shard_on_last_dim(arg):
648 has_vocab_sharded_dtensor = True
649 break
650 if not has_vocab_sharded_dtensor:
651 for val in kwargs.values():
652 if isinstance(val, DTensor) and _is_shard_on_last_dim(val):
653 has_vocab_sharded_dtensor = True
654 break
656 if has_vocab_sharded_dtensor:
657 raise ValueError(
658 f"Operator '{op_name}' is a decomposed component of cross_entropy and should not be called "
659 f"directly within loss_parallel() context. Use F.cross_entropy(logits, targets) instead. "
660 f"For example, replace:\n"
661 f" with loss_parallel():\n"
662 f" log_probs = F.log_softmax(logits, dim=-1)\n"
663 f" loss = F.nll_loss(log_probs, targets)\n"
664 f"with:\n"
665 f" with loss_parallel():\n"
666 f" loss = F.cross_entropy(logits, targets)"
667 )
669 def _dispatch_loss_parallel(self, op_call: callable, args: tuple, kwargs: dict):
670 """Dispatch cross_entropy through the loss_parallel distributed kernel.
672 Args:
673 op_call: The raw operator callable.
674 args: Positional arguments for op_call.
675 kwargs: Keyword arguments for op_call.
677 Returns:
678 Result of the distributed cross_entropy computation.
679 """
680 if platform.platform_type == PlatformType.PYTORCH:
681 # pylint: disable=C0415
682 from hyper_parallel.platform.torch.loss_parallel_ops import distributed_cross_entropy_from_op_call
683 elif platform.platform_type == PlatformType.MINDSPORE:
684 # pylint: disable=C0415
685 from hyper_parallel.platform.mindspore.loss_parallel_ops import distributed_cross_entropy_from_op_call
686 else:
687 raise RuntimeError(f"Unsupported platform for loss_parallel: {platform.platform_type}")
688 return distributed_cross_entropy_from_op_call(op_call, args, kwargs)
690 def _check_ce_op_without_loss_parallel_context(self, op_name: str, args: tuple):
691 """Check if CE op is called with Shard(-1) DTensor outside loss_parallel context.
693 Args:
694 op_name: Canonical operator name.
695 args: Positional arguments for op_call.
697 Raises:
698 ValueError: If CE op is called with Shard(-1) logits outside loss_parallel context.
699 """
700 if is_loss_parallel_active() or not is_loss_parallel_op(op_name):
701 return
703 if len(args) == 0 or not isinstance(args[0], DTensor):
704 return
706 logits = args[0]
707 if _is_shard_on_last_dim(logits):
708 raise ValueError(
709 f"Operator '{op_name}' requires loss_parallel context when input logits are "
710 f"sharded on the vocabulary dimension (Shard(-1)). Please wrap your forward "
711 f"and backward pass with loss_parallel():\n"
712 f" with loss_parallel():\n"
713 f" loss = F.cross_entropy(logits, targets)\n"
714 f" loss.backward()\n"
715 f"If you intentionally want to gather all shards to compute cross_entropy "
716 f"(not recommended for large vocabulary), use logits.full_tensor() explicitly."
717 )
719 @staticmethod
720 def _normalize_aclop_args(op_name: str, unpack_ops: list, args: tuple) -> tuple:
721 """
722 Normalize aclop-packed arguments for MindSpore backend operators.
724 NOTE: This handles MindSpore aclop operators whose kernel signature packs
725 arguments as ``(prim, op_name_str, (real_arg0, real_arg1, ...))``. The
726 ``prim`` and ``op_name_str`` are preserved as ``packed_call`` for the
727 final kernel invocation, while the real tensor arguments are extracted
728 for layout inference and preprocessing.
730 **aclop is planned for deprecation.** Once aclop is fully removed, this
731 normalization and the associated ``unpack_ops`` list can be deleted.
733 Args:
734 op_name (str): Canonical operator name.
735 unpack_ops (list): List of op names that may use aclop packed format.
736 args (tuple): Raw positional arguments from the op call.
738 Returns:
739 tuple: ``(packed_call, normalized_args)``
740 - **packed_call**: ``(prim, op_name_str)`` tuple for kernel
741 invocation, or ``None`` if no unpacking was performed.
742 - **normalized_args**: The real tensor arguments (unpacked if
743 the packed format was detected, otherwise the original args).
744 """
745 if OpDispatcher._is_aclop_packed(op_name, unpack_ops, args):
746 return (args[0], args[1]), tuple(args[2])
747 return None, args
749 @staticmethod
750 def _is_aclop_packed(op_name: str, unpack_ops: list, args: tuple) -> bool:
751 """Check if arguments use aclop packed format."""
752 return (
753 op_name in unpack_ops
754 and len(args) == 3
755 and isinstance(args[1], str)
756 and isinstance(args[2], (tuple, list))
757 )
759 @staticmethod
760 def _call_op_impl(op_impl: callable, packed_call, args, kwargs: dict):
761 """Invoke *op_impl* with optional aclop packed-call wrapping.
763 When *packed_call* is not ``None`` the MindSpore aclop kernel expects
764 ``(prim, op_name, (arg0, arg1, ...))``. Otherwise *args* are spread
765 as positional arguments in the usual way.
767 Args:
768 op_impl: The op implementation callable.
769 packed_call: ``(prim, op_name)`` tuple or ``None``.
770 args: Local tensor arguments (list or tuple).
771 kwargs: Keyword arguments dict.
773 Returns:
774 Result of the *op_impl* invocation.
775 """
776 if packed_call is not None:
777 return op_impl(packed_call[0], packed_call[1], tuple(args), **kwargs)
778 return op_impl(*args, **kwargs)
780 def _handle_unregistered_op(
781 self, op_name: str, op_call: callable, args: tuple, kwargs: dict
782 ):
783 """Handle ops that have no registered layout-inference entry.
785 This is a fallback path for ops that are not registered in
786 ``layout_infer_ops``. When arguments contain DTensors it either raises
787 (with a hint to register a distributed op) or, for loss-parallel ops,
788 gathers tensors to full and dispatches through the raw callable.
790 Args:
791 op_name: Canonical operator name.
792 op_call: The raw operator callable.
793 args: Positional arguments for op_call.
794 kwargs: Keyword arguments for op_call.
796 Returns:
797 Raw dispatch result (plain Tensor, not wrapped as DTensor).
799 Raises:
800 RuntimeError: If op_name is not registered for layout inference.
801 """
802 has_dtensor = any(isinstance(arg, DTensor) for arg in args)
803 has_dtensor = has_dtensor or any(isinstance(v, DTensor) for v in kwargs.values())
804 if has_dtensor:
805 self._check_ce_op_without_loss_parallel_context(op_name, args)
807 if not is_loss_parallel_op(op_name):
808 raise RuntimeError(
809 f"Operator {op_name} does not contain parallel layout infer func. "
810 f"DTensor dispatch requires explicit layout inference registration. "
811 f"Please register a distributed operator for '{op_name}' or use local tensors."
812 )
814 gathered_args, gathered_kwargs = self._gather_dtensors_to_full(args, kwargs)
816 # Special handling for cross_entropy with 3D logits (only when NOT in loss_parallel context)
817 # PyTorch expects: logits [N, C], targets [N]
818 # But LLM forward returns: logits [batch, seq, vocab], targets [batch, seq]
819 # Note: nll_loss input is log_probs, typically already 2D, so we only reshape for cross_entropy
820 if op_name == "cross_entropy" and len(gathered_args) >= 2:
821 logits = gathered_args[0]
822 targets = gathered_args[1]
823 if isinstance(logits, Tensor) and isinstance(targets, Tensor):
824 if logits.ndim > 2 and targets.ndim > 1 and targets.ndim == logits.ndim - 1:
825 vocab_size = logits.shape[-1]
826 gathered_args[0] = logits.reshape(-1, vocab_size)
827 gathered_args[1] = targets.reshape(-1)
829 return op_call(*gathered_args, **gathered_kwargs)
830 raise RuntimeError(f"Operator {op_name} does not contain parallel layout infer func.")
832 def _dispatch_layout_infer(
833 self, op_name: str, op_call: callable, args: tuple, kwargs: dict
834 ):
835 """Standard dispatch through layout-inference: preprocess → infer → execute → wrap.
837 Args:
838 op_name: Canonical operator name (already resolved by the caller).
839 op_call: The raw operator callable.
840 args: Positional arguments for op_call.
841 kwargs: Keyword arguments for op_call.
843 Returns:
844 DTensor: Dispatched result wrapped as DTensor.
846 Raises:
847 RuntimeError: If op_name is not registered, or preprocess returns None.
848 """
849 if op_name not in self.layout_infer_ops:
850 return self._handle_unregistered_op(op_name, op_call, args, kwargs)
852 cache_manager = LayoutCacheManager.get_instance()
853 distribute_op = cache_manager.distributed_op(op_name)
855 # Normalize aclop-packed args before any per-op processing. Only the handful
856 # of (deprecation-bound) unpack_ops ever use the packed format, so gate the
857 # whole normalization behind an O(1) membership test instead of paying two
858 # function frames (_normalize_aclop_args + _is_aclop_packed) on every op.
859 if op_name in getattr(self, 'unpack_ops', ()):
860 packed_call, args = self._normalize_aclop_args(op_name, self.unpack_ops, args)
861 else:
862 packed_call = None
864 result = distribute_op.preprocess(args, kwargs)
865 if result is None:
866 raise RuntimeError(
867 f"Operator '{op_name}' has not been migrated to the three-phase dispatch flow. "
868 f"Please implement preprocess() to return (local_args, local_kwargs, cache_values)."
869 )
870 local_args, local_kwargs, cache_values = result
871 cache_key = LayoutCacheKey.from_cache_values(cache_values)
873 infer_result, op_impl = OpDispatcher._lookup_or_infer_layout(
874 op_call, op_name, cache_key, cache_values, distribute_op, cache_manager
875 )
877 op_impl = op_call if op_impl is None else op_impl
878 py_output = OpDispatcher._call_op_impl(op_impl, packed_call, local_args, local_kwargs)
879 output = distribute_op.wrap_output(py_output, infer_result[0])
880 return OpDispatcher._restore_inplace_dtensor_result(op_name, args, output)
882 @staticmethod
883 def _restore_inplace_dtensor_result(op_name: str, args: tuple, output: Any) -> Any:
884 """Return the original DTensor wrapper after a local in-place operation."""
885 if op_name in {"add_", "sub_"} and args and isinstance(args[0], DTensor):
886 return args[0]
887 return output
889 @staticmethod
890 def _lookup_or_infer_layout(func, func_name, cache_key, cache_values, distribute_op, cache_manager):
891 """Look up cached layout or compute via distributed op.
893 Returns:
894 (infer_result, op_impl)
895 """
896 layout_cache = cache_manager.get_layout_cache()
897 if func_name not in layout_cache:
898 layout_cache[func_name] = {}
899 op_layout_cache = layout_cache[func_name]
900 if cache_key in op_layout_cache:
901 return op_layout_cache[cache_key]
902 infer_result = distribute_op.infer_layout(cache_values)
903 op_impl = distribute_op.get_expand_impl(func, infer_result, cache_values)
904 op_layout_cache[cache_key] = (infer_result, op_impl)
905 return infer_result, op_impl
907 def dispatch(self, op_call: callable, args: tuple, kwargs: dict) -> object:
908 """Route an op call through the appropriate DTensor dispatch path.
910 Args:
911 op_call: The raw operator callable.
912 args: Positional arguments for op_call.
913 kwargs: Keyword arguments for op_call.
915 Returns:
916 Result of the dispatched op call.
917 """
918 op_name = platform.get_op_name(op_call)
919 if logger.isEnabledFor(logging.DEBUG):
920 log_dispatch_enter(op_name, args, kwargs)
922 observer = _debug_mode_observer.get()
923 if observer is not None:
924 observer.on_op_dispatch_enter(op_name, op_call, args, kwargs)
926 result = None
927 try:
928 if self._should_bypass_dispatch(op_name):
929 self._validate_inplace_partial_inputs(op_name, args, kwargs)
930 result = op_call(*self._unwrap_args(args), **self._unwrap_kwargs(kwargs))
931 if op_name in self._INPLACE_BYPASS_OPS and args and isinstance(args[0], DTensor):
932 result = args[0]
933 return result
935 if op_name in self._random_ops or op_name in self._random_ms_ops:
936 result = self._dispatch_random_op(op_name, op_call, args, kwargs)
937 return result
939 self._check_decomposed_ce_op_in_loss_parallel(op_name, args, kwargs)
941 if self._should_dispatch_loss_parallel(op_name):
942 result = self._dispatch_loss_parallel(op_call, args, kwargs)
943 return result
945 if op_name not in self.layout_infer_ops and get_distributed_op(op_name) is not None:
946 self.layout_infer_ops[op_name] = {}
948 result = self._dispatch_layout_infer(op_name, op_call, args, kwargs)
949 return result
950 finally:
951 if logger.isEnabledFor(logging.DEBUG):
952 log_dispatch_exit(op_name, result)
954 if observer is not None:
955 observer.on_op_dispatch_exit(op_name, result)
957_OP_DISPATCHER = OpDispatcher()