Variants in C++¶
As described in the section on choosing variants, Mitsuba 3 code can be compiled into different variants, which are parameterized by their computational backend and representation of color. To enable such retargeting from a single implementation, the system relies on C++ templates and metaprogramming. Indeed, most C++ classes and functions in Mitsuba 3 are templates with the following two type parameters:
Float
andSpectrum
.
These two parameters exactly correspond to the previously mentioned
computational backend and color representation. During compilation, Mitsuba’s
build system reads the mitsuba.conf
file and substitutes the types of
selected variants into these template parameters. For example,
"scalar_rgb": {
"float": "float",
"spectrum": "Color<Float, 3>"
},
causes an explicit template instantiation with
Float = float;
Spectrum = Color<Float, 3>;
The resulting C++ symbols will be added to the shared libraries
(dist/libmitsuba.so
, dist/plugin/*.so
, …). At runtime, the
user-specified variant will determine the set of symbols to be used by the
renderer.
Type aliases¶
Of course, Float
and Spectrum
are not the only types that are used in a
renderer. It also access to integer arithmetic types, vectors, normals, points,
rays, matrices, and so on. More complex data structures like intersection and
sampling records are also commonly used.
It would be tedious to have to define these types in the context of a given
variant. To facilitate this process, Mitsuba provides a helper macro to import
suitable types that are inferred from the definition of Float
and
Spectrum
.
For example, after evaluating this macro at the beginning of a templated
function, we are then able to use other templated Mitsuba types (e.g.
Vector2f
, Ray3f
, SurfaceInteraction
, …) as if they were not templated.
template <typename Float, typename Spectrum>
void my_function() {
/// Import type aliases (e.g. using Vector3f = Vector<Float, 3>;)
MI_IMPORT_TYPES()
// Can now use those types as if they were not templated
Point3f p(4.f, 3.f, 0.f);
Vector3f v(1.f, 0.f, 0.f);
Ray3f ray(p, v);
std::cout << ray << std::endl;
}
Note
The MI_VARIANT
macro is often used as a shorthand notation instead of
the somewhat verbose template <typename Float, typename Spectrum>
.
Those macros are described in more detail in this section.
Branching and masking¶
When dealing with vectorized computational backends (e.g. llvm_*
,
cuda_*
), additional scrutiny is needed to adapt C++ branching logic (in
particular, if
statements).
Consider the result of a ray intersection in scalar mode. The resulting
SurfaceInteraction3f
record holds information
concerning a single surface intersection. In this case, conditional logic works
fine using normal if
statements.
On the other hand, the same data structure in a vectorized backend (e.g.
cuda_rgb
) holds information concerning many surface intersections. Since
any condition may only be true for a subset of the elements, conditionals
logic can no longer be carried out using ordinary if
statements.
The alternative operation dr::select(mask, arg1, arg2)
takes a mask
argument (typically the result of a comparison) and evaluates (mask ? arg1 :
arg2)
in parallel for each lane. We refer to Dr.Jit’s documentation for
further information on working with masks. The following shows an example
contrasting these two cases:
// --------------------
// Scalar code (discouraged)
Scene scene = ...;
Ray3f ray = ...;
SurfaceInteraction3f si = scene->ray_intersect(ray);
if (si.is_valid())
return 1.f;
else
return 0.f;
// --------------------
// Generic code
Scene scene = ...;
Ray3f ray = ...;
SurfaceInteraction3f si = scene->ray_intersect(ray);
return dr::select(si.is_valid(), 1.0f, 0.f);
Moreover, most of the functions/methods take an optional active
parameter
that encodes which lanes remain active. In the example above, we can e.g.
provide this information to the ray_intersect
routine to avoid computation
(particularly, memory reads) associated with invalid entries. The updated code
then reads:
// Mask specifying the active lanes
Mask active = ...;
Scene scene = ...;
Ray3f ray = ...;
SurfaceInteraction3f si = scene->ray_intersect(ray, active);
return dr::select(active & si.is_valid(), 1.0f, 0.f);
JIT backend synchronization point¶
As described in Dr.Jit’s documentation,
the cuda
and llvm
computational backends rely on a JIT compiler that
dynamically generates kernels using NVIDIA’s PTX intermediate language. This JIT
compiler is highly efficient for vertical operations (additions,
multiplications, gathers, scatters, etc.). However, applying a horizontal
operations (e.g. dr::any()
, dr::all()
, dr::hsum()
, etc.) to a
JITArray<T>
will flush all currently queued computations, which limits the
amount of parallelism.
In many cases, horizontal mask-related operations can safely be skipped if this
yields a performance benefit. For this reason, the Mitsuba 3 codebase makes
frequent use of alternative reduction operations (any_or<>()
,
all_or<>()
, …) that skip evaluation on GPU targets.
For example, the code ...
in the example below will
only be executed if condition
is true
in scalar_*
variants.
Mask condition = ...;
if (any_or<true>(condition)) {
...
}
In cuda
and llvm
variants, we are typically working with arrays
containing millions of elements, and it is quite likely that at least of one of
the array entries will in any case trigger execution of the ...
. The
any_or<true>(condition)
then skips the costly horizontal reduction and
always assumes the condition to be true.
Pointer types¶
The MI_IMPORT_TYPES
macro also imports variant-specific type aliases for
pointer types. This is important: for example, consider the BSDF
associated
with a surface intersection. In a scalar variant , this is nicely represented
using a const BSDF *
pointer. However, on a vectorized variants (cuda_*
,
llvm_*
), the intersection is in fact an array of many intersections, and
the simple pointer is therefore replaced by an array of pointers*. These
pointer aliases are used as follows:
// Imports BSDFPtr, EmitterPtr, etc..
MI_IMPORT_TYPES()
Scene scene = ...;
Mask active = ...;
Ray3f ray = ...;
SurfaceInteraction3f si = scene->ray_intersect(ray, active);
// Array of pointers if Float is an array
BSDFPtr bsdf = si.bsdf();
// Dr.Jit is able to dispatch method calls involving arrays of pointers
bsdf->eval(..., active);
More information on vectorized method calls is provided in the Dr.Jit documentation.
Variant-specific code¶
The C++17 if constexpr
statement is often used throughout the codebase to
restrict code fragments to specific variants. For instance the following C++
snippet converts a spectrum to an XYZ tristimulus value, which crucially
depends on the color representation of the variant being compiled.
Ray3f ray = ...;
Mask active = ...;
Spectrum result = compute_stuff(ray, active);
Color3f xyz;
if constexpr (is_monochromatic_v<Spectrum>)
xyz = result.x();
else if constexpr (is_rgb_v<Spectrum>)
xyz = srgb_to_xyz(result, active);
else
xyz = spectrum_to_xyz(result, ray.wavelengths, active);
Since if constexpr
is resolved at compile-time, this branch does not cause
any runtime overheads. Another useful feature of if constexpr
is that it
suppresses compilation errors in disabled branches. This makes it possible to
write generic code that could potentially produce compilation errors when expressed
using ordinary (non-constexpr
) if
statements (for example, by accessing
a member of a class that may not exist in all variants).
Mitsuba provides various type-traits such as is_monochromatic_v
to query
variant-specific properties. They can be found in
include/mitsuba/core/traits.h
.