diff --git a/docs/legate/core/source/api/classes.rst b/docs/legate/core/source/api/classes.rst index f46811a82..e69077c8a 100644 --- a/docs/legate/core/source/api/classes.rst +++ b/docs/legate/core/source/api/classes.rst @@ -8,6 +8,7 @@ Classes runtime operation store + machine allocation shape .. partition diff --git a/docs/legate/core/source/api/machine.rst b/docs/legate/core/source/api/machine.rst new file mode 100644 index 000000000..46a8e9a6e --- /dev/null +++ b/docs/legate/core/source/api/machine.rst @@ -0,0 +1,74 @@ +.. _label_machine: + +.. currentmodule:: legate.core + +Machine and Resource Scoping +============================ + +By default, each Legate operation is allowed to use the entire machine for +parallelization, but oftentimes client programs want control on the machine +resource assigned to each section of the program. Legate provides a +programmatic way to control resource assignment, called a *resource scoping*. + +To scope the resource, a client takes two steps. First, the client queries the +machine resource available in the given scope and shrinks it to a subset to +assign to a scope. Then, the client assigns that subset of the machine to a scope +with a usual ``with`` statement. All Legate operations issued within that +``with`` block are now subject to the resource scoping. The steps look like the +following pseudocode: + +:: + + # Retrieves the machine of the current scope + machine = legate.core.get_machine() + # Extracts a subset to assign to a nested scope + subset = extract_subset(machine) + # Installs the new machine to a scope + with subset: + ... + +The machine available to a nested scope is always a subset of that for the +outer scope. If the machine given to a scope has some resources that are not +part of the machine for the outer scope, they will be removed during the +resource scoping. The machine used in a scoping must not be empty; otherwise, +an ``EmptyMachineError`` will be raised. + +In cases where a machine has more than one kind of processor, the +parallelization heuristic has the following precedence on preference between +different types: GPU > OpenMP > CPU. + +Metadata about the machine is stored in a ``Machine`` object and the +``Machine`` class provides APIs for querying and subdivision of resources. + +Machine +------- + +.. autosummary:: + :toctree: generated/ + + Machine.preferred_kind + Machine.kinds + Machine.get_processor_range + Machine.get_node_range + Machine.only + Machine.count + Machine.empty + Machine.__and__ + Machine.__len__ + Machine.__getitem__ + + +ProcessorRange +-------------- + +A ``ProcessorRange`` is a half-open interval of global processor IDs. + +.. autosummary:: + :toctree: generated/ + + ProcessorRange.empty + ProcessorRange.get_node_range + ProcessorRange.slice + ProcessorRange.__and__ + ProcessorRange.__len__ + ProcessorRange.__getitem__ diff --git a/docs/legate/core/source/api/routines.rst b/docs/legate/core/source/api/routines.rst index 78dd1609a..c8ac68f98 100644 --- a/docs/legate/core/source/api/routines.rst +++ b/docs/legate/core/source/api/routines.rst @@ -8,4 +8,5 @@ Routines :toctree: generated/ get_legate_runtime + get_machine track_provenance diff --git a/legate/core/__init__.py b/legate/core/__init__.py index 143279ab6..ad2a5a305 100644 --- a/legate/core/__init__.py +++ b/legate/core/__init__.py @@ -90,7 +90,13 @@ Library, Table, ) -from .machine import EmptyMachineError, Machine, ProcessorKind, ProcessorSlice +from .machine import ( + EmptyMachineError, + Machine, + ProcessorKind, + ProcessorRange, + ProcessorSlice, +) from .runtime import ( Annotation, get_legate_runtime, diff --git a/legate/core/machine.py b/legate/core/machine.py index ac64b0dc1..f9fa19b04 100644 --- a/legate/core/machine.py +++ b/legate/core/machine.py @@ -75,12 +75,41 @@ def create_empty_range(kind: ProcessorKind) -> ProcessorRange: @property def empty(self) -> bool: + """ + Indicates if the processor range is empty + + Returns + ------- + bool + ``True`` if the machine is empty, ``False`` otherwise. + """ return self.high <= self.low def __len__(self) -> int: + """ + Returns the number of processors in the range + + Returns + ------- + int + Processor count + """ return self.high - self.low def __and__(self, other: ProcessorRange) -> ProcessorRange: + """ + Computes an intersection with a given processor range + + Parameters + ---------- + other : ProcessorRange + A processor range to intersect with + + Returns + ------- + ProcessorRange + Intersection result + """ if self.kind != other.kind: raise ValueError( "Intersection between different processor kinds: " @@ -95,6 +124,19 @@ def __and__(self, other: ProcessorRange) -> ProcessorRange: ) def slice(self, sl: slice) -> ProcessorRange: + """ + Slices the processor range by a given ``slice`` + + Parameters + ---------- + sl : slice + A ``slice`` to slice this processor range by + + Returns + ------- + ProcessorRange + Processor range after slicing + """ if sl.step is not None and sl.step != 1: raise ValueError("The slicing step must be 1 or None") sz = len(self) @@ -119,6 +161,21 @@ def slice(self, sl: slice) -> ProcessorRange: ) def __getitem__(self, key: PROC_RANGE_KEY) -> ProcessorRange: + """ + Slices the processor range with a given slicer + + Parameters + ---------- + key : slice, int + Key to slice the processor range by. If the ``key`` is an ``int``, + it is treated like a singleton slice (i.e., ```slice(key, key + + 1)```) + + Returns + ------- + ProcessorRange + Processor range after slicing + """ if isinstance(key, int): return self.slice(slice(key, key + 1)) elif isinstance(key, slice): @@ -127,6 +184,14 @@ def __getitem__(self, key: PROC_RANGE_KEY) -> ProcessorRange: raise KeyError(f"Invalid slicing key: {key}") def get_node_range(self) -> tuple[int, int]: + """ + Returns the range of node IDs for this processor range + + Returns + ------- + tuple[int, int] + Half-open interval of node IDs + """ if self.empty: raise ValueError( "Illegal to get a node range of an empty processor range" @@ -158,6 +223,14 @@ def __init__(self, proc_ranges: Sequence[ProcessorRange]) -> None: ) def __len__(self) -> int: + """ + Returns the number of processors of the preferred kind + + Returns + ------- + int + Processor count + """ return len(self._get_range(self._preferred_kind)) def __eq__(self, other: object) -> bool: @@ -170,15 +243,44 @@ def __eq__(self, other: object) -> bool: @property def preferred_kind(self) -> ProcessorKind: + """ + Returns the preferred kind of processors + + Returns + ------- + ProcessorKind + Processor kind + """ return self._preferred_kind @property def kinds(self) -> tuple[ProcessorKind, ...]: + """ + Returns the kinds of processors available in this machine + + Returns + ------- + tuple[ProcessorKind, ...] + Processor kinds + """ return self._non_empty_kinds def get_processor_range( self, kind: Optional[ProcessorKind] = None ) -> ProcessorRange: + """ + Returns the processor range of a given kind. + + Parameters + ---------- + kind : ProcessorKind, optional + Kind of processor to query. If None, the preferred kind is used. + + Returns + ------- + ProcessorRange + Processor range of the chosen kind + """ if kind is None: kind = self._preferred_kind return self._get_range(kind) @@ -186,6 +288,21 @@ def get_processor_range( def get_node_range( self, kind: Optional[ProcessorKind] = None ) -> tuple[int, int]: + """ + Returns the node range for processor of a given kind. + + If no kind is given, the preferred kind is used. + + Parameters + ---------- + kind : ProcessorKind, optional + Processor kind to query + + Returns + ------- + tuple[int, int] + Node range for the chosen processor kind + """ return self.get_processor_range(kind).get_node_range() def _get_range(self, kind: ProcessorKind) -> ProcessorRange: @@ -194,9 +311,35 @@ def _get_range(self, kind: ProcessorKind) -> ProcessorRange: return self._proc_ranges[kind] def only(self, *kinds: ProcessorKind) -> Machine: + """ + Returns a machine that contains only the processors of given kinds + + Parameters + ---------- + kinds : ProcessorKinds + Kinds of processors to leave in the returned machine + + Returns + ------- + Machine + A new machine only with the chosen processors + """ return Machine([self._get_range(kind) for kind in kinds]) def count(self, kind: ProcessorKind) -> int: + """ + Returns the number of processors of a given kind + + Parameters + ---------- + kind : ProcessorKind + Kind of processor to query. + + Returns + ------- + int + Processor count + """ return len(self._get_range(kind)) def filter_ranges( @@ -213,6 +356,31 @@ def filter_ranges( return self.only(*valid_kinds) def __getitem__(self, key: MACHINE_KEY) -> Machine: + """ + Slices the machine with a given slicer + + Parameters + ---------- + key : ProcessorKind, slice, int, tuple[ProcessorKind, slice] + Key to slice the machine by + + If the ``key`` is a ``ProcessorKind``, a machine with only the + processors of the chosen kind is returned. + + If the ``key`` is a ``slice``, the returned machine only has a + processor range for the preferred kind, which is sliced by the + ``key``. An integer ``key`` is treated like a singleton slice + (i.e., ``slice(key, key + 1)``). + + If the `key` is a pair of a processor kind and a slice, the + returned machine only has a processor range of the chosen kind, + which is sliced by the ``key``. + + Returns + ------- + Machine + A new machine after slicing + """ if isinstance(key, ProcessorKind): return self.only(key) elif isinstance(key, (slice, int)): @@ -260,6 +428,19 @@ def create_range(kind: ProcessorKind) -> ProcessorRange: return result def __and__(self, other: Machine) -> Machine: + """ + Computes an intersection with a given machine + + Parameters + ---------- + other : Machine + A machine to intersect with + + Returns + ------- + Machine + Intersection result + """ if self is other: return self result = [ @@ -271,6 +452,17 @@ def __and__(self, other: Machine) -> Machine: @property def empty(self) -> bool: + """ + Indicates if the machine is empty + + An empty machine is a machine with all its processor ranges being + empty. + + Returns + ------- + bool + ``True`` if the machine is empty, ``False`` otherwise. + """ return all(r.empty for r in self._proc_ranges.values()) def __repr__(self) -> str: