|
Peano
|
Exahype uses a domain specific language (DSL) to define its kernels.
This allows us to generate versions for different architectures, and to also generate a version that uses MLIR to perform optimisation passes. For details on using MLIR see Native LLVM.
This page provides details its syntax. It is compiled by making modifications to a Python abstract syntax tree (AST), so the majority of its syntax matches that of Python. The timeStepWithRusanov function is a good reference to look at to give context to the following descriptions.
The DSL kernels start with specifying the name and parameters, given in usual Python syntax. Parameters types are given using type annotations, which are strings if it is not an existing Python type. The first few lines of the function usually access diferent properties of the CellData parameter. In the general case this looks like
The format of the DataBlock constructor is explained in the next section.
The parsing and compilation takes place in the Parser class. Create a default parser then call its parse() function. It takes the following parameters:
This function returns the root of an ast, which can be used to generate the kernel using root.print_cpp(). Functions can be organised in files by calling the print_declaration methods, and versions with a timer can also be generated. See kernels.py in the rusanov python code for example usage.
Implementing a solver for MLIR usage is mostly the same as for the C++ pretty printer. There are however some additional steps. For CPU usage this requires certain Jinja2 variables to be set so that the MLIRBridge files can be generated. The variables that need to be set can be seen in python/fv/rusanov/kernels.py, which implements all required steps.
Compiling to GPU is more involved, as we require an MLIR version of each physics function so that they can be inlined. We do this using Polygeist. To compile a function using Polygeist we need a file that contains the minimal number of includes. This requires Vector parameters to be replaced with double*, which is how we represent them in the MLIR code. With this change Polygeist should be able to compile each funcition with
The resulting functions should all be added to one file, and the file should be added as the "MLIR_PHYSICS_FILE" makefile argument.
Unfortunately this function may still not be included or inlined successfully, leading to an error when building the benchmark. The issues we have encountered so far are:
An example of these changes can be seen by comparing CCZ4Kernels.cpph with CCZ4MLIR.cpp.
A DataBlock is used to represent an array of patches in the DSL code. They are constructed using a 2D list specifying its dimensions, which for three dimensions looks like
Notice how [-HaloSize, NumberOfVolumes + HaloSize] is repeated for each dimension.
The second parameter specifies where the patch data comes from. It could be a string, indicating that the data already exists, in which case the string should be the name of the existing data. Alternatively, it could be a function which is performed over the given interval. A string is used mainly to access CellData information. Assuming that a CellData object with name patchData is a kernel parameter, the code would look like
Should there be any optional code in the kernel that should only be used in certain versions of the kernel, template boolean parameters can be passed to the parser. Using if statements on these can be used to control code execution. Only use if satements with these template parameters as they evaluate to if constexpr() to enable more opportunities for optimisation.
Conditional evaluation can also be used to generate DataBlocks, by assigning them the result of a boolean comparison with another DataBlock. e.g. test = (input > 0.0) * 2 - 1 After this test has the same dimensions as input, but with 1 wherever input is positive, and -1 wherever it's negative. It is worth noting that test's underlying type is an integer if no floating point operation is performed to it.
Subsets of a datablock can be specified using indexing, such as input[0, :, :] which selects the first variable of a 2d DataBlock. The order of indices is the same as when specifying a DataBlocks dimensions. Each index can either be an individual integer or a slice. When a slice is used, if either side isn't specified it is assumed to be the relevant end of the DataBlock's iteration range. For example input[10:] selects variable 10 onwards, and input[:10] selects up to variable 10.
If any operations are performed between different sizes of datablock indexing should be used to select the relevant part of the smaller DataBlock. The only exception is when performing an operation with DataBlock with different numbers of variables, and one of them has one variable. In this case the datablock with one variable will act as if it contained duplicates of this variable to match the number of variables in the other DataBlock.