Extending silt

This page describes the contract for writing a library that operates on silt tensors. It is the counterpart to Design Details, which explains how the polymorphic layer works; this page explains what you have to do to plug into it.

The short version: write strict-typed templated kernels against silt::tensor_t<T>, explicitly instantiate them for the types you support, mark the instantiations EXPORT_SHARED, and bind them with a silt::select call that recovers the static type from the runtime tag.

Linking against silt

silt builds two targets. silt_lib (aliased as silt::silt) is the shared C++/CUDA library; silt is the Python module. As a submodule consumer you want the former:

add_subdirectory(${CMAKE_SOURCE_DIR}/ext/silt silt)
target_link_libraries(${TARGET_NAME} PUBLIC silt_lib)

Linking silt_lib is what makes your library interoperable with every other library built against the same silt: a silt::tensor created by one can be passed to another, because both resolve the same exported symbols and the same polymorphic implementation pointer.

Note that silt deliberately hides the CUDA runtime behind its own interface (silt/core/memory.hpp), so only silt itself links against the CUDA runtime. Your library does not need to.

Symbol export rules

On Windows, everything reachable across the DLL boundary must be explicitly exported, and this includes free functions – not just types. EXPORT_SHARED (defined in silt/silt.hpp) expands to __declspec(dllexport) or dllimport as appropriate, and to a visibility attribute elsewhere.

The rules, which are easy to get subtly wrong:

  • Generic templates are not EXPORT_SHARED. A template is not a symbol; there is nothing to export until it is instantiated.

  • Explicit template instantiations are EXPORT_SHARED. The instantiation is what produces the specialized symbol, so that is where the attribute goes.

  • Specialized templates in headers are EXPORT_SHARED, including bare declarations.

  • Non-template functions in headers are EXPORT_SHARED, including bare declarations.

Concretely, from silt/op/common_unary.cu:

// The generic template: no EXPORT_SHARED.
template<typename T>
void set(tensor_t<T> lhs, const T rhs) {
  op::uniop_inplace(lhs, [rhs] GPU_ENABLE (const T a){ return rhs; });
}

// The instantiations: EXPORT_SHARED goes here.
template EXPORT_SHARED void silt::set<int>   (silt::tensor_t<int> lhs,    const int rhs);
template EXPORT_SHARED void silt::set<float> (silt::tensor_t<float> lhs,  const float rhs);
template EXPORT_SHARED void silt::set<double>(silt::tensor_t<double> lhs, const double rhs);

Explicit instantiation is required because the kernel bodies live in .cu translation units compiled by nvcc, while the call sites live in .cpp translation units compiled by the host compiler. The host compiler never sees the template body, so the symbol has to exist already.

Host and device functions

GPU_ENABLE marks a function as callable from both host and device. It expands to __host__ __device__ when HAS_CUDA is defined and to nothing otherwise, which is what lets the same tensor_t, shape, slice and view_t headers be consumed by both nvcc and the host compiler.

HAS_CUDA must be defined consistently for every translation unit that includes silt headers and is compiled by nvcc. Defining it for some translation units and not others means the inline members of those shared types have different definitions in different units, which is an ODR violation – benign in practice today, but not something to rely on.

Writing a kernel operation

The typical shape of a downstream operation:

// mylib/erode.cuh
#include <silt/core/tensor.hpp>

namespace mylib {

template<typename T>
void erode(silt::tensor_t<T> height, const T rate);

}
// mylib/erode.cu
#define HAS_CUDA
#include "erode.cuh"

namespace mylib {

template<typename T>
__global__ void __erode(silt::tensor_t<T> height, const T rate) {
  const unsigned int n = blockIdx.x * blockDim.x + threadIdx.x;
  if (n >= height.elem()) return;
  // ... operate on height[n] ...
}

template<typename T>
void erode(silt::tensor_t<T> height, const T rate) {
  __erode<<<(height.elem() + 511) / 512, 512>>>(height, rate);
}

template EXPORT_SHARED void mylib::erode<float> (silt::tensor_t<float> h,  const float r);
template EXPORT_SHARED void mylib::erode<double>(silt::tensor_t<double> h, const double r);

}

Note that tensor_t<T> is passed by value into kernels. It is reference counted, so this is cheap, and the copy is what makes the raw data pointer available on the device.

Generic elementwise operations

Before writing a kernel by hand, check whether silt/core/operation.hpp already covers it. It provides host/device-dispatching map and zip primitives that accept an arbitrary functor, so most elementwise work needs no kernel of your own:

#include <silt/core/operation.hpp>

// unary, in place: lhs[n] = func(lhs[n])
silt::op::uniop_inplace(tensor, [] GPU_ENABLE (const float a){ return a * 0.5f; });

// binary, in place: lhs[n] = func(lhs[n], rhs[n])
silt::op::binop_inplace(lhs, rhs, [] GPU_ENABLE (const float a, const float b){
  return a + b;
});

These dispatch on lhs.host() and run either a host loop or a CUDA kernel. They accept both tensor_t<T> and view_t<T>, so the same functor works on whole tensors and on slices.

Two caveats. These primitives do not validate that lhs and rhs agree in shape or live on the same device – that is the caller’s responsibility. And because they take an arbitrary functor, the lambda must be marked GPU_ENABLE and the translation unit must be compiled with --extended-lambda.

Exposing your operation to Python

Bind the polymorphic wrapper, not the strict-typed template, and use silt::select to recover the static type from the runtime tag. Constrain the lambda’s template parameter to the types your kernel actually supports – a mismatched type then raises at runtime instead of failing to compile:

module.def("erode", [](silt::tensor& height, const float rate){
  silt::select(height.type(), [&]<std::floating_point S>(){
    mylib::erode<S>(height.as<S>(), S(rate));
  });
});

as<T>() is an unchecked static_cast on the implementation pointer. Inside a select the tag has just been switched on, so the cast is sound. Calling as<T>() outside a select, on a tag you have not verified, is type confusion – check type() first.

See Design Details for the details of the select mechanism and how concept constraints on the lambda parameter restrict which instantiations are generated.