最近在AR开发的时候, 使用需要将camera的预览流传递到算法(c++), 算法要求的图像结构是RGB24排列的图像。 由于Unity侧获取的图像还是xy倒置的, 一开始的处理获取webcameratexture之后, 通过blit 一个材质, 材质在uv采样的时候进行翻转, 并离线输出到一张RGB24的RT上。 但由于每一帧都存在着一次这样额外的CPU和GPU切换, 在P30Pro的这样的机器上耗时大概18ms, 太长了。 于是就考虑在CPU上使用Neon直接处理webcamera的图像, 最终在android里使用neon指令, 整个过程降低到6ms, 整体时间减少了2/3(tip: CPU的算力在某些方面终于强过GPU了)。

说到Neon, 就不得不提到 SIMD. 通常我们进行多媒体处理的时候,很多的数据都是16位或者8位的,如果这些程序运行在32位的机器上,那么计算机有一部分的计算单元是没有工作的, 所以这是一种浪费。 为了更好的使用那些被浪费的资源, SIMD就应运而生了。 SIMD这种技术就是使用一条指令,但对多个相同类型和尺寸的数据进行并行处理,就像我们现实生活中的好几个人都在做同一件事情那样,这样就可以将速度提升很多倍。

Neon是适用于ARM Cortex-A系列处理器的一种128位SIMD(Single Instruction, Multiple Data,单指令、多数据)扩展结构。NEON技术与Cortex-A8和Cortex-A9处理器相结合,已经被许多领先企业广泛采用。越来越多的机构正在IP设计中采用NEON技术,或提供为NEON技术优化的软件,构成了NEON生态系统的一部分。NEON 技术可加速多媒体和信号处理算法(如视频编码/解码、2D/3D 图形、游戏、音频和语音处理、图像处理技术、电话和声音合成),其性能至少为 ARMv5 性能的3倍,为ARMv6 SIMD性能的2倍。

Neon为什么速度快, 原因有以下几条:

  • Neon有32个128bit寄存器,能够输入多行的数据同时操作
  • CPU运算比加载数据快,速度瓶颈在加载数据这里。可以操作多种数据类型,包括浮点数
  • 基于位和字节向量操作,避免了拆解字节的耗费
  • Neon基于SIMD,一条指令操作多个数据,对多个数据项同时执行相同的操作。这些数据项在较大的寄存器中打包为单独的通道

1. Burst

Burst是一个编译器,它使用LLVM将IL/.NET字节码转换为高度优化的本机代码。它作为Unity包发布,并使用Unity Package Manager集成到Unity中。它既能与Unity DOTS一起生成高优化代码,又能作为单独的功能使用。最近发布的新版本 Unity Burst 1.5 重点添加了多条 Neon intrinsics 指令。Neon intrinsics 指令支持精确设定矢量命令,为 Arm CPU 的处理进程生成最为高效代码。新指令的效果立竿见影,其中一项优化甚至能让 Burst 代码比结构精巧的非 Burst 代码快 6 倍,让手动编写的 Neon 代码快 10 倍。Neon 指令集传统上只适用于 C/C++ 语言,而 Unity 目前已成功将其移植到了 C# 中。

Burst主要用于与Job系统高效协作, 通过使用属性[BurstCompile]装饰Job结构,从而在代码中简单地使用burst编译器

using Unity.Burst;
using Unity.Jobs;
using UnityEngine;

public class MyBurst2Behavior : MonoBehaviour
{
    void Start()
    {
        var input = new NativeArray<float>(10, Allocator.Persistent);
        var output = new NativeArray<float>(1, Allocator.Persistent);
        for (int i = 0; i < input.Length; i++)
            input[i] = 1.0f * i;
        var job = new MyJob
        {
            Input = input,
            Output = output
        };
        job.Schedule().Complete();
        Debug.Log("The result of the sum is: " + output[0]);
        input.Dispose();
        output.Dispose();
    }
    // Using BurstCompile to compile a Job with burst
    // Set CompileSynchronously to true to make sure that the method will not be compiled asynchronously
    // but on the first schedule
    [BurstCompile(CompileSynchronously = true)]
    private struct MyJob : IJob
    {
        [ReadOnly]
        public NativeArray<float> Input;
        [WriteOnly]
        public NativeArray<float> Output;
        public void Execute()
        {
            float result = 0.0f;
            for (int i = 0; i < Input.Length; i++)
            {
                result += Input[i];
            }
            Output[0] = result;
        }
    }
}

默认情况下,在编辑器中,Burst JIT是通过异步来编译job,但在上面的示例中,我们使用该选项CompileSynchronously = true确保在第一个Schedule中编译该方法。通常,您应该使用异步编译。见[BurstCompile]选项

从“Jobs”菜单中,您可以打开Burst 属性面板。属性面板允许您查看可以编译的所有作业,然后您还可以检查生成的本机代码

在左侧窗格中,我们有Compile Targets,它提供了一个可以编译的Jobs列表。以白色突出显示的作业可以通过Burst 编译,而禁用的作业则不具有该[BurstCompile]属性。
1.从左窗格中选择一个活动的编译目标。
2.在右窗格中,按“ 刷新反汇编 ”按钮
3.在不同选项卡之间切换以显示详细信息:

  • 选项卡程序集(Assembly)提供了由burst生成的最终优化本机代码
  • 选项卡 .NET IL 提供了从Job方法中提取的原始.NET IL的视图
  • 选项卡LLVM(未优化)在优化之前提供内部LLVM IR的视图。
  • 选项卡 LLVM(优化)在优化后提供内部LLVM IR的视图。
  • 选项卡LLVM IR Optimization Diagnostics提供优化的详细LLVM诊断(即,如果它们成功或失败)。

切换不同的选项:

如果启用“Safety Checks”将生成包括容器访问安全检查(如检查是否有作业写入本地容器是只读)的代码。

如果启用“Optimizations ”此选项将允许编译器优化代码。

如果启用了“ Fast Math”选项,则编译器可以折叠数学运算以提高效率,但代价是不考虑精确的数学正确性(请参阅编译器放宽选项)。

C#/.NET语言支持

Burst正在研究.NET的一个子集,它不允许在代码中使用任何托管对象/引用类型(C#中的类)。
以下部分提供了更多有关burst实际支持的构造类型详细信息。

支持的.NET类型

原始类型

Burst支持以下原始类型:

bool
char
sbyte/byte
short/ushort
int/uint
long/ulong
float
double

Burst不支持以下类型:

string //因为这是一种托管类型
decimal

矢量类型

Burst能够将矢量类型从Unity.Mathematics原生SIMD矢量类型转换为优化的第一类支持:

bool2/bool3/bool4
uint2/uint3/uint4
int2/int3/int4
float2/float3/float4

请注意,出于性能原因,应首选4种wide 类型(float4,int4…)

枚举类型

Burst支持所有枚举,包括具有特定存储类型的枚举(例如public enum MyEnum : short)
Burst目前不支持Enum方法(例如Enum.HasFlag)

结构类型

Burst支持具有支持类型的任何字段的常规结构。
Burst支持固定数组字段。

关于布局,LayoutKind.Sequential和LayoutKind.Explicit都受到支持,该StructLayout.Pack包装尺寸不支持
本机支持System.IntPtr和UIntPtr作为直接表示指针的内部结构。

指针类型

Burst支持任何Burst支持类型的指针类型

通用类型

Burst支持结构使用的泛型类型。具体来说,它支持对具有接口约束的泛型类型的泛型调用的完全实例化(例如,当具有通用参数的结构需要实现接口时)

数组类型

Burst不支持托管阵列。例如,您应该使用本机容器NativeArray

Burst缺陷

经过一番使用, 发现Burst缺少太多的指令集, 比如说图像处理的所有交错加载多个128位寄存器的指令都没有, 只有一些 vld 或者 vld1q, 也就是一次只能操纵一个寄存器, 这太不友好了, 大大降低了neon的使用场景。

// 连续交错加载3个寄存器 int32_t
vld3q_s32

// 连续交错加载3个寄存器 uint32_t
vld3q_u32

// 连续交错加载3个寄存器 float
vdl3q_f32

// 连续交错加载4个寄存器 uint8
vld4q_u8

// 连续交错加载4个寄存器 int32_t
vld4q_s32

// 连续交错加载4个寄存器 float
vld4q_f32

// 连续交错加载2个寄存器 float
vld2q_f32

// 连续交错加载2个寄存器 int32_t
vld2q_s32

// 连续交错加载2个寄存器 uint8
vld2q_u8

这些指令在图像处理,特别是分离RGBA通道单独计算非常重要, 却官方却没有实现, 这在平时运算的时候, 很多算法需要绕来绕去, 还不一定绕的过去。

还有一些指令虽然只有声明, 却没有实现, 直接抛出一个异常:

比如说

作者使用的Unity版本是2020, 默认使用的1.6.6版本, 却没有基础的存储指令, 升级了1.7.x之后, 才发现其中的实现。

// 所有的 vst 存储都没有实现
vst3q_u8

vst3q_s32

vst1q_u8

vst1q_s32

vst1q_f32

vst4q_s32

vst4q_u8

Burst里的数据结构也比较有限, 其使用了 v64, v128, v256代表了本来在c里面使用的 uint8x8, int32x2, uint8x4x2 之类的数据结构, 由于其没有实现 vld4q_u8 这样的指令, 所以也不会有类似的 uint8x16x4_t 这样数据, 在burst的 NEON_AArch64.cs 这个类中 没有一个方法有 v256 (等价于 uint8x16x4)的参数或者返回值。

总体来说, Burst的 Neon intrinsics 还是一个比较低阶的实现, 绝大多数指令都没有相关的实现, 如果真的使用 Burst来写复杂的算法, 实现的实现会出现各种掣肘,估计unity跨平台的原因<具体已不可考> 希望在之后的Burst的版本能快速的完善。 Unity之所以能做到支持Neon, 本质原因还是因为c#是支持JIT( Just-in-Time ), c# 代码不是一步到位编译成机器码。 在c#里使用的好处就是 Unity帮处理了多个平台适配的情况, 具体有:X86_SSE2, X86_SSE3, X86_SSE4, AVX, AVX2, WASM32, ARMV7A_NEON32, ARMV8A_64, THUMB2_NEON32, ARMV8A_AARCH64_HALFFP等。

NDK

由于Burst里的neon 是残缺版本的, 于是就想到直接在c++(Android NDK)里直接使用neon, 经过使用发现这个比在c# 好用多了, intrinsics 支持的也很完整。 实现完整个算法, 打包生成一个so, 然后集成到Unity的Plugins目录里, 就可以直接调用了。

包含把相关的头文件引用到项目, 这里建议直接在Android Studio里开发, 而不是使用visual studio开发标准的c++程序, 否则的话, 就不会找到相关的库:

#include <arm_neon.h>

这里处理 RGBA32 -> RGB24, 该函数的实现用到了两个NEON Intrinsics,分别是vld4q_u8和vst3q_u8

void hw_rgba2rgb_with_neon(const uint8_t *rgba_img, uint8_t *rgb_img,
                    int32_t height, int32_t width) {
    const int total_pixels = height * width;
    const int stride_pixels = 16;

    for (int i = 0; i < total_pixels; i += stride_pixels) {
        const uint8_t *src = rgba_img + i * SRC_CHANNELS;
        uint8_t *dst = rgb_img + i * DST_CHANNELS;

        uint8x16x4_t a = vld4q_u8(src);
        uint8x16x3_t b;
        b.val[0] = a.val[0];
        b.val[1] = a.val[1];
        b.val[2] = a.val[2];
        vst3q_u8(dst, b);
    }
}

vld4q_u8的函数原型为uint8x16x4_t vld4q_u8 (const uint8_t * __a),作用为以步长为 4 交叉地加载数据到四个连续的 128-bit 的向量寄存器。

s具体地:将内存地址__a、__a+4、…、__a+60处的内容分别赋值给向量寄存器Vn的lane[0]、lane[1]、…、lane[15],将内存地址__a+1、__a+5、…、__a+61处的内容分别赋值给向量寄存器Vn+1的lane[0]、lane[1]、…、lane[15],将内存地址__a+2、__a+6、…、__a+62处的内容分别赋值给向量寄存器Vn+2的lane[0]、lane[1]、…、lane[15],将内存地址__a+3、__a+7、…、__a+63处的内容分别赋值给向量寄存器Vn+3的lane[0]、lane[1]、…、lane[15]。也就是说,vld4q_u8在上述函数中的作用为:将连续 16 个像素的 R 通道: R0、R1、… 、R15 加载到向量寄存器Vn,将连续 16 个像素的 G 通道: G0、G1、… 、G15 加载到向量寄存器Vn+1,将连续 16 个像素的 B 通道: B0、B1、… 、B15 加载到向量寄存器Vn+2,将连续 16 个像素的 Alpha 通道: A0、A1、… 、A15 加载到向量寄存器Vn+3。

vst3q_u8的函数原型为void vst3q_u8 (uint8_t * __a, uint8x16x3_t val),作用为以步长为 3 交叉地存储数据到内存中。

由于每一次迭代可以同时处理 16 个连续的像素。所以,变量stride_pixels的值为 16。

内联汇编

优点:在C代码中嵌入汇编,调用简单,无需手动存储寄存器;

缺点:有较为复杂的格式需要事先学习,不好移植到其他语言环境。

//add for int array. assumed that count is multiple of 4  
#include<arm_neon.h>  
// C version void add_int_c(int* dst, int* src1, int* src2, int count)
{
  int i;
  for (i = 0; i < count; i++)
    dst[i] = src1[i] + src2[i];
  }
}
 
// NEON version void add_float_neon1(int* dst, int* src1, int* src2, int count)
{
  int i;
  for (i = 0; i < count; i += 4)
  {
    int32x4_t in1, in2, out;
    in1 = vld1q_s32(src1);
    src1 += 4;
    in2 = vld1q_s32(src2);
    src2 += 4;
    out = vaddq_s32(in1, in2);
    vst1q_s32(dst, out);
    dst += 4;
  }
}

比如上述intrinsics代码产生的汇编代码为:

// ARMv7-A/AArch32 
void add_float_neon2(int* dst, int* src1, int* src2, int count)
{
  asm volatile (
    "1: \n"
    "vld1.32 {q0}, [%[src1]]! \n"
    "vld1.32 {q1}, [%[src2]]! \n"
    "vadd.f32 q0, q0, q1 \n"
    "subs %[count], %[count], #4 \n"
    "vst1.32 {q0}, [%[dst]]! \n"
    "bgt 1b \n"
    : [dst] "+r" (dst)
    : [src1] "r" (src1), [src2] "r" (src2), [count] "r" (count)
    : "memory", "q0", "q1"
  );
}

运行时查看当前设备是否支持neon, 可以通过cpu-features里获取cpu信息,主要是获取cpu类型和相关的标识位:

#include <cpu-features.h>

bool isSupportNeon()
{
    auto family = android_getCpuFamily();
    return (family == ANDROID_CPU_FAMILY_ARM  || family == ANDROID_CPU_FAMILY_ARM64) &&
        (android_getCpuFeatures() & ANDROID_CPU_ARM_FEATURE_NEON) != 0;
}

为了使 cpu-features 生效, CMakeLists.txt需要做如下调整:

add_library( # Sets the name of the library.
             cam-turbo
 
             # Sets the library as a shared library.
             SHARED
 
            ${ANDROID_NDK}/sources/android/cpufeatures/cpu-features.c
            img_neon.cpp
            native-lib.cpp )
 
target_include_directories(cam-turbo PRIVATE ${ANDROID_NDK}/sources/android/cpufeatures)

编译

编译的时候, 为模块(库)启用 NEON, 如果使用cmakelist编译, 可以这样配置:

set_target_properties(${TARGET} PROPERTIES COMPILE_FLAGS -mfpu=neon)

NEON 在 ndk-build 模块中构建所有源文件,请将以下内容添加到 Android.mk 的模块定义中:

LOCAL_ARM_NEON := true

除此之外, 还支持为某些cpp文件单独支持NEON, 例如cmakelist可以这样配置, 下面的foo.cpp就支持neon了:

set_source_files_properties(foo.cpp PROPERTIES COMPILE_FLAGS -mfpu=neon)

mdk-build 使用ANdroid.mk 方式如下:

LOCAL_SRC_FILES := foo.c.neon bar.c

为 LOCAL_SRC_FILES 变量列出源文件时,可以选择使用 .neon 后缀表示要构建支持 Neon 的单个文件。例如,以下示例会构建一个支持 Neon 的文件 (foo.c),以及另一个不支持 Neon 的文件 (bar.c), 您可结合使用 .neon 后缀与 .arm 后缀,后者指定用于非 Neon 指令的 32 位 ARM 指令集(而非 Thumb2)。在这种情况下,.arm 必须在 .neon 之前。例如:foo.c.arm.neon 可行,但 foo.c.neon.arm 不可行。

参考文献: