Design Details¶
Hybrid Static Polymorphism with Concepts¶
The silt C++ API is designed to be completely strict-typed, for static compile-time guarantees. At the same time, it provides the option to have polymorphic type deduction where desired. The consequence is a blended structure that lets you decide where you need strict-typing, and where you want runtime deduction.
This is particularly useful in the context of python bindings, as data of various types can be easily constructed and passed into your kernels.
This is achieved using a simple tag-based polymorphic pattern, with a wrapper type tensor that points to a strict-typed tensor_t<T>, and a templated lambda based type deduction that not only makes runtime deduction syntactically terse but also allows for concept based selection.
How it works¶
We will use data-types as our polymorphic example, as it is implemented in silt. The basic runtime polymorphism setup involves a tag enumerator, a mapping struct, a base class and a strict-typed class.
The mapping struct maps between C++ strict types and the polymorphic tag enumerator
The base class has a virtual destructor and a constexpr virtual function that returns a tag enumerator.
The strict-typed class derives from the polymorphic base and overrides the constexpr virtual function.
// Polymorphic Tag
enum dtype {
NONE,
INT32,
INT64,
FLOAT32,
FLOAT64
};
// Mapping Struct
template<typename T>
struct typedesc {
static constexpr dtype type = NONE;
};
// template specializations...
template<>
struct typedesc<int> {
static constexpr const char* name = "int";
static constexpr dtype type = INT;
typedef int value_t;
};
// Polymorphic Base Type
struct typedbase {
virtual ~typedbase() {};
constexpr virtual dtype type() noexcept {
return {};
}
};
// Strict-Typed Derived Class
template<typename T>
struct tensor_t: typedbase {
constexpr dtype type() noexcept {
return typedesc<T>::type;
}
};
Note that the enumerator includes NONE so that we can detect invalid initializations.
In principle, this is all we need to create the fully polymorphic interface. We can now create the polymorphic wrapper class, which can be converted to and from the strict-typed class if the type is known statically:
struct tensor {
// Constructors for wrapping any tensor_t<T>...
// Strict-Type Cast
template<typename T>
inline const tensor_t<T> &as() const noexcept {
return static_cast<tensor_t<T> &>(*(this->impl));
}
template<typename T>
inline tensor_t<T> &as() noexcept {
return static_cast<tensor_t<T> &>(*(this->impl));
}
inline dtype type() const noexcept {
return this->impl->type();
}
private:
typedbase* impl = NULL; // Polymorphic Implementation Pointer
}
If the strict-type is not known statically, we can use the overriden virtual tag function to select the type at runtime with a single switch statement. This is particularly convenient with templated lambdas:
void runtime_poly_to_static(const tensor& tensor){
select(tensor.type(), [tensor]<typename S>(){
const auto tensor_t = tensor.as<S>();
//... do something static ...
});
}
Note that the very definition of the lambda will instantiate all template paths specified by the select function, and that the entirety of the lambda is strict-typed. Only the one runtime static_cast will actually be executed and the static path selected based on the switch statement.
It is possible to restrict the path instantiation further using concepts by defining an additional concept that asks whether another concept generates a valid evaluatable expression (“matches_lambda”), and running an if constexpr on that concept. This is necessary because otherwise the compilation would fail. The consequence is that each enumerator has to duplicate this code once - it doesn’t appear to be possible without that.
template<typename T, typename F, typename... Args>
concept matches_lambda = requires(F lambda, Args &&...args) {
{ lambda.template operator()<T>(std::forward<Args>(args)...) };
};
template<typename F, typename... Args>
auto select(const dtype type, F lambda, Args &&...args) {
switch (type) {
case dtype::INT:
if constexpr (matches_lambda<int, F, Args...>) {
return lambda.template operator()<int>(std::forward<Args>(args)...);
} else {
throw type_op_error<int, F>(lambda);
}
break;
case dtype::FLOAT32:
if constexpr (matches_lambda<float, F, Args...>) {
return lambda.template operator()<float>(std::forward<Args>(args)...);
} else {
throw:type_op_error<float, F>(lambda);
}
break;
case dtype::FLOAT64:
if constexpr (matches_lambda<double, F, Args...>) {
return lambda.template operator()<double>(std::forward<Args>(args)...);
} else {
throw type_op_error<double, F>(lambda);
}
break;
default:
throw std::invalid_argument("type not supported");
}
}
Ultimately, this allows us to write strict-typed operations that only work for tensors that satisfy specific concepts, that are fully compiled with strict types, but call them using runtime polymorphic concept matching:
template<std::floating_point T>
void my_floating_point_operation(tensor_t<T>& tensor);
// won't compile: some switch paths don't match concept
void interface_func(tensor& tensor){
select(tensor.type(), [tensor]<typename S>(){
my_floating_point_operation(tensor.as<S>());
});
}
// will compile! mismatched types throw runtime error.
void interface_func(tensor& tensor){
select(tensor.type(), [tensor]<std::floating_point S>(){
my_floating_point_operation(tensor.as<S>());
});
}
Contiguous Storage, Strided Views¶
tensor_t<T> only ever holds a shape – a dense, row-major extent with
no notion of stride. Its element access is a flat, unconditional index into
the owned buffer:
GPU_ENABLE T operator[](const size_t index) const noexcept {
return this->_data[index];
}
There is no way to construct a non-contiguous tensor_t<T>. Strided access
– the result of slicing along a dimension with an offset, stride or a
sub-extent – lives entirely in a separate type, view_t<T>, whose index
operator instead goes through slice::transform():
GPU_ENABLE T operator[](const size_t index) const noexcept {
return this->_data[this->_slice.transform(index)];
}
So tensor_t<T> and view_t<T> are not two ways of writing the same
thing: tensor_t<T> is the type that owns memory and guarantees
contiguity, and view_t<T> is the type that can describe an arbitrary
strided sub-region but never owns anything. Slicing a tensor
(tensor[...] in Python, or .view<T>() in C++) produces a
view/view_t<T>, never another tensor/tensor_t<T> – there is
no operation in silt that takes a strided region and hands you back
something claiming to be densely packed.
This is also why numpy/torch interop copies rather than aliasing memory (the “Note that these conversions currently copy the data” callout in Using silt and the README): a numpy or torch array can be non-contiguous (a transpose, a stride-2 slice, …), and silt has no owning, contiguous- guaranteed type that could wrap such a thing without either copying it into one or exposing an owning type that lies about its own invariant. Wrapping non-contiguous foreign memory as a view instead of a tensor would sidestep that problem, and is roughly the shape zero-copy interop would need to take, should it ever be implemented.
Explicit Host Placement¶
silt never migrates data between CPU and GPU on your behalf. operator[]
does not check host() and dispatch accordingly – it unconditionally
dereferences _data, exactly as shown above. Indexing a GPU tensor from
host code is therefore not a silt error, it is an illegal host dereference of
a device pointer, the same as it would be with a raw cudaMalloc’d buffer.
The only host-crossing operations are the explicit ones – all built on
tensor_t<T>::copy_to(), which covers all four host/device copy
directions through the one function – and the numpy/torch conversion
functions. copy_to() always allocates and copies, even when the
target host matches the source’s; .to(host) (the in-place mutator, a
no-op if already on the target host – .to_cpu()/.to_gpu() are
simple aliases for it) is built on top of it. The Python-facing
.copy_to(host=None) exposes it directly as an out-of-place,
always-independent copy, and usable across hosts, not just same-host. This mirrors
PyTorch’s .cpu()/.cuda() naming for the in-place movers
deliberately, for the same reason silt.synchronize() mirrors
torch.cuda.synchronize(): someone moving between the two libraries
should not have to learn a second mental model for where data lives and
when it moves.
The detail Namespace Convention¶
Functions and kernels that exist only to implement something else –
never called from outside their own translation unit or from Python –
live in a nested namespace detail { ... }, rather than being named with
a leading double underscore (the codebase’s older convention). This is a
correctness fix, not a style preference: [lex.name] reserves any identifier containing a
double underscore to the implementation at any scope, not only at global
scope, so a name like op::__uniop_inplace_gpu was already undefined
behaviour to declare, namespace or not. Renaming to op::detail::
uniop_inplace_gpu fixes the reserved-identifier problem and, as a side
effect, gives every internal helper a consistent, greppable home:
silt::detail and silt::op::detail should never appear in anything a
caller (C++ or Python) is meant to depend on, and the Doxygen configuration
(EXCLUDE_SYMBOLS in doc/Doxyfile) keeps them out of C++ API
on that basis. A single leading underscore is not subject to the same
rule – it is only reserved at global scope – so a name like silt::_set
inside a namespace is legal and was deliberately left alone rather than
renamed for its own sake.
If you add an internal helper, it belongs in detail inside whichever
namespace it supports (silt::detail for core-layer helpers,
silt::op::detail for kernel-layer helpers), not spelled with a
double underscore, and not left bare in the surrounding namespace either.
In-Place vs. Out-of-Place Naming¶
Every C++-bound operation is in-place: it writes into a tensor you already
own, and its Python binding carries a trailing underscore (set_, add_,
multiply_, divide_, mix_, clamp_) – the same convention
PyTorch uses for its mutating methods. The out-of-place counterparts
(silt.add, silt.multiply, …) exist only in
python/silt/__init__.py, as plain Python functions that copy the first
argument via tensor.copy_to() and then call the in-place form on the
copy:
def add(lhs, rhs):
result = lhs.copy_to()
add_(result, rhs)
return result
There is no separate C++ kernel for “out-of-place add” and there is not meant to be one: “out-of-place” is definitionally “allocate, then do the in-place thing,” so writing it in Python is not a shortcut being taken for now, it is the whole implementation. A new operation added to silt should follow the same split: write the in-place kernel and its C++/Python binding, and let the out-of-place form (if you need one at all) be a two-line Python wrapper alongside the existing ones, not a second kernel.