DALI Expressions and Arithmetic Operators

This example shows you how to use binary arithmetic operators in the DALI Pipeline that allow for element-wise operations on tensors in a pipeline. We will provide information about the available operators and examples of using constant and scalar inputs.

Supported Operators

DALI currently supports the following operators:

  • Unary arithmetic operators: +, -;

  • Binary arithmetic operators: +, -, *, /, and //;

  • Comparison operators: ==, !=, <, <=, >, >=;

  • Bitwise binary operators: &, |, ^.

DALI also supports set of mathematical functions exposed in the nvidia.dali.math module:

  • abs, fabs, floor, ceil, pow, fpow, min, max, clamp;

  • Exponents and logarithms: sqrt, rsqrt, cbrt, exp, log, log2, log10;

  • Trigonometric Functions: sin, cos, tan, asin, acos, atan, atan2;

  • Hyperbolic Functions: sinh, cosh, tanh, asinh, acosh, atanh;

Binary operators can be used as an operation between two tensors, between a tensor and a scalar or a tensor and a constant. By tensor, we consider the output of DALI operators (regular or other arithmetic operators). Unary operators work only with tensor inputs.

In this section we focus on binary arithmetic operators, Tensor, Constant and Scalar operands. The detailed type promotion rules for comparison and bitwise operators are covered in the Supported operations section and other examples.

Prepare the Test Pipeline

  1. Prepare the helper code, so we can easily manipulate the types and values that will appear as tensors in the DALI pipeline.

  2. We will be using numpy as source for the custom provided data, so we need to import several things from DALI needed to create the Pipeline and use the ExternalSource operator.

[1]:
import numpy as np
from nvidia.dali.pipeline import pipeline_def
import nvidia.dali.fn as fn
import nvidia.dali.types as types
from nvidia.dali.types import Constant

Defining the Data

To define the data, because there are binary operators, two inputs are required. We will create a simple helper function that returns two batches of hardcoded data, that are stored as np.int32. In an actual scenario the data that is processed by the DALI arithmetic operators would be tensors produced by another operator that contains some images, video sequences or other data.

You can experiment by changing those values or adjusting the get_data() function to use different input data.

Note: The shapes of both inputs need to either match exactly or be compatible according to the broadcasting rules - the operations are performed element-wise.

[2]:
left_magic_values = [
    [[42, 7, 0], [0, 0, 0]],
    [[5, 10, 15], [10, 100, 1000]]
]

right_magic_values = [
    [[3, 3, 3], [1, 3, 5]],
    [[1, 5, 5], [1, 1, 1]]
]

batch_size = len(left_magic_values)

def convert_batch(batch):
    return [np.int32(tensor) for tensor in batch]

def get_data():
    return (convert_batch(left_magic_values), convert_batch(right_magic_values))

Operating on Tensors

Defining the Pipeline

  1. To define the pipeline, the data will be obtained from the get_data function and made available to the pipeline through ExternalSource.

Note: You do not need to instantiate any additional operators, we can use regular Python arithmetic expressions on the results of other operators.

  1. You can manipulate the source data by adding, multiplying and dividing it.

[3]:
@pipeline_def(batch_size=batch_size, num_threads=4, device_id=0)
def pipeline():
    l, r = fn.external_source(source=get_data, num_outputs=2, dtype=types.INT32)
    sum_result = l + r
    mul_result = l * r
    div_result = l // r
    return l, r, sum_result, mul_result, div_result

Running the Pipeline

  1. Build and run our pipeline

[4]:
pipe = pipeline()
pipe.build()
out = pipe.run()
  1. Display the results:

[5]:
def examine_output(pipe_out):
    l = pipe_out[0].as_array()
    r = pipe_out[1].as_array()
    sum_out = pipe_out[2].as_array()
    mul_out = pipe_out[3].as_array()
    div_out = pipe_out[4].as_array()
    print("{}\n+\n{}\n=\n{}\n\n".format(l, r, sum_out))
    print("{}\n*\n{}\n=\n{}\n\n".format(l, r, mul_out))
    print("{}\n//\n{}\n=\n{}\n\n".format(l, r, div_out))

examine_output(out)
[[[  42    7    0]
  [   0    0    0]]

 [[   5   10   15]
  [  10  100 1000]]]
+
[[[3 3 3]
  [1 3 5]]

 [[1 5 5]
  [1 1 1]]]
=
[[[  45   10    3]
  [   1    3    5]]

 [[   6   15   20]
  [  11  101 1001]]]


[[[  42    7    0]
  [   0    0    0]]

 [[   5   10   15]
  [  10  100 1000]]]
*
[[[3 3 3]
  [1 3 5]]

 [[1 5 5]
  [1 1 1]]]
=
[[[ 126   21    0]
  [   0    0    0]]

 [[   5   50   75]
  [  10  100 1000]]]


[[[  42    7    0]
  [   0    0    0]]

 [[   5   10   15]
  [  10  100 1000]]]
//
[[[3 3 3]
  [1 3 5]]

 [[1 5 5]
  [1 1 1]]]
=
[[[  14    2    0]
  [   0    0    0]]

 [[   5    2    3]
  [  10  100 1000]]]


The resulting tensors are obtained by applying the arithmetic operation between corresponding elements of its inputs.

With an exception for scalar tensor inputs that we will describe in the next section, the shapes of the arguments should be compatible - either match exactly or be broadcastable, otherwise you will get an error.

Constant and Scalar Operands

Until now we considered only tensor inputs of matching shapes for inputs of arithmetic operators. DALI allows one of the operands to be a constant or a batch of scalars, and such operands can appear on both sides of binary expressions.

Constants

The constant operand for arithmetic operator can be one of the following options:

  • Values of Python’s int and float types that are used directly.

  • Values that are wrapped in nvidia.dali.types.Constant.

The operation between the tensor and the constant results in the constant that is broadcast to all tensor elements.

Note: Currently, the values of the integral constants are passed internally to DALI as int32 and the values of floating point constants are passed to DALI as float32.

With regard to type promotion, the Python int values will be treated as int32 and the float as float32.

The DALI Constant can be used to indicate other types. It accepts DALIDataType enum values as second argument and has convenience member functions such as .uint8() or .float32() that can be used for conversions.

Using the Constants

  1. Adjust the Pipeline to utilize constants.

[6]:
@pipeline_def(batch_size=batch_size, num_threads=4, device_id=0)
def pipeline_2():
    l, r = fn.external_source(source=get_data, num_outputs=2, dtype=types.INT32)
    add_200 = l + 200
    mul_075 = l * 0.75
    sub_15 = Constant(15).float32() - r
    return l, r, add_200, mul_075, sub_15
[7]:
pipe = pipeline_2()
pipe.build()
out = pipe.run()
  1. Display the results:

[8]:
def examine_output(pipe_out):
    l = pipe_out[0].as_array()
    r = pipe_out[1].as_array()
    add_200 = pipe_out[2].as_array()
    mul_075 = pipe_out[3].as_array()
    sub_15 = pipe_out[4].as_array()
    print("{}\n+ 200 =\n{}\n\n".format(l, add_200))
    print("{}\n* 0.75 =\n{}\n\n".format(l, mul_075))
    print("15 -\n{}\n=\n{}\n\n".format(r, sub_15))

examine_output(out)
[[[  42    7    0]
  [   0    0    0]]

 [[   5   10   15]
  [  10  100 1000]]]
+ 200 =
[[[ 242  207  200]
  [ 200  200  200]]

 [[ 205  210  215]
  [ 210  300 1200]]]


[[[  42    7    0]
  [   0    0    0]]

 [[   5   10   15]
  [  10  100 1000]]]
* 0.75 =
[[[ 31.5    5.25   0.  ]
  [  0.     0.     0.  ]]

 [[  3.75   7.5   11.25]
  [  7.5   75.   750.  ]]]


15 -
[[[3 3 3]
  [1 3 5]]

 [[1 5 5]
  [1 1 1]]]
=
[[[12. 12. 12.]
  [14. 12. 10.]]

 [[14. 10. 10.]
  [14. 14. 14.]]]


The constant value is used with all elements of all tensors in the batch.

Broadcasting

The term “broadcasting” refers to how tensors with different shapes are treated in mathematical expressions. A value from a smaller tensor is “broadcast” so it contributes to multiple output values. At its simplest, a scalar value is broadcast to all output values. The broadcasting between two batches can be seen as generalization of this concept.

In more complex cases, the values can be broadcast along some dimensions if one of the operands has size 1 and the other is larger. For example tensor of shape (1, 3) can be broadcast along the outermost dimension when added to a tensor with (2, 3) shape. Let’s add two such constants and see the result.

[9]:
@pipeline_def(batch_size=1, num_threads=4, device_id=0)
def pipeline_3():
    left = Constant(np.float32([[1., 2., 3.]]))
    right = Constant(np.float32([[-5., -6., -7.],
                               [10., 20., 30.]]))
    return left + right
[10]:
pipe = pipeline_3()
pipe.build()
out, = pipe.run()
print(out)
TensorListCPU(
    [[[-4. -4. -4.]
      [11. 22. 33.]]],
    dtype=DALIDataType.FLOAT,
    num_samples=1,
    shape=[(2, 3)])

Examining the output, we can see that the left tensor was added to both rows of the right tensor.

In fact, we don’t have to create a tensor with the leading dimensions equal to 1 - all the missing dimensions would be padded by 1. For example, broadcasting operation between tensors of shapes (3) and (800, 600, 3) is equivalent to broadcasting between (1, 1, 3) and (800, 600, 3).

Keep in mind, that every sample is broadcast individually.

Broadcasting scalars

Batch of scalars (0D tensors) can be broadcast against any other batch.

  1. Use an ExternalSource to generate a sequence of numbers which will be added to the tensor operands.

[11]:
@pipeline_def(batch_size=batch_size, num_threads=4, device_id=0)
def pipeline_4():
    tensors = fn.external_source(lambda: get_data()[0], dtype=types.INT32)
    scalars = fn.external_source(lambda: np.arange(1, batch_size + 1), dtype=types.INT64)
    return tensors, scalars, tensors + scalars
  1. Build and run the Pipeline.

[12]:
pipe = pipeline_4()
pipe.build()
out = pipe.run()
[13]:
def examine_output(pipe_out):
    t = pipe_out[0].as_array()
    scalar = pipe_out[1].as_array()
    result = pipe_out[2].as_array()
    print("{}\n+\n{}\n=\n{}".format(t, scalar, result))

examine_output(out)
[[[  42    7    0]
  [   0    0    0]]

 [[   5   10   15]
  [  10  100 1000]]]
+
[1 2]
=
[[[  43    8    1]
  [   1    1    1]]

 [[   7   12   17]
  [  12  102 1002]]]

The first scalar in the batch (1) is added to all elements in the first tensor, and the second scalar (2) is added to the second tensor.