17 个不稳定版本 (6 个破坏性更新)
0.7.3 | 2024年7月13日 |
---|---|
0.7.2 | 2024年3月19日 |
0.7.0 | 2024年2月20日 |
0.5.0 | 2023年10月8日 |
#149 in 科学
370 每月下载量
用于 2 crates
330KB
6.5K SLoC
Kyanite
概述
Kyanite 是一个用 Rust 编写/为 Rust 编写的神经网络推理库。它可以在 CPU 或使用 cuda/cudnn/cublas 的 Nvidia GPU 上运行 ONNX 文件。
它足够通用,可以运行各种类型的网络,已经与以下网络进行了测试:
- 简单的全连接网络
- 基于 ResNet 的 CNN
- 大型语言模型,如 LLaMA
- 图像生成模型,如 Stable Diffusion。有关演示,请参阅
stable_diffusion
示例,位于kn-runtime
crate 中。
该框架由以下 crates 组成:
kn-graph
: 核心crate,包含中间表示和 CPU 执行器。kn-cuda-sys
: 使用 rust-bindgen 生成的 Cuda FFI 绑定。kn-cuda-eval
: Cuda 执行器和规划器。kn-runtime
: 包装其他 crates,以允许在运行时选择 CPU 和 GPU 执行。kn-python
: 使用 PyO3 的实验性 Python 包装器,围绕运行时 crate。
快速演示
// 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);
系统要求
要使用 CUDA crates,需要在系统上安装适当的库;它们不会自动下载
- CUDA (包括CUDA、cuBLAS、NVRTC): 安装程序,请按照说明操作。确保环境变量
CUDA_PATH
指向安装的根目录(即,CUDA_PATH/bin/
应该存在)。 - cuDNN: 存档文件,需要解压到您选择的路径。如果您选择与
CUDA_PATH
相同的路径,则无需执行其他操作。否则,将环境变量CUDNN_PATH
设置为 cuDNN 安装的根目录(即,CUDNN_PATH/bin
应该存在)。
该项目已在 CUDA v12.2
和 cuDNN 版本 v8.9.5
上进行了测试。较新版本可能也可以工作,但这不能保证,因为 CUDA 有时会更改某些函数的名称或删除它们。
内部结构
下面的第一幅图显示了典型的流程。第二幅图显示了在简单的神经网络架构上运行此流程的结果。
图 IR
核心是 Graph IR,神经网络图的中间表示。
该结构是一个 SSA 风格的有向无环图,其中节点是具有形状、数据类型及其计算操作值的值。这些值是抽象的;它们还没有步长或内存位置。
这些操作与其他框架类似,但尽可能地保持正交。一些示例操作:卷积、矩阵乘法、重塑、广播、切片、一元、二元、减少、softmax 等。有关图形操作完整列表的详细信息,请参阅 文档。
可以使用 图形构建器 API 在代码中直接构建图形,但为了方便起见,存在一个 ONNX 加载器。它可以读取 ONNX 文件并将支持的子集操作转换为 IR 支持的操作。
由于图形 IR 比 ONNX 规范更为正交,因此许多 ONNX 操作被分解为单独的步骤,以下是一些示例
- ONNX 二进制操作隐式广播它们的操作数,但在 IR 中这是一个单独的操作。
- ONNX 卷积和矩阵乘法有一个内置的可选偏置操作数;这也变成了一个单独的广播加二元加法操作。
要确定 ONNX 操作是否受支持,请检查 load.rs
中 visit_node
函数顶级匹配语句的分支。许多常见操作已经实现,添加更多操作应该不会太难。
有关典型图形的更大示例,请参阅 stable_diffusion_piece.svg,这是从稳定扩散模型开始的小部分。
优化器
图形可以由 优化器 优化。由于图形是只写的,因此返回一个新的图形。
当前实现的优化包括
- 常量折叠
- 将连续的仿射(偏置、缩放、批归一化)操作融合为单个偏置+缩放操作。
- 将连续的钳位操作(relu、min、max)融合为单个 min+max 操作。
- 强度降低:用乘以倒数常数替换除以常数。
- 识别layernorm模板(reduce、subtract、power、reduce、divide)并用layernorm运算符替换。
CPU 执行器
最后,需要执行图形。有一个简单的 CPU 执行器,它直接运行每个操作。这里没有尝试进行任何主要优化,除了使用 BLAS 例程进行矩阵乘法和 im2col 进行卷积。重要的是,这个执行器尽可能简单,因为它作为检查 GPU 执行器正确性的单元测试的基准。
Cuda 执行器
运行这些图(更有效的方法)的第二个方式是使用Cuda executor。这涉及到通过Cuda Planner运行图,它输出预定的Cuda操作调度并分配必要的内存缓冲区。这一步被分离出来,以便昂贵的规划步骤只需要在每种网络架构上执行一次;生成的计划可以在executor中多次重用。
规划器有以下主要职责
- 确定张量的内存布局:步幅和内存偏移量
- 这隐式处理了大多数reshape、广播、步幅等操作。
- 如果可能,也会重用缓冲区,以最小化总内存使用。这里有很大的改进空间;目前,这只是一个单遍算法。
- 决定对于卷积和矩阵乘法运行哪些cuDNN/cuBLAS操作。如果可能,操作会被融合在一起。一些例子
- cuDNN支持一个“卷积 + 剩余 + 偏置 + relu”操作
- cuBLAS矩阵乘法可以包括输入矩阵的转置,以及通过交换输入等价于输出。
- cuDNN和cuBLAS操作有时包括一个“标量”参数,它会乘以一些操作数
- 使用基于NVRTC(运行时编译)的autokernel框架编译剩余的标量和复合操作的定制内核。
- autokernel处理的操作包括:标量操作、reduce、softmax、layernorm、gather。
- 使用手写的内核模板,在编译前将张量形状、步幅、标量操作等详细信息替换进去。
- 这里发生了更多的操作融合
- 多个标量操作被编译成一个单独的内核
- 常量标量被内联
- 一些复合内核支持融合输入或输出标量操作
这种最终的操作融合可以非常显著,并节省大量从主内存到和从主内存的冗余传输。可以通过为每个操作的组合手动编写内核来达到相同的效果,但组合爆炸和相关维护会非常大。
以下是一个包含一些手写说明性注释的生成标量内核的例子
残差 + 批标准化 + relu6的标量autokernel示例
#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]);
}
}
与其他 Crates 的比较
有关可能的替代方案的全列表,请参阅Are We Learning Yet?
现有运行时周围的 Rust 包装器
- PyTorch包装器:tch
- TensorFlow包装器:tensorflow
- ONNXRuntime包装器:ort
优点
- 广泛支持许多神经网络操作
- 支持许多不同的后端(CPU、GPU(Nvidia + AMD)、TPU等)
缺点
- 对于加载ONNX文件的支持并不总是很好(尽管ort在这方面表现得很棒,正如其名称所暗示的)
- 大型且有些黑盒的外部依赖
- 在许多情况下,操作融合较少,尽管预计未来会得到改善
对于操作融合不是很重要的情况,性能应该与Kyanite大致相同;所有库基本上都使用相同的底层cuDNN和cuBLAS内核。
从头开始的 Rust 项目
- tract:覆盖ONNX规范的范围更广,但仅支持CPU推理
开发
在开发此crate时,为了更新ONNX proto,使用了prost-build crate。这需要安装protoc
,并将PROTOC
环境变量设置为指向可执行文件。有关更多信息,请参阅他们的安装说明(或构建脚本显示的错误消息,如果有)。
要实际更新 proto 定义,将 kn-graph/proto/onnx.proto3
替换为较新版本,并运行 cargo run --bin proto-to-rust
。然后提交 onnx.proto3
文件和生成的 onnx.rs
文件。
依赖项
约 8MB
约 150K SLoC