..  Licensed to the Apache Software Foundation (ASF) under one
    or more contributor license agreements.  See the NOTICE file
    distributed with this work for additional information
    regarding copyright ownership.  The ASF licenses this file
    to you under the Apache License, Version 2.0 (the
    "License"); you may not use this file except in compliance
    with the License.  You may obtain a copy of the License at

..    http://www.apache.org/licenses/LICENSE-2.0

..  Unless required by applicable law or agreed to in writing,
    software distributed under the License is distributed on an
    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    KIND, either express or implied.  See the License for the
    specific language governing permissions and limitations
    under the License.

Testing TVM
===========

This page describes how to write and run Python tests for TVM,
including the target parametrization utilities used by CI.

Python Target Parametrization
-----------------------------

Summary
~~~~~~~

For any supported runtime, TVM should produce numerically
correct results.  Therefore, when writing unit tests that validate
the numeric output, these unit tests should be run on all supported
runtimes.  Since this is a very common use case, TVM has helper
functions to parametrize unit tests such that they will run on all
targets that are enabled and have a compatible device.

A single Python function in the test suite can expand to several
parameterized unit tests, each of which tests a single target device.
In order for a test to be run, all of the following must be true.

- The test exists in a file or directory that has been passed to
  `pytest`.

- The pytest marks applied to the function, either explicitly or
  through target parametrization, must be compatible with the
  expression passed to pytest's `-m` argument.

- For parametrized tests using the `target` fixture, the target must
  appear in the environment variable `TVM_TEST_TARGETS`.

- For parametrized tests using the `target` fixture, the build
  configuration in `config.cmake` must enable the corresponding
  runtime.

Unit-Test File Contents
~~~~~~~~~~~~~~~~~~~~~~~

.. _pytest-marks: https://docs.pytest.org/en/stable/how-to/mark.html

The recommended way to run a test on multiple targets is to parametrize
over ``target`` with ``@pytest.mark.parametrize``.  Tag each GPU target
with ``pytest.mark.gpu`` so the CI routes it to a GPU node, skip a target
that cannot run on the current machine with
:py:func:`tvm.testing.device_enabled`, and obtain its device with
``tvm.device(target)``.  The function is run once per target, the
success/failure of each is reported separately, and a target whose device
is disabled in ``config.cmake`` or absent from the machine is reported as
skipped.

.. code-block:: python

    @pytest.mark.parametrize(
        "target",
        ["llvm", pytest.param("cuda", marks=pytest.mark.gpu)],
    )
    def test_function(target):
        if not tvm.testing.device_enabled(target):
            pytest.skip(f"{target} not enabled")
        dev = tvm.device(target)
        # Test code goes here

For a test that only applies to a single target, omit the parametrization
and gate the test with ``@pytest.mark.skipif`` (plus ``@pytest.mark.gpu``
for a GPU target):

.. code-block:: python

    @pytest.mark.gpu
    @pytest.mark.skipif(
        not tvm.testing.device_enabled("cuda"), reason="cuda not enabled"
    )
    def test_function():
        target = "cuda"
        dev = tvm.device(target)
        # Test code goes here

To exclude a target, leave it out of the parametrize list.  To mark a
target as expected to fail, wrap it with
``pytest.param("target", marks=pytest.mark.xfail(reason=...))``.

Additional parameters can be combined with the target parametrization by
stacking ``@pytest.mark.parametrize`` decorators, or by listing tuples of
arguments.  Tag the GPU rows with ``pytest.mark.gpu`` and skip in the body
as above:

.. code-block:: python

   @pytest.mark.parametrize("target,impl", [
        ("llvm", cpu_implementation),
        pytest.param("cuda", gpu_implementation_small_batch, marks=pytest.mark.gpu),
        pytest.param("cuda", gpu_implementation_large_batch, marks=pytest.mark.gpu),
    ])
    def test_function(target, impl):
        if not tvm.testing.device_enabled(target):
            pytest.skip(f"{target} not enabled")
        dev = tvm.device(target)
        # Test code goes here


Tests gate on hardware and carry metadata using
`pytest marks <pytest-marks>`_.  The most frequently applied
marks are as follows.

- ``@pytest.mark.gpu`` - Tags a function as using GPU
  capabilities. This has no effect on its own, but can be paired with
  the command-line arguments ``-m gpu`` or ``-m 'not gpu'`` to restrict
  which tests pytest will execute.  Apply it to any test that needs a
  GPU so that the CI runs it only on GPU nodes.

- ``@pytest.mark.skipif(not tvm.testing.env.has_X(), reason=...)`` -
  Skips a test when a required runtime or hardware feature is not
  available.  The :py:mod:`tvm.testing.env` module exposes one memoized
  probe per capability (e.g. ``has_cuda()``, ``has_rocm()``,
  ``has_vulkan()``, ``has_gpu()``, ``has_llvm()``), each of which
  returns ``False`` when the runtime is disabled in ``config.cmake`` or
  no compatible device is present.  Pair it with ``@pytest.mark.gpu``
  for tests that use the GPU::

      @pytest.mark.gpu
      @pytest.mark.skipif(not tvm.testing.env.has_cuda(), reason="need cuda")
      def test_cuda_vectorize_add():
          # Test code goes here

- ``pytest.importorskip("package_name")`` - Skips a test (or the whole
  module, when called at import time) if an optional Python package is
  not installed.  Use this instead of a ``skipif`` for package
  dependencies.

Tests that execute on a local GPU must put the complete live-device
lifetime in a small callback passed to
:py:func:`tvm.testing.run_with_gpu_lock`.  Target construction and
compilation remain outside so that pytest-xdist workers can compile in
parallel.  Device creation, allocation, execution, synchronization,
host conversion, result checks, and child-process teardown remain inside
the callback so no device-backed object outlives the lock.

.. code-block:: python

    @pytest.mark.gpu
    @pytest.mark.skipif(not tvm.testing.env.has_cuda(), reason="need cuda")
    def test_cuda_add_one():
        target = tvm.target.Target("cuda -arch=sm_90")
        executable = tvm.compile(make_add_one_module(), target)
        host_input = np.arange(16, dtype="float32")

        def run_and_check():
            dev = tvm.cuda(0)
            device_input = tvm.runtime.tensor(host_input, dev)
            device_output = tvm.runtime.empty(host_input.shape, "float32", dev)
            executable(device_input, device_output)
            dev.sync()
            tvm.testing.assert_allclose(device_output.numpy(), host_input + 1)

        tvm.testing.run_with_gpu_lock(run_and_check)

The wrapper uses the existing :py:class:`tvm_ffi.utils.FileLock` with a
persistent machine-local path.  A process exit releases the kernel lock;
the remaining file is not stale ownership.  Test startup must never
delete or rotate it, because another process could then lock a different
inode.  Set ``TVM_TEST_LOCK_DIR`` only when all cooperating processes need
an explicitly configured shared machine-local directory.  The default
temporary path coordinates processes running as the same user.  Multi-user
runners sharing a GPU must use one administrator-provisioned directory and
persistent lock file that every contender can write, or enforce exclusivity
through the runner.  A per-user lock path cannot protect a GPU shared across
users because each user would lock a different file.

There also exists a ``tvm.testing.enabled_targets()`` that returns
all targets that are enabled and runnable on the current machine,
based on the environment variable ``TVM_TEST_TARGETS``, the build
configuration, and the physical hardware present.  Some legacy tests
explicitly loop over the targets returned from ``enabled_targets()``,
but this style should not be used for new tests.  The pytest output
for this style silently skips runtimes that are disabled in
``config.cmake``, or do not have a device on which they can run.  In
addition, the test halts on the first target to fail, which is
ambiguous as to whether the error occurs on a particular target, or on
every target.

.. code-block:: python

    # Old style, do not use.
    def test_function():
        for target, dev in tvm.testing.enabled_targets():
            # Test code goes here



Running Locally
~~~~~~~~~~~~~~~

To run the Python unit tests locally, use the command ``pytest`` in
the ``${TVM_HOME}`` directory.

- Environment variables
    - ``TVM_TEST_TARGETS`` should be a semicolon-separated list of
      targets to run. If unset, will default to the targets defined in
      ``tvm.testing.DEFAULT_TEST_TARGETS``.

      Note: If ``TVM_TEST_TARGETS`` does not contain any targets that
      are both enabled, and have an accessible device of that type,
      then the tests will fall back to running on the ``llvm`` target
      only.

    - ``TVM_LIBRARY_PATH`` should be a path to the ``libtvm.so``
      library. This can be used, for example, to run tests using a
      debug build. If unset, will search for ``libtvm.so`` relative to
      the TVM source directory.

- Command-line arguments

    - Passing a path to a folder or file will run only the unit tests
      in that folder or file. This can be useful, for example, to
      avoid running tests located in ``tests/python/contrib`` on a
      system without a specific backend installed.

    - The ``-m`` argument only runs unit tests that are tagged with a
      specific pytest marker. The most frequent usage is to use
      ``-m gpu`` to run only tests that are marked with
      ``@pytest.mark.gpu`` and use a GPU to run. It can also be used
      to run only tests that do not use a GPU, by passing ``not gpu``
      as the marker expression to ``-m``.

      Note: This filtering takes place after the selection of targets
      based on the ``TVM_TEST_TARGETS`` environment variable.  Even if
      ``-m gpu`` is specified, if ``TVM_TEST_TARGETS`` does not
      contain GPU targets, no GPU tests will be run.

Running in a Local Docker Container
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. _tlcpack: https://hub.docker.com/u/tlcpack

The ``docker/bash.sh`` script can be used to run unit tests inside the
same docker image as is used by the CI.  The first argument should
specify which docker image to run (e.g. ``docker/bash.sh ci_gpu``).
Allowed image names are defined in ``ci/jenkins/data.py`` in the TVM source directory,
and map to images at `tlcpack`_.

If no additional arguments are given, the docker image will be loaded
with an interactive bash session.  If a script is passed as an
optional argument (e.g. ``docker/bash.sh ci_gpu tests/scripts/task_python_unittest.sh``), then that script will be
executed inside the docker image.

Note: The docker images contain all system dependencies, but do not
include the ``build/config.cmake`` configuration file for those
systems.  The TVM source directory is used as the home directory of
the docker image, and so this will default to using the same
config/build directories as the local config.  One solution is to
maintain separate ``build_local`` and ``build_docker`` directories,
and make a symlink from ``build`` to the appropriate folder when
entering/exiting docker.

Running in CI
~~~~~~~~~~~~~

Everything in the CI starts from the task definitions present in the
Jenkinsfile.  This includes defining which docker image gets used,
what the compile-time configuration is, and which tests are included
in which stages.

- Docker images

  Each task of the Jenkinsfile (e.g. 'BUILD: CPU') makes calls to
  ``docker/bash.sh``.  The argument following the call to
  docker/bash.sh defines the docker image in CI, just as it does
  locally.

- Compile-time configuration

  The docker image does not have the ``config.cmake`` file built into
  it, so this is the first step in each of the ``BUILD`` tasks.  This
  is done using the ``tests/scripts/task_config_build_*.sh`` scripts.
  Which script is used depends on the build being tested, and is
  specified in the Jenkinsfile.

  Each ``BUILD`` task concludes by packing a library for use in later
  tests.

- Which tests run

  The ``Unit Test`` stage of the Jenkinsfile determines how ``pytest``
  is called.  Each task starts by unpacking a
  compiled library that was previous compiled in the ``BUILD`` stage,
  then runs a test script
  (e.g. ``tests/scripts/task_python_unittest.sh``).  These scripts set
  the files/folders and command-line options that are passed to
  ``pytest``.

  Several of these scripts include the ``-m gpu`` option, which
  restricts the tests to only run tests that include the
  ``@pytest.mark.gpu`` mark.
