Rate this Page

Source code for monarch.common._coalescing

# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.

# pyre-unsafe

import functools
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import dataclass
from typing import (
    Any,
    Callable,
    Dict,
    Generator,
    List,
    NamedTuple,
    Optional,
    Sequence,
    Tuple,
    TYPE_CHECKING,
)

import torch
from monarch.common import messages
from monarch.common.fake import fake_call
from monarch.common.function_caching import (
    hashable_tensor_flatten,
    TensorGroup,
    TensorGroupPattern,
)
from monarch.common.tensor import InputChecker, Tensor
from monarch.common.tree import flatten

if TYPE_CHECKING:
    from monarch.common.client import Recorder
    from monarch.common.recording import Recording

    from .client import Client

_coalescing = None


class CoalescingState:
    def __init__(self, recording=False):
        self.controller: Optional["Client"] = None
        self.recorder: Optional["Recorder"] = None
        self.recording = recording

    def set_controller(self, controller: "Client"):
        if self.controller is None:
            self.controller = controller
            controller.flush_deletes(False)
        if self.controller is not controller:
            raise ValueError(
                "using multiple controllers in the same coalescing block is not supported"
            )

    @contextmanager
    def activate(self) -> Generator[None, Any, Any]:
        global _coalescing
        assert _coalescing is None
        finished = False
        try:
            _coalescing = self
            yield
            finished = True
        finally:
            ctrl = self.controller
            if ctrl is not None:
                if finished:
                    ctrl.flush_deletes()
                self.recorder = ctrl.reset_recorder()
                if not finished:
                    self.recorder.abandon()
            _coalescing = None


[docs]@contextmanager def coalescing() -> Generator[None, Any, Any]: global _coalescing if _coalescing is not None: yield return state = CoalescingState() with state.activate(): yield if state.recorder is not None: assert state.controller is not None state.recorder.run_once(state.controller)
def _record_and_define( fn: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any] ) -> "CacheEntry": input_tensors, unflatten_input = flatten( (args, kwargs), lambda x: isinstance(x, Tensor) ) with InputChecker.from_flat_args( "compile", input_tensors, unflatten_input ) as checker: checker.check_no_requires_grad() for a in input_tensors: assert a._seq is not None state = CoalescingState(recording=True) with state.activate(): formal_tensors = [] for i, input in enumerate(input_tensors): state.set_controller(input.mesh.client) t = Tensor(input._fake, input.mesh, input.stream) input.mesh._send( messages.RecordingFormal(t, i, t.stream._to_ref(input.mesh.client)) ) formal_tensors.append(t) formal_args, formal_kwargs = unflatten_input(formal_tensors) recorded_result = fn(*formal_args, **formal_kwargs) output_tensors, unflatten_result = flatten( recorded_result, lambda x: isinstance(x, Tensor) ) with InputChecker( output_tensors, lambda ts: f"{unflatten_result(ts)} = compiled_function(...)", ) as checker: checker.check_no_requires_grad() for i, output in enumerate(output_tensors): state.set_controller(output.mesh.client) output.mesh._send( messages.RecordingResult( output, i, output.stream._to_ref(output.mesh.client) ) ) recorder = state.recorder if recorder is None: # no input tensors or output tensors, so just cache the result return CacheEntry( TensorGroup([]), TensorGroupPattern(()), lambda args, kwargs: recorded_result, None, ) controller = state.controller assert controller is not None recorder.add((), output_tensors, []) recording = recorder.define_recording( controller, len(output_tensors), len(input_tensors) ) fake_uses = [r._fake for r in recording.uses] captures_group = TensorGroup(fake_uses) inputs_group = TensorGroup([i._fake for i in input_tensors], parent=captures_group) outputs_group = TensorGroup([o._fake for o in output_tensors], parent=inputs_group) outputs_pattern = outputs_group.pattern def run(args, kwargs): actuals, _ = flatten((args, kwargs), lambda x: isinstance(x, Tensor)) for a in actuals: assert a._seq is not None fake_result_tensors = fake_call( outputs_pattern.empty, [fake_uses, [a._fake for a in actuals]] ) # recording.run does permissions checks on all the tensors. # if those checks fail then the tensors here will have been created # but not defined, causes spurious delete messages. # To avoid this, we pass a generator rather than a list # and only create the tensors in run result_tensors_generator = ( Tensor(f, o.mesh, o.stream) for f, o in zip(fake_result_tensors, output_tensors) ) return unflatten_result(recording.run(result_tensors_generator, actuals)) return CacheEntry(captures_group, inputs_group.pattern, run, recording) @dataclass class CacheEntry: captures_group: TensorGroup inputs_pattern: TensorGroupPattern run: Callable[[Tuple[Any, ...], Dict[str, Any]], Any] to_verify: Optional["Recording"] def matches(self, input_tensors: List[torch.Tensor]) -> bool: # if an input aliases a captured tensor, then we have # to check that all future inputs alias the _same exact_ # captured tensor. These are additional checks after # matching on the pattern of aliasing for just the inputs because # we do not what the captures would be without first matching the inputs without the captures. inputs_group = TensorGroup(input_tensors, parent=self.captures_group) return self.inputs_pattern == inputs_group.pattern def compile(fn=None, verify=True): """ Wraps `fn` such that it records and later replays a single message to workers to instruct them to run the entire contents of this function. Since the function invocation is much smaller than the original set of messages and since we do not re-execute the python inside the function after recording, this has substantially lower latency. While eventually `compile` will be backed by `torch.compile`'s dynamo executor, it currently works as a simple tracer with the following rules for when it chooses to trace vs when it will reuse an existing trace. A new trace is created whenever: * The _values_ of a non-tensor argument to fn have not been seen before. * The _metadata_ of a tensor arguments has not been seen before. Metadata includes the sizes, strides, dtype, devices, layout, device meshes, streams, and pattern of aliasing of the arguments with respect to other arguments and any values the trace captures. A new trace will not be created in these following situations that are known to be **unsafe**: * A value that is not an argument to the function but is used by the function (e.g. a global), changes in a way that would affect what messages are being sent. * A tensor that is not an argument to the function changes metadata, or gets reassigned to a new tensor in Python. The trace is allowed to use tensors that are referenced in the body but not listed as arguments, such as globals or closure-captured locals as long as these values are not modified in the the ways that are listed as unsafe above. When switched to a torch.compile backed version, these safety caveats will be improved. Compilation currently does not work if the inputs or outputs to the function have `requires_grad=True`, because we will not generate a correctly backwards pass graph. However, captured tensors are allowed to be requires_grad=True, and gradient calculation (forward+backward) can run entirely within the function. Can be used as a wrapper: wrapped = compile(my_function, verify=False) Or as a decorator: @compile def my_function(...): ... @compile(verify=False) def my_function(...): ... Args: fn (callable): the function to be wrapped. (Default: None, in which case we return a single argument, function that can be used as a decorator) verify (bool): To guard as much as possible against the above unsafe situations, if `verify=True`, the first time we would reuse a trace, we additionally do another recording and check the second recording matches the original recording, and report where they diverge. (Default: True) Returns: If fn=None, it returns a function that can be used as a decorator on a function to be wrapped. Otherwise, it returns the wrapped function itself. """ if fn is None: return lambda fn: compile(fn, verify) # pyrefly: ignore [no-matching-overload] cache: Dict[Any, Recording] = defaultdict(list) @functools.wraps(fn) def wrapper(*args, **kwargs): global _coalescing if _coalescing: return fn(*args, **kwargs) tensors, shape_key = hashable_tensor_flatten(args, kwargs) # pyrefly: ignore [missing-attribute] input_group = TensorGroup([t._fake for t in tensors]) # pyrefly: ignore [missing-attribute] props = tuple((t.mesh, t.stream, t.requires_grad) for t in tensors) key = (shape_key, input_group.pattern, props) # pyrefly: ignore [not-iterable] for entry in cache[key]: if entry.matches(input_group.tensors): if entry.to_verify is not None: entry.to_verify.client.recorder.verify_against(entry.to_verify) _record_and_define(fn, args, kwargs) entry.to_verify = None return entry.run(args, kwargs) entry = _record_and_define(fn, args, kwargs) if not verify: entry.to_verify = None # pyrefly: ignore [missing-attribute] cache[key].append(entry) return entry.run(args, kwargs) return wrapper def is_active(controller: "Client"): if _coalescing is None: return False _coalescing.set_controller(controller) return True def is_recording(controller: "Client"): # pyrefly: ignore [missing-attribute] return is_active(controller) and _coalescing.recording