Graph Framework
|
Hands on tutorial for building expressions and running workflows.
In this tutorial we will put the basic General Concepts of the graph_framework into action. This will discuss building trees, generating kernels, and executing workflows.
To accomplish this there is a playground tool in the graph_playground
directory. This playground is a preconfigured executable target which can be used to test out the API's of this framework. The playground starts with a blank main function.
To start, create a template function above main and call that function from main. This will allow us to play with different floating point types. For now we will start with a simple float type.
Here jit::float_scalar is a C++20 Concept of valid floating point types allowed by the framework.
The graph_framework is built around applying the same equations to a large ensemble. Ensembles are defined from variables. All variables need to have same dimensionality. To create a variable we use one of the variable factory methods. For now we will build it without initalizing it.
Here we are creating a graph::variable_node with the symbol \(x\) which holds 1000 elements. The symbol is used by the \(\LaTeX\) renderer as a symbol place holder. Any graph can be rendered to \(\LaTeX\) by calling the graph::leaf_node::to_latex method.
When compiling and running the code, this will print out the \(\LaTeX\) code needed to generate the equation. By copy and pasting this into \(\LaTeX\) it produces the symbol \(x\). Note that all nodes of a graph are wrapped in a std::shared_ptr
. so all method are called using the ->
operator.
Next we want to define a constant. There are two method to define constants explicitly or implicitly.
An explicit graph::constant_node is created for m
while an impicit constant was defined for b
. Note in the implicit case, the actual node for b
is not created until we use it in an expression.
Finally lets create our equation of a line \(y=mx+b\) and generate the \(\LaTeX\) expression for it.
Running this will generate the output \left(0.4 x+0.6\right)
which renders to \(\left(0.4 x+0.6\right)\).
Derivatives of nodes can be taken with respect to any explicit node or graph. As an example lets take the \(\frac{\partial y}{\partial x}\) and render the expression to latex.
Here we take derivatives using the graph::leaf_node::df method. We can also take several variations of this.
The results will be \(0.4\), \(x\), \(1\), \(1\), and \(1\) respectively.
In this section we will build a workflow from these nodes we created. For simplicity we will decrease the number of elements in the variable so we can set the values easier. First thing we do is create a workflow::manager.
This creates a workflow for device 0.
To create a kernal, we add a workflow::work_item using the workflow::manager::add_item method.
Here we have created a kernel which computes the outputs of \(y\) and \(\frac{\partial y}{\partial x}\). The third and forth arguments are blank because there are no maps and we are not using random numbers. The last argument needs to match the dimensions of the inputs. Inputs need to be cast from generic graph::leaf_node to the specific graph::variable_node.
To evaluate the kernel, we need to first compile it. Then set the values for \(x\) and run the kernel. To see the results we can print the values of the nodes used.
Running this we get the output
At this point it's important to understand some things that need to be taken into account when working with devices. Note that we needed to set the value of \(x\) before we create the kernel since that's when the device buffers are populated. If we move that to after the kernel is generated.
We get the output
showing that the device dose not have the updated values. To set values from the host. You need to explicity copy from a host buffer to a device buffer using the workflow::manager::copy_to_device method.
This restores the expected result.
In this section we are going to make use of maps to iterate a variable. We want to evaluate the value of \(y\) and set it as the new value of \(x\). We do this my modifying call to workflow::manager::add_item to define a map. This generates a kernel where after \(y\) is computed it is stored in the \(x\) buffer.
Running this code shows the value of x continously updating.
In this tutorial we are going to show how we can put all these concepts together to implement a Newton's method. Newton's method is defined as
\begin{equation}x = x - \frac{f\left(x\right)}{\frac{\partial}{\partial x}f\left(x\right)}\end{equation}
From the iteration example, its step update can be handled by a simple map. However, we need a measure for convergence. To do that we output the value of \(f\left(x\right)\). Lets setup a test function.
Running shows the objective function and all three elements found the same root.
However there are some things that are not optimial here. We are performing a reduction on the host side and transfering the entire array to the host. To improve this we can use a converge item instead.
We can achieve the same result in a single run call. The advantage here is the reduction is now performed on the device and only a scalar is copied to the host to test for convergence.