Crates.io | kn-cuda-eval |
lib.rs | kn-cuda-eval |
version | 0.7.3 |
source | src |
created_at | 2023-09-25 20:53:21.33648 |
updated_at | 2024-07-13 22:49:01.913153 |
description | A CUDA executor for neural network graphs |
homepage | |
repository | https://github.com/KarelPeeters/Kyanite |
max_upload_size | |
id | 983037 |
size | 758,232 |
Kyanite is a neural network inference library, written in/for Rust. It can run ONNX files either on the CPU or on Nvidia GPUs using cuda/cudnn/cublas.
It is general enough to run all kinds of networks, it has been tested with:
stable_diffusion
example in the kn-runtime
crate.The framework consists of the following crates:
kn-graph
: The core crate, containing the intermediate representation and the CPU executor.kn-cuda-sys
: The Cuda FFI bindings, generated with rust-bindgen.kn-cuda-eval
: The Cuda executor and planner.kn-runtime
: A wrapper around the other crates to allow selecting between CPU and GPU execution at runtime.kn-python
: An experimental python wrapper around the runtime crate, using PyO3.// Graph operations (using kn-graph)
// Load on onnx file into a graph
let graph = load_graph_from_onnx_path("test.onnx", false)?;
// Optimize the graph
let graph = optimize_graph(&graph, Default::default());
// Render the graph as an svg file
graph_to_svg("test.svg", &graph, false, false)?;
// Build the inputs
let batch_size = 8;
let inputs = [DTensor::F32(Tensor::zeros(IxDyn(&[batch_size, 16])))];
// CPU: (using kn-graph)
// just evaluate the graph
let outputs: Vec<DTensor> = cpu_eval_graph(&graph, batch_size, &inputs);
// GPU: (using kn-cuda-eval)
// build an executor
let device = CudaDevice::new(0).unwrap();
let mut executor = CudaExecutor::new(device, &graph, batch_size);
// run the executor on the inputs
let outputs: &[DTensor] = executor.evaluate(&inputs);
// Runtime device selection: (using kn-runtime)
let device = Device::best();
let mut prepared = device.prepare(graph, batch_size);
let outputs: Vec<DTensor> = prepared.eval( & inputs);
To use the CUDA crates, the appropriate libraries need to be installed on this system; they are not downloaded automatically:
CUDA_PATH
points to the root directory of the install (i.e., CUDA_PATH/bin/
should exist).CUDA_PATH
, you don't need to do anything else. Otherwise, set the environment
variable CUDNN_PATH
to the root directory of the cuDNN installation (i.e., CUDNN_PATH/bin
should exist).The project has been tested with CUDA v12.2
and cuDNN version v8.9.5
. Newer versions might work, but this is not
guaranteed since CUDA sometimes changes the name of or removes certain functions.
The typical pipeline is shown in the first figure below. The second figure shows the results of running this pipeline on a simple NN architecture.
Central is the Graph IR, the intermediate representation for neural network graphs.
The structure is an SSA-style directed acyclic graph, where nodes are values with a shape, data type, and the operation that computes it. These values are abstract; they don't have strides or memory locations yet.
The operations are similar to those of other frameworks but are kept as orthogonal as possible. Some example operations: convolution, matmul, reshape, broadcast, slice, unary, binary, reduce, softmax, ... See the docs for the full list of graph operations.
The graph can be constructed directly in code using the graph builder API, but for convenience, an ONNX loader exists. It can read ONNX files and convert the supported subset of operations into those supported by the IR.
Because the graph IR is much more orthogonal than the ONNX specification, many ONNX operations are decomposed into separate steps, some examples:
To figure out if an ONNX operation is supported, check the branches of the top-level match statement in the visit_node
function in load.rs
. Many common
operations are already implemented, and adding more operations shouldn't be too hard.
For a larger example of a typical graph, see stable_diffusion_piece.svg, a small section taken from the start of the stable diffusion model.
The graph can optionally be optimized by the optimizer. Since the graph is append-only, a new graph is returned.
The optimizations that are currently implemented are:
Finally, the graph needs to be executed. There is a simple CPU executor that just directly runs each operation. No major optimizations are attempted here, except for using BLAS routines for matmuls and im2col for convolutions. It's important that this executor is as simple as possible because it serves as the baseline for unit tests that check the correctness of the GPU executor.
The second (and more useful) way to run these graphs is with the Cuda executor. This involves running the graph through the Cuda Planner, which outputs a predetermined schedule of Cuda operations and allocates the necessary memory buffers. This is split out as a separate step so this expensive planning step only needs to be carried out once per network architecture; the resulting plan can then be reused many times in the executor.
The planner has the following major responsibilities:
This final operator fusion can be significant and save a lot of redundant transfers to and from main memory. The same performance could be achieved by manually writing kernels for each used combination of operations, but the combinatorial explosion and associated maintenance would be huge.
An example generated scalar kernel with some handwritten clarifying comments is shown below:
#include "util.cu"
// constants that got inserted into the template
// this scalar operation happens on a tensor of rank 4, with 7 operands
const int RANK = 4;
const int OPERANDS = 7;
const int STRIDES_DENSE[RANK] = {648, 81, 9, 1};
const int STRIDES[OPERANDS][RANK] = {
// these are full input tensors with normal, dense strides
{648, 81, 9, 1},
{648, 81, 9, 1},
// these values have zero strides for all axes except the channel one,
// so these are probably biases and scaling factors
// that are broadcast across the other axes
{0, 1, 0, 0},
{0, 1, 0, 0},
{0, 1, 0, 0},
{0, 1, 0, 0},
// the output tensor is just another operand
{648, 81, 9, 1}
};
// the template function, the body of which is generated at runtime
__device__ void operation(void *pointers[OPERANDS], int offsets[OPERANDS]) {
// all input operand memory locations are cast to the right type
float *x0 = &((float *) pointers[0])[offsets[0]];
float *x1 = &((float *) pointers[1])[offsets[1]];
float *x2 = &((float *) pointers[2])[offsets[2]];
float *x3 = &((float *) pointers[3])[offsets[3]];
float *x4 = &((float *) pointers[4])[offsets[4]];
float *x5 = &((float *) pointers[5])[offsets[5]];
float *x6 = &((float *) pointers[6])[offsets[6]];
// input operands are loaded
float y0 = *x0;
float y1 = *x1;
// this is probably a residual connection
float y2 = y0 + y1;
// these 4 steps look like they're implementing a batchnorm layer
float y3 = *x2;
float y4 = y2 - y3;
float y5 = *x3;
float y6 = y4 / y5;
float y7 = *x4;
float y8 = y6 * y7;
float y9 = *x5;
float y10 = y8 + y9;
// this implements a relu6 activation function
float y11 = 6;
float y12 = min(y10, y11);
float y13 = (0.0);
float y14 = max(y12, y13);
// finally the output is stored
*x6 = y14;
}
// the kernel main function is the same for all scalar kernels
__global__ void scalar_kernel(
int batch_size,
Array<void *, OPERANDS> pointers
) {
KernelInfo info = kernel_info();
int size = batch_size * STRIDES_DENSE[0];
// the main loop, following https://developer.nvidia.com/blog/cuda-pro-tip-write-flexible-kernels-grid-stride-loops/
for (int flat = info.global_thread_id; flat < size; flat += info.thread_count) {
Array<int, OPERANDS> offsets = flat_index_to_offsets<RANK, OPERANDS>(flat, STRIDES_DENSE, STRIDES);
operation(pointers.data, &offsets[0]);
}
}
See Are We Learning Yet? for a full list of potential alternatives.
Positives:
Negatives
Performance should be about the same as Kyanite for cases where operator fusion does not matter much; all libraries mostly use the same underlying cuDNN and cuBLAS kernels.
While developing this crate, to update the ONNX proto, the prost-build crate is
used. This requires that protoc
is installed and that the PROTOC
environment variable is set to point to the
executable. See their installation instructions (or the error message the build script shows if any) for more details.
To actually update the proto definition, replace kn-graph/proto/onnx.proto3
with the newer version and
run cargo run --bin proto-to-rust
. Then commit both the onnx.proto3
file and the generated onnx.rs
file.