Skip to content

Commit

Permalink
API reference for resource scoping (#857)
Browse files Browse the repository at this point in the history
* API reference for resource scoping

* Missing file

* Address review comments
  • Loading branch information
magnatelee authored Oct 13, 2023
1 parent 68306e1 commit d550900
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 1 deletion.
1 change: 1 addition & 0 deletions docs/legate/core/source/api/classes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Classes
runtime
operation
store
machine
allocation
shape
.. partition
Expand Down
74 changes: 74 additions & 0 deletions docs/legate/core/source/api/machine.rst
Original file line number Diff line number Diff line change
@@ -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__
1 change: 1 addition & 0 deletions docs/legate/core/source/api/routines.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ Routines
:toctree: generated/

get_legate_runtime
get_machine
track_provenance
8 changes: 7 additions & 1 deletion legate/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
192 changes: 192 additions & 0 deletions legate/core/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: "
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -170,22 +243,66 @@ 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)

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:
Expand All @@ -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(
Expand All @@ -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)):
Expand Down Expand Up @@ -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 = [
Expand All @@ -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:
Expand Down

0 comments on commit d550900

Please sign in to comment.