在之前的文章中已经介绍了Softmax算子开发的整体思路,但笔者只从向量的角度进行了说明,本篇文章就以处理张量的角度来进一步阐述。
张量的内存排布
要使Softmax算子处理张量,首先要了解张量在内存上的排布,我们用numpy
的ndarray
来说明。
首先利用python
来创建一个ndarray
。
1 2 import numpy as np data = np.random.randn(32 , 32 ).astype(np.float32)
然后我们写一个简单的C++
函数(tensor.cpp
),该函数接受一个对应数据类型的指针作为参数,功能就简单的打印输出。
1 2 3 4 5 6 7 8 9 10 #include <iostream> extern "C" void printTensor (float *tensor, int *shape) { for (int i = 0 ; i < shape[0 ]; i++) { for (int j = 0 ; j < shape[1 ]; j++) { std::cout << tensor[i * shape[0 ] + j] << " " ; } std::cout << "\n" ; } }
其中的extern "C"
是必要的,否则调用时会出现undefined symbol
问题。
然后将其编译成一个动态链接库,以供python
调用,命令如下。
1 g++ -shared -fPIC -o tensor.so tensor.cpp
注意:如果代码中使用了C++
的标准库,则编译器要使用g++
而不是gcc
,否则会出现标准库符号找不到的问题,clang
同理。
编译好后在python
中调用该库,数据类型的转换是利用ctypes
来实现的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import numpy as npfrom ctypes import CDLL, POINTER, c_float, c_int data = np.random.randn(32 , 32 ).astype(np.float32) c_int_arr = c_int * len (data.shape) lib = CDLL("./tensor.so" ) printTensor = lib.printTensor printTensor.argtypes = [ POINTER(c_float), POINTER(c_int), ] printTensor( data.ctypes.data_as(POINTER(c_float)), c_int_arr(data.shape[0 ], data.shape[1 ]), )
部分执行结果如下所示。
1 2 3 -1.48456 0 .336725 1 .69846 -0.0248114 0.39322 0 .614784 0 .326595 0 .575949 -0.708058 -1.39587 -1.83477 0 .349339 -0.610898 -0.423076 -0.136989 0 .442269 0 .446412 -0.486558 -0.292987 1.29332 0 .187811 0 .331237 0 .63905 -1.46251 -0.536956 0.495119 -0.429213 -0.988436 -0.414105 -2.26553 1 .23408 -0.544561 0.369648 -1.12966 -0.154628 1 .09682 0 .676383 0 .444374 -0.706796 -0.873308 -1.32488 -0.537758 -1.81611 -2.06588 0 .721618 1 .02888 -0.919128 -0.765203 -0.42332 0 .0602946 1 .16713 0 .140398 -0.534829 -0.0961945 0.0153079 -0.261519 0.0927059 -0.868659 1.27008 -0.379786 0.382002 -1.76778 0 .660476 1 .06135 ...
以上得出结论,numpy
中的张量在内存上就是一个一维数组 ,可以用指针来操作,其他框架的张量同理。
算子逻辑
由于张量在内存上都是一维排布的,所以最内层维度在内存上是连续的。所以对于昇腾芯片来说,Softmax
最适合加速的就是在张量的最后一个维度上进行计算,下面的讨论都基于最后一个维度。对于一个NHWC的张量来说,我们在最后一个维度上进行Softmax
也是实际中最常用的情况。
算子涉及向量自然指数、向量归约求和、向量除法等运算,其中最需要关心的就是向量归约求和,因为它涉及到对齐的问题。
可能的数据情况
主要有以下几种情况:
情况一:数据repeat
对齐
最好解决的就是repeat
对齐的情况,不需要做尾块处理,直接利用vec_cross_add()
归约求和即可。
情况二:数据部分repeat
对齐,部分block
对齐
对于部分repeat
对齐,部分block
对齐的情况,需要分开来处理。对于repeat
对齐的部分同样简单处理,对于block
对齐的部分,无法直接调用vec_cross_add
接口进行归约求和,需要利用标量操作来累加进前面的结果中。
情况三:数据部分repeat
对齐,部分block
对齐,剩余尾块block
不对齐
最后一种情况是最需要注意的情况,而且实际使用中大部分是这种情况。着重关注非block
对齐部分的数据,这部分数据要从搬移的时候就开始做单独处理。因为GM与UB之间的数据搬移最小粒度是一个block
,无法真正做到元素级别的搬移。
对于这种情况的数据搬移,我们考虑一种简化情况,即数据长度大于一个block
但不足两个block
。对于这样的数据,GM与UB之间的搬移需要一个临时空间来辅助。
具体方式是从末尾向前取一个整block
进行搬移,这样不会影响到后续的数据,同时使得group
之间的访存严格隔离开来。
这样处理的时候,由于会有被重复搬移的数据,所以要注意在累加的时候不要重复累加元素。
算子实现
为了避免张量过大,在UB上申请的空间超出限制,这里使group
循环分批处理一个向量。即从GM搬移进UB,处理完后再搬回GM,再搬入下一批数据进行处理,直到所有数据被处理完成。
这样处理有一个好处就是,情况二和情况三的数据只会出现在最后一次迭代中。该算子的处理大体分为三个小模块,求 、归约求和以及向量除法。
在写核心逻辑之前,我们需要为算子准备一系列的常量,方便后面使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 std::size_t total_repeat_count = vec_bytes / REPEAT_SIZE; std::size_t iteration_times = total_repeat_count / MAX_REPEAT_PER_ITERATION + 1 ; std::size_t last_iter_bytes = vec_bytes - (iteration_times - 1 ) * MAX_BYTES_PER_ITERATION; std::size_t elem_count = last_iter_bytes / sizeof (float ); std::size_t block_count = last_iter_bytes / BLOCK_SIZE; std::size_t repeat_count = last_iter_bytes / REPEAT_SIZE; std::size_t align_block_elem_count = block_count * BLOCK_SIZE / sizeof (float ); std::size_t align_repeat_elem_count = repeat_count * REPEAT_SIZE / sizeof (float ); std::size_t tail_elem_count = elem_count - align_block_elem_count; std::size_t tail_bytes = tail_elem_count * sizeof (float ); std::size_t tail_block_elem_count = BLOCK_SIZE / sizeof (float ); std::size_t tail_memcpy_index = dim2 - tail_block_elem_count;
核心逻辑如下,详细说明见注释。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 for (std::size_t i = 0 ; i < iteration_times; i++) { index = group_id * dim2 + i * MAX_BYTES_PER_ITERATION / sizeof (float ); if (i == iteration_times - 1 ) { tail_index = group_id * dim2 + tail_memcpy_index; input_vec.load (sycl::global_ptr <float >(d_tensor + index).get (), align_block_elem_count); if (tail_bytes) { temp.load (sycl::global_ptr <float >(d_tensor + tail_index).get (), tail_block_elem_count); } bisheng::vec_exp (input_vec.to_view (elem_count), input_vec.to_view (elem_count)); bisheng::vec_exp (temp, temp); input_vec.store (sycl::global_ptr <float >(d_tensor + index).get (), align_block_elem_count); if (tail_bytes) { temp.store (sycl::global_ptr <float >(d_tensor + tail_index).get (), tail_block_elem_count); } } else { input_vec.load (sycl::global_ptr <float >(d_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); bisheng::vec_exp (input_vec, input_vec); input_vec.store (sycl::global_ptr <float >(d_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); } }for (std::size_t i = 0 ; i < iteration_times; i++) { index = group_id * dim2 + i * MAX_BYTES_PER_ITERATION / sizeof (float ); if (i == iteration_times - 1 ) { tail_index = group_id * dim2 + tail_memcpy_index; input_vec.load (sycl::global_ptr <float >(d_tensor + index).get (), align_block_elem_count); if (tail_bytes) { temp.load (sycl::global_ptr <float >(d_tensor + tail_index).get (), tail_block_elem_count); } if (align_repeat_elem_count) { bisheng::vec_cross_add (sum_vec.to_view (repeat_count, 0 , 1 ), input_vec.to_view (align_repeat_elem_count)); for (std::size_t j = 0 ; j < repeat_count; j++) { sum += sum_vec[j]; } } for (std::size_t j = align_repeat_elem_count; j < align_block_elem_count; j++) { sum += input_vec[j]; } for (std::size_t j = tail_block_elem_count - tail_elem_count; j < tail_block_elem_count; j++) { sum += temp[j]; } } else { input_vec.load (sycl::global_ptr <float >(d_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); bisheng::vec_cross_add (sum_vec.to_view (MAX_REPEAT_PER_ITERATION, 0 , 1 ), input_vec.to_view (MAX_BYTES_PER_ITERATION / sizeof (float ))); for (std::size_t j = 0 ; j < MAX_BYTES_PER_ITERATION / sizeof (float ); j++) { sum += sum_vec[j]; } } } bisheng::vector<float , BLOCK_SIZE / sizeof (float )> temp_res;bisheng::vector<float , MAX_BYTES_PER_ITERATION / sizeof (float ) > divisor (sum) ;bisheng::vector<float , BLOCK_SIZE / sizeof (float ) > temp_divisor (sum) ;for (std::size_t i = 0 ; i < iteration_times; i++) { index = group_id * dim2 + i * MAX_BYTES_PER_ITERATION / sizeof (float ); if (i == iteration_times - 1 ) { tail_index = group_id * dim2 + tail_memcpy_index; input_vec.load (sycl::global_ptr <float >(d_tensor + index).get (), align_block_elem_count); if (tail_bytes) { temp.load (sycl::global_ptr <float >(d_tensor + tail_index).get (), tail_block_elem_count); } bisheng::vec_div (res_vec.to_view (elem_count), input_vec.to_view (elem_count), divisor.to_view (elem_count)); bisheng::vec_div (temp_res, temp, temp_divisor); res_vec.store (sycl::global_ptr <float >(d_tensor + index).get (), align_block_elem_count); if (tail_bytes) { temp_res.store (sycl::global_ptr <float >(d_tensor + tail_index).get (), tail_block_elem_count); } } else { input_vec.load (sycl::global_ptr <float >(d_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); bisheng::vec_div (res_vec, input_vec, divisor); res_vec.store (sycl::global_ptr <float >(d_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); } }
算子优化
算子逻辑优化
观察上述的核心逻辑,可以观察到几个比较明显的优化点:
计算 和归约求和的过程可以合并,不需要先计算 后搬出,再搬入计算归约和;
最后计算除法的过程,可以用倒数乘法来代替;
可以开启double buffering
。
优化后的核心逻辑如下所示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 for (std::size_t i = 0 ; i < iteration_times; i++) { index = group_id * dim2 + i * MAX_BYTES_PER_ITERATION / sizeof (float ); auto &input_vec = i % 2 ? input_vec_0 : input_vec_1; auto &sum_vec = i % 2 ? sum_vec_0 : sum_vec_1; auto &sum_temp = i % 2 ? sum_temp_0 : sum_temp_1; auto &sum = i % 2 ? sum_0 : sum_1; if (i == iteration_times - 1 ) { tail_index = group_id * dim2 + tail_memcpy_index; if (align_block_elem_count) { input_vec.load (sycl::global_ptr <float >(d_tensor + index).get (), align_block_elem_count); bisheng::vec_exp (input_vec.to_view (elem_count), input_vec.to_view (elem_count)); if (align_repeat_elem_count) { bisheng::vec_cross_add (sum_vec.to_view (repeat_count, 0 , 1 ), input_vec.to_view ()); for (int j = 0 ; j < repeat_count; j++) { sum += sum_vec[j]; } } for (std::size_t j = align_repeat_elem_count; j < align_block_elem_count; j++) { sum += input_vec[j]; } input_vec.store (sycl::global_ptr <float >(d_exp_tensor + index).get (), align_block_elem_count); } if (tail_bytes) { temp.load (sycl::global_ptr <float >(d_tensor + tail_index).get (), tail_block_elem_count); bisheng::vec_exp (temp, temp); for (std::size_t j = tail_block_elem_count - tail_elem_count; j < tail_block_elem_count; j++) { sum += temp[j]; } temp.store (sycl::global_ptr <float >(d_exp_tensor + tail_index).get (), tail_block_elem_count); } } else { input_vec.load (sycl::global_ptr <float >(d_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); bisheng::vec_exp (input_vec, input_vec); bisheng::vec_cross_add (sum_vec.to_view (MAX_REPEAT_PER_ITERATION, 0 , 1 ), input_vec.to_view (MAX_BYTES_PER_ITERATION / sizeof (float ))); bisheng::vec_cross_add (sum_temp.to_view (sum_vec_repeat_count, 0 , 1 ), sum_vec.to_view (MAX_REPEAT_PER_ITERATION)); for (int j = 0 ; j < sum_vec_repeat_count; j++) { sum += sum_temp[j]; } input_vec.store (sycl::global_ptr <float >(d_exp_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); } } sum_0 += sum_1;auto &sum = sum_0; bisheng::vector<float , BLOCK_SIZE / sizeof (float )> temp_res;float divisor = 1 / sum;for (std::size_t i = 0 ; i < iteration_times; i++) { index = group_id * dim2 + i * MAX_BYTES_PER_ITERATION / sizeof (float ); auto &input_vec = i % 2 ? input_vec_0 : input_vec_1; auto &res_vec = i % 2 ? res_vec_0 : res_vec_1; if (i == iteration_times - 1 ) { tail_index = group_id * dim2 + tail_memcpy_index; if (align_block_elem_count) { input_vec.load (sycl::global_ptr <float >(d_exp_tensor + index).get (), align_block_elem_count); bisheng::vec_mul (res_vec.to_view (elem_count), input_vec.to_view (elem_count), divisor); res_vec.store (sycl::global_ptr <float >(d_tensor + index).get (), align_block_elem_count); } if (tail_bytes) { temp.load (sycl::global_ptr <float >(d_exp_tensor + tail_index).get (), tail_block_elem_count); bisheng::vec_mul (temp_res, temp, divisor); temp_res.store (sycl::global_ptr <float >(d_tensor + tail_index).get (), tail_block_elem_count); } } else { input_vec.load (sycl::global_ptr <float >(d_exp_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); bisheng::vec_mul (res_vec, input_vec, divisor); res_vec.store (sycl::global_ptr <float >(d_tensor + index).get (), MAX_BYTES_PER_ITERATION / sizeof (float )); } }
分核方案优化
由于毕昇异构算子中存在一个限制,即group
的数量最大为65535。按照上述的分核方案,最多只能处理65535个向量,显然是不合理的。所以,当向量个数大于65535时,要令每个逻辑核处理多个向量。
1 2 3 4 std::size_t vec_count_per_group = (vec_count + MAX_KERNEL_COUNT - 1 ) / MAX_KERNEL_COUNT; std::size_t group_count = (vec_count + vec_count_per_group - 1 ) / vec_count_per_group;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 for (std::size_t i = 0 ; i < vec_count_per_group; i++) { iteration_begin = group_index + i * dim3; if (iteration_begin >= element_total_count) break ; bisheng::vector<float , MAX_BYTES_PER_ITERATION / sizeof (float )> input_vec_0; bisheng::vector<float , MAX_BYTES_PER_ITERATION / sizeof (float )> input_vec_1; bisheng::vector<float , MAX_REPEAT_PER_ITERATION> sum_vec_0 (0 ) ; bisheng::vector<float , MAX_REPEAT_PER_ITERATION> sum_vec_1 (0 ) ; const std::size_t sum_vec_repeat_count = MAX_REPEAT_PER_ITERATION * sizeof (float ) / REPEAT_SIZE; bisheng::vector<float , sum_vec_repeat_count> sum_temp_0 (0 ) ; bisheng::vector<float , sum_vec_repeat_count> sum_temp_1 (0 ) ; bisheng::vector<float , MAX_BYTES_PER_ITERATION / sizeof (float )> res_vec_0; bisheng::vector<float , MAX_BYTES_PER_ITERATION / sizeof (float )> res_vec_1; bisheng::vector<float , BLOCK_SIZE / sizeof (float )> temp; __local float sum_0 = 0.0f ; __local float sum_1 = 0.0f ; for (std::size_t j = 0 ; j < iteration_times; j++) { ... } sum_0 += sum_1; auto &sum = sum_0; bisheng::vector<float , BLOCK_SIZE / sizeof (float )> temp_res; float divisor = 1 / sum; for (std::size_t j = 0 ; j < iteration_times; j++) { ... } }
功能测试
功能测试采取将算子封装到MindSpore
框架中进行测试,具体方案如下。先将算子代码编译为动态链接库。
1 2 3 4 5 6 clang++ -fsycl -fdevices=ascend_910 \ -I ${ASCEND_TOOLKIT_HOME} /include \ -L ${ASCEND_TOOLKIT_HOME} /lib64 -lascendcl \ -shared -fPIC -o softmax.so \ -mllvm -inline-threshold=9000 -mllvm -enable-explicit-vectorizer -Rpass=ascend-vec \ ./softmax.cpp
然后按照要求封装为MindSpore
可调用的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class SoftmaxBS (Cell ): def __init__ (self ): super (SoftmaxBS, self).__init__() self.bisheng_softmax = ops.Custom( "softmax.so:softmax_npu" , out_shape=lambda x: x, out_dtype=lambda x: x, func_type="aot" , ) self.bisheng_softmax.add_prim_attr("primitive_target" , "Ascend" ) def construct (self, x0 ): output = self.bisheng_softmax(x0) return output
在结果正确性方面,采用了numpy
中的allClose()
函数来对比MindSpore
算子与自定义算子的结果张量,若两者在一定精度范围内接近,则认为计算结果正确。具体判断逻辑如下。
1 2 3 4 5 6 7 8 9 10 context.set_context(mode=ms.PYNATIVE_MODE, device_target="Ascend" ) softmax_bs = SoftmaxBS() softmax = Softmax(axis=-1 ) data = ms.Tensor(np.random.randn(dim0, dim1, dim2, dim3), ms.float16) output_bs = softmax_bs(data) output_ms = softmax(data)if np.allclose(output_bs.asnumpy(), output_ms.asnumpy(), rtol=1e-3 , atol=1e-3 ): print ("correct!" )else : print ("error!" )
经过测试,算子逻辑没有问题,精度由于使用了float16
来计算,所以只设置到了1e-3
。
性能测试
性能测试采用单算子测试的方式。对于MindSpore
中的算子,采用框架自带的Profiler()
来分析算子性能,再通过msprof.py
脚本工具导出算子性能数据的summary
数据,通过读取Task Duration
列来获取算子的执行时间。而对于自定义算子,则采用msprof
命令行工具运行算子,同样通过summary
数据来获取算子执行时间。
1
8x16x1024x1024
half
2658.672
9720.826
0.273503
2
16x16x1024x1024
half
5274.796
18483.56
0.285378
3
16x16x1024x2048
half
10550.81
21184.74
0.498038
4
16x16x1024x4096
half
22255.22
26612.28
0.836276
5
4x4x512x8192
half
1221.49
1350.776
0.904288
6
4x4x512x16384
half
2438.494
1337.628
1.822999
7
4x4x512x32768
half
4869.044
2412.854
2.01796
8
4x4x512x65535
half
6671.878
6200.692
1.075989
9
4x4x512x131072
half
19467.07
8748.974
2.225069
10
4x4x512x8193
half
4984.124
1564.236
3.186299
11
4x4x512x16385
half
1354.314
1555.814
0.870486
12
4x4x512x32769
half
4288.27
2667.928
1.607341
13
4x4x512x65536
half
9732.024
4516.888
2.154586
14
4x4x512x131073
half
79671.69
9390.606
8.484191
经过一系列的性能测试,发现在小数据量的情况下,性能始终无法与TBE算子相比。推测可能的原因是,TBE算子针对某些静态形状有优化,但本算子针对的是动态形状场景,所以性能较差。但当数据量变大,充分发挥设备并行能力的情况下,性能有所好转。在其最擅长的形状上,加速比可以达到2左右,在用例14这种情况下,加速比甚至达到了8以上。