Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 0 additions & 316 deletions doc/extending/creating_a_c_op.rst
Original file line number Diff line number Diff line change
Expand Up @@ -713,322 +713,6 @@ C code.
return c_code % locals()


Alternate way of defining C :class:`Op`\s
=========================================

The two previous examples have covered the standard way of implementing C :class:`Op`\s
in PyTensor by inheriting from the class :class:`Op`. This process is mostly
simple but it still involves defining many methods as well as mixing, in the
same file, both Python and C code which tends to make the result less
readable.

To help with this, PyTensor defines a class, `ExternalCOp`, from which new C :class:`Op`\s
can inherit. The class `ExternalCOp` aims to simplify the process of implementing
C :class:`Op`\s by doing the following :

* It allows you to define the C implementation of your :class:`Op` in a distinct
C code file. This makes it easier to keep your Python and C code
readable and well indented.

* It can automatically handle all the methods that return C code,
in addition to :meth:`Op.c_code_cache_version` based on the
provided external C implementation.

To illustrate how much simpler the class `ExternalCOp` makes the process of defining
a new :class:`Op` with a C implementation, let's revisit the second example of this
tutorial, the `VectorTimesVector`\ :class:`Op`. In that example, we implemented an :class:`Op`
to perform the task of element-wise vector-vector multiplication. The two
following blocks of code illustrate what the :class:`Op` would look like if it was
implemented using the `ExternalCOp` class.

The new :class:`Op` is defined inside a Python file with the following code :

.. testcode::

import pytensor
from pytensor.link.c.op import ExternalCOp

class VectorTimesVector(ExternalCOp):
__props__ = ()

func_file = "./vectorTimesVector.c"
func_name = "APPLY_SPECIFIC(vector_times_vector)"

def __init__(self):
super().__init__(self.func_file, self.func_name)

def make_node(self, x, y):
# Validate the inputs' type
if x.type.ndim != 1:
raise TypeError('x must be a 1-d vector')
if y.type.ndim != 1:
raise TypeError('y must be a 1-d vector')

# Create an output variable of the same type as x
output_var = pytensor.tensor.type.TensorType(
dtype=pytensor.scalar.upcast(x.dtype, y.dtype),
shape=(None,))()

return Apply(self, [x, y], [output_var])

And the following is the C implementation of the :class:`Op`, defined in an external
C file named ``vectorTimesVector.c``:

.. code-block:: c

#section support_code

// Support code function
bool vector_same_shape(PyArrayObject* arr1, PyArrayObject* arr2)
{
return (PyArray_DIMS(arr1)[0] == PyArray_DIMS(arr2)[0]);
}


#section support_code_apply

// Apply-specific support function
void APPLY_SPECIFIC(vector_elemwise_mult)(
DTYPE_INPUT_0* x_ptr, int x_str,
DTYPE_INPUT_1* y_ptr, int y_str,
DTYPE_OUTPUT_0* z_ptr, int z_str, int nbElements)
{
for (int i=0; i < nbElements; i++){
z_ptr[i * z_str] = x_ptr[i * x_str] * y_ptr[i * y_str];
}
}

// Apply-specific main function
int APPLY_SPECIFIC(vector_times_vector)(PyArrayObject* input0,
PyArrayObject* input1,
PyArrayObject** output0)
{
// Validate that the inputs have the same shape
if ( !vector_same_shape(input0, input1))
{
PyErr_Format(PyExc_ValueError, "Shape mismatch : "
"input0.shape[0] and input1.shape[0] should "
"match but x.shape[0] == %i and "
"y.shape[0] == %i",
PyArray_DIMS(input0)[0], PyArray_DIMS(input1)[0]);
return 1;
}

// Validate that the output storage exists and has the same
// dimension as x.
if (NULL == *output0 || !(vector_same_shape(input0, *output0)))
{
/* Reference received to invalid output variable.
Decrease received reference's ref count and allocate new
output variable */
Py_XDECREF(*output0);
*output0 = (PyArrayObject*)PyArray_EMPTY(1,
PyArray_DIMS(input0),
TYPENUM_OUTPUT_0,
0);

if (!*output0) {
PyErr_Format(PyExc_ValueError,
"Could not allocate output storage");
return 1;
}
}

// Perform the actual vector-vector multiplication
APPLY_SPECIFIC(vector_elemwise_mult)(
(DTYPE_INPUT_0*)PyArray_DATA(input0),
PyArray_STRIDES(input0)[0] / ITEMSIZE_INPUT_0,
(DTYPE_INPUT_1*)PyArray_DATA(input1),
PyArray_STRIDES(input1)[0] / ITEMSIZE_INPUT_1,
(DTYPE_OUTPUT_0*)PyArray_DATA(*output0),
PyArray_STRIDES(*output0)[0] / ITEMSIZE_OUTPUT_0,
PyArray_DIMS(input0)[0]);

return 0;
}

As you can see from this example, the Python and C implementations are nicely
decoupled which makes them much more readable than when they were intertwined
in the same file and the C code contained string formatting markers.

Now that we have motivated the `ExternalCOp` class, we can have a more precise look at
what it does for us. For this, we go through the various elements that make up
this new version of the `VectorTimesVector`\ `Op` :

* Parent class : instead of inheriting from the class :class:`Op`,
VectorTimesVector inherits from the class `ExternalCOp`.

* Constructor : in our new `COp`, the :meth:`COp.__init__` method has an
important use; to inform the constructor of the `ExternalCOp` class
of the location, on the filesystem of the C implementation of
this `COp`. To do this, it gives a list of file paths containing
the C code for this `COp`. To auto-generate the c_code method
with a function call you can specify the function name as the
second parameter. The paths should be given as a relative
path from the folder where the descendant of the `ExternalCOp` class
is defined.

* :meth:`ExternalCOp.make_node` : this method is absolutely
identical to the one in our old example. Using the `ExternalCOp`
class doesn't change anything here.

* External C code : the external C code implements the various
functions associated with the `COp`. Writing this C code
involves a few subtleties which deserve their own respective
sections.

Main function
-------------

If you pass a function name to :meth:`ExternalCOp.__init___`, it must respect
the following constraints:

* It must return an int. The value of that int indicates whether
the `Op` could perform its task or not. A value of 0 indicates
success while any non-zero value will interrupt the execution
of the PyTensor function. When returning non-zero the function
must set a python exception indicating the details of the
problem.

* It must receive one argument for each input to the `Op` followed
by one pointer to an argument for each output of the `Op`. The
types for the argument is dependent on the Types (that is
pytensor Types) of your inputs and outputs.

* You can specify the number of inputs and outputs for your `Op`
by setting the ``_cop_num_inputs`` and ``_cop_num_outputs``
attributes on your `COp`. The main function will always be
called with that number of arguments, using NULL to fill in
for missing values at the end. This can be used if your `COp`
has a variable number of inputs or outputs, but with a fixed
maximum.

For example, the main C function of an `COp` that takes two TensorTypes
(which has ``PyArrayObject *`` as its C type) as inputs and returns
both their sum and the difference between them would have four
parameters (two for the `COp`'s inputs and two for its outputs) and it's
signature would look something like this :

.. code-block:: c

int sumAndDiffOfScalars(PyArrayObject* in0, PyArrayObject* in1,
PyArrayObject** out0, PyArrayObject** out1)

Macros
------

For certain section tags, your C code can benefit from a number of
pre-defined macros. These section tags have no macros: ``init_code``,
``support_code``. All other tags will have the support macros
discussed below.

* ``APPLY_SPECIFIC(str)`` which will automatically append a name
unique to the :ref:`apply` node that applies the `Op` at the end
of the provided ``str``. The use of this macro is discussed
further below.

For every input which has a :attr:`dtype` attribute (this means
Tensors), the following macros will be
defined unless your `Op` class has an :attr:`Op.check_input` attribute
defined to False. In these descriptions 'i' refers to the position
(indexed from 0) in the input array.

* ``DTYPE_INPUT_{i}`` : NumPy dtype of the data in the array.
This is the variable type corresponding to the NumPy dtype, not the
string representation of the NumPy dtype. For instance, if the `Op`'s
first input is a float32 :class:`ndarray`, then the macro ``DTYPE_INPUT_0``
corresponds to ``npy_float32`` and can directly be used to declare a
new variable of the same dtype as the data in the array :

.. code-block:: c

DTYPE_INPUT_0 myVar = someValue;

* ``TYPENUM_INPUT_{i}`` : Typenum of the data in the array

* ``ITEMSIZE_INPUT_{i}`` : Size, in bytes, of the elements in
the array.

In the same way, the macros ``DTYPE_OUTPUT_{i}``,
``ITEMSIZE_OUTPUT_{i}`` and ``TYPENUM_OUTPUT_{i}`` are defined for
every output 'i' of the `Op`.

In addition to these macros, the ``init_code_struct``, ``code``, and
``code_cleanup`` section tags also have the following macros:

* ``FAIL`` : Code to insert at error points. A python exception
should be set prior to this code. An invocation look like this:

.. code-block:: c

if (error) {
// Set python exception
FAIL
}

You can add a semicolon after the macro if it makes your editor
happy.

* ``PARAMS`` : Name of the params variable for this node. (only
for `Op`\s which have params, which is discussed elsewhere)

Finally the tag ``code`` and ``code_cleanup`` have macros to
pass the inputs and output names. These are name ``INPUT_{i}`` and
``OUTPUT_{i}`` where `i` is the 0-based index position in the input
and output arrays respectively.

Support code
------------

Certain section are limited in what you can place in them due to
semantic and syntactic restrictions of the C++ language. Most of
these restrictions apply to the tags that end in ``_struct``.

When we defined the ``VectorTimesVector`` `Op` without using the ``ExternalCOp``
class, we had to make a distinction between two types of support_code
: the support code that was apply-specific and the support code that
wasn't. The apply-specific code was defined in the
``c_support_code_apply`` method and the elements defined in that
code (global variables and functions) had to include the name of the
Apply node in their own names to avoid conflicts between the different
versions of the apply-specific code. The code that wasn't
apply-specific was simply defined in the ``c_support_code`` method.

To make indentifiers that include the :ref:`apply` node name use the
``APPLY_SPECIFIC(str)`` macro. In the above example, this macro is
used when defining the functions ``vector_elemwise_mult`` and
``vector_times_vector`` as well as when calling function
``vector_elemwise_mult`` from inside ``vector_times_vector``.

When using the ``ExternalCOp`` class, we still have to make the distinction
between C code for each of the methods of a C class. These sections of
code are separated by ``#section <tag>`` markers. The tag determines
the name of the method this C code applies to with the rule that
``<tag>`` applies to `c_<tag>`. Unknown tags are an error and will be
reported. Duplicate tags will be merged together in the order the
appear in the C files.

The rules for knowing if where a piece of code should be put can be
sometimes tricky. The key thing to remember is that things that can
be shared between instances of the `Op` should be apply-agnostic and go
into a section which does not end in ``_apply`` or ``_struct``. The
distinction of ``_apply`` and ``_struct`` mostly hinghes on how you
want to manage the lifetime of the object. Note that to use an
apply-specific object, you have to be in a apply-specific section, so
some portions of the code that might seem apply-agnostic may still be
apply-specific because of the data they use (this does not include
arguments).

In the above example, the ``function vector_same_shape`` is
apply-agnostic because it uses none of the macros defined by the class
``ExternalCOp`` and it doesn't rely on any apply-specific code. The function
``vector_elemwise_mult`` is apply-specific because it uses the
macros defined by ``ExternalCOp``. Finally, the function
``vector_times_vector`` is apply-specific because it uses those same
macros and also because it calls ``vector_elemwise_mult`` which is
an apply-specific function.


Using GDB to debug :class:`COp`'s C code
========================================

Expand Down
24 changes: 11 additions & 13 deletions pytensor/link/c/op.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ def get_io_macros(inputs: list[str], outputs: list[str]) -> tuple[str, str]:
class ExternalCOp(COp):
"""Class for an `Op` with an external C implementation.

.. deprecated::
Inherit from `COp` and return the C code directly from `c_code` and
related methods instead.

One can inherit from this class, provide its constructor with a path to
an external C source file and the name of a function within it, and define
an `Op` for said function.
Expand Down Expand Up @@ -306,6 +310,13 @@ def __init__(
files overriding sections in previous files.

"""
warnings.warn(
"ExternalCOp is deprecated and will be removed in a future release. "
"Inherit from COp and return the C code directly from `c_code` and "
"related methods instead.",
FutureWarning,
stacklevel=2,
)
if not isinstance(func_files, list):
self.func_files = [Path(func_files)]
else:
Expand Down Expand Up @@ -633,16 +644,3 @@ class _NoPythonCOp(COp):

def perform(self, node, inputs, output_storage):
raise NotImplementedError("No Python implementation is provided by this COp.")


class _NoPythonExternalCOp(ExternalCOp):
"""A class used to indicate that an `ExternalCOp` does not provide a Python implementation.

XXX: Do not use this class; it's only for tracking bad implementations internally.

"""

def perform(self, node, inputs, output_storage):
raise NotImplementedError(
"No Python implementation is provided by this ExternalCOp."
)
Loading
Loading