Neon Intrinsics:Android 入门





0/5 (0投票)
在本文中,我们将学习如何为原生 C++ 开发设置 Android Studio,并为 Arm 驱动的移动设备利用 Neon Intrinsics。
不要重复你自己 (DRY) 是软件开发的主要原则之一,遵循此原则通常意味着通过函数重用代码。不幸的是,调用函数会产生额外的开销。为了减少这种开销,编译器会利用称为 Intrinsics 的内置函数,编译器会将高级编程语言 (C/C++) 中使用的 Intrinsics 替换为几乎一对一映射的汇编指令。为了进一步提高性能,您可以进入汇编代码的领域,但可以使用 Arm Neon Intrinsics。您通常可以避免编写汇编函数的复杂性。取而
作为一名 Android 开发者,您可能没有时间编写汇编语言。相反,您的重点是应用程序的可用性、可移植性、设计、数据访问以及针对各种设备的应用程序调优。如果是这样,Neon Intrinsics 将在性能方面提供极大的帮助。
Arm Neon Intrinsics 技术是 Arm 处理器的一种先进的 单指令多数据 (SIMD) 架构扩展。SIMD 的理念是在单个 CPU 周期内对数据序列或向量执行相同的操作。
例如,如果您要对两个一维数组中的数字进行求和,则需要逐个相加。在非 SIMD CPU 中,每个数组元素都从内存加载到 CPU 寄存器,然后相加寄存器值并将结果存储到内存中。此过程对所有元素重复。为了加快此类操作的速度,支持 SIMD 的 CPU 会一次加载多个元素,执行操作,然后将结果存储到内存中。性能将根据序列长度 N 提高。理论上,计算时间将减少 N 倍。
通过利用 SIMD 架构,Neon Intrinsics 可以加速多媒体和信号处理应用程序的性能,包括视频和音频编码解码、3D 图形以及语音和图像处理。Neon Intrinsics 提供的控制程度几乎与编写汇编代码一样,但它们将寄存器分配留给编译器,因此开发人员可以专注于算法。因此,Neon Intrinsics 在性能提升和汇编语言编写之间取得了平衡。
首先,我将向您展示如何设置您的 Android 开发环境以使用 Neon Intrinsics。然后,我们将实现一个使用 Android Native Development Kit (NDK) 计算两个向量点积的 Android 应用程序。最后,我们将了解如何使用 NEON Intrinsics 改进此类函数的性能。
我使用 Android Studio 创建了示例项目。样本代码可从 GitHub 存储库 NeonIntrinsics-Android 获取。我使用三星 SM-J710F 手机测试了该代码。
原生 C++ Android 项目模板
我通过创建原生 C++ 项目模板开始了一个新项目。
然后,我将应用程序名称设置为 Neon Intrinsics,选择 Java 作为语言,并将最低 SDK 设置为 API 19:Android 4.4 (KitKat)。
然后,我为 C++ 标准选择了 Toolchain Default。
我创建的项目包含一个在 MainActivity
类中实现的 Activity,该类继承自 AppCompatActivity
(参见 app/java/com.example.neonintrinsics/MainActivity.java)。关联的视图仅包含一个 TextView
控件,用于显示“Hello from C++”字符串。
要获得这些结果,您可以使用模拟器直接从 Android Studio 运行项目。要成功构建项目,您需要安装 CMake 和 Android NDK。您可以通过设置(文件 | 设置)来完成此操作。然后,在 SDK 工具选项卡上选择 NDK 和 CMake。
如果打开 MainActivity.java 文件,您会注意到应用程序中显示的字符串来自 native-lib
。此库的代码位于 app/cpp/native-lib.cpp 文件中。我们将使用该文件进行实现。
启用 Neon Intrinsics 支持
要启用对 Neon Intrinsics 的支持,您需要修改 ABI 过滤器,以便应用程序可以为 Arm 架构构建。Neon 有两个版本:一个用于 Armv7、Armv8 AArch32,一个用于 Armv8 AArch64。从 Intrinsics 的角度来看,存在一些差异,例如 Armv8-A 中添加了 2xfloat64 向量。它们都包含在编译器安装路径中的 arm_neon.h 头文件中。您还需要导入 Neon 库。
转到 Gradle 脚本,然后打开 build.gradle
(Module: app) 文件。然后,通过添加以下语句来补充 defaultConfig
部分。首先,将此行添加到常规设置中
ndk.abiFilters 'x86', 'armeabi-v7a', 'arm64-v8a'
在这里,我添加了对 x86、32 位和 64 位 ARM 架构的支持。然后将此行添加到 cmake 选项下
arguments "-DANDROID_ARM_NEON=ON"
它应该看起来像这样
defaultConfig { applicationId "com.example.myapplication" minSdkVersion 16 targetSdkVersion 29 versionCode 1 versionName "1.0" ndk.abiFilters 'x86', 'armeabi-v7a', 'arm64-v8a' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { cmake { cppFlags "" arguments "-DANDROID_ARM_NEON=ON" } } }
现在您可以使用 arm_neon.h 头文件中声明的 Neon Intrinsics。请注意,构建仅对 ARM-v7 及更高版本成功。为了使您的代码与 x86 兼容,您可以使用 Intel 移植指南。
点积和辅助方法
我们现在可以使用 C++ 实现两个向量的点积。所有代码都应放在 native-lib.cpp 文件中。请注意,从 armv8.4a 开始,DotProduct 是新指令集的一部分。这对应于一些 Cortex A75 设计以及所有 Cortex A76 设计及更高版本。有关更多信息,请参阅 探索 Arm 点积指令。
我们从生成斜坡的辅助方法开始,斜坡是从 startValue
递增的 16 位整数向量
short* generateRamp(short startValue, short len) { short* ramp = new short[len]; for(short i = 0; i < len; i++) { ramp[i] = startValue + i; } return ramp; }
接下来,我们实现 msElapsedTime
和 now
方法,这些方法稍后将用于确定执行时间
double msElapsedTime(chrono::system_clock::time_point start) { auto end = chrono::system_clock::now(); return chrono::duration_cast<chrono::milliseconds>(end - start).count(); } chrono::system_clock::time_point now() { return chrono::system_clock::now(); }
msElapsedTime
方法计算自给定起始点以来的持续时间(以毫秒为单位)。
now
方法是 std::chrono::system_clock::now
方法的一个便捷包装器,它返回当前时间。
现在创建实际的 dotProduct
方法。您从编程课上应该还记得,要计算两个等长向量的点积,您需要逐个元素地相乘向量,然后累加产生的乘积。此算法的直接实现如下
int dotProduct(short* vector1, short* vector2, short len) { int result = 0; for(short i = 0; i < len; i++) { result += vector1[i] * vector2[i]; } return result; }
上述实现使用了 for 循环。因此,我们按顺序相乘向量元素,然后将产生的乘积累加到名为 result 的局部变量中。
使用 Neon Intrinsics 计算点积
要修改 dotProduct
函数以受益于 Neon Intrinsics,您需要拆分 for 循环,使其能够利用数据通道。为此,请将循环分区或向量化,以便在单个 CPU 周期内处理数据序列。这些序列定义为 向量。但是,为了区分它们与我们用作点积输入的向量,我将这些序列称为 寄存器向量。
使用寄存器向量,您可以减少循环迭代次数,使得每次迭代时,您都可以对多个向量元素进行乘法和累加,以计算点积。您可以处理的元素数量取决于寄存器布局。
Arm Neon 架构使用 64 位或 128 位寄存器文件(更多信息请点击此处)。在 64 位情况下,您可以处理八个 8 位、四个 16 位或两个 32 位元素。在 128 位情况下,您可以处理十六个 8 位、八个 16 位、四个 32 位或两个 64 位元素。
为了表示各种寄存器向量,Neon Intrinsics 使用以下命名约定
<type><size>x<number of lanes>_t
<type>
是数据类型(int, uint, float 或 poly)。<size>
是用于数据类型的位数(8, 16, 32, 64)。<number of lanes>
定义了通道数。
例如,int16x4_t
将表示一个具有 4 个 16 位整数元素的通道的向量寄存器,这等效于一个四元素 int16 一维数组(short[4]
)。
您不会直接实例化 Neon Intrinsics 类型。相反,您可以使用专用方法将数据从数组加载到 CPU 寄存器。这些方法的名称以 vld
开头。请注意,方法命名使用类似于类型命名的约定。所有方法都以 v
开头,后跟一个方法简称(如 ld
表示加载),以及一个字母和位数的组合(例如,s16
)来指定输入数据类型。
Neon Intrinsics 直接对应于汇编指令。
int dotProductNeon(short* vector1, short* vector2, short len) { const short transferSize = 4; short segments = len / transferSize; // 4-element vector of zeros int32x4_t partialSumsNeon = vdupq_n_s32(0); // Main loop (note that loop index goes through segments) for(short i = 0; i < segments; i++) { // Load vector elements to registers short offset = i * transferSize; int16x4_t vector1Neon = vld1_s16(vector1 + offset); int16x4_t vector2Neon = vld1_s16(vector2 + offset); // Multiply and accumulate: partialSumsNeon += vector1Neon * vector2Neon partialSumsNeon = vmlal_s16(partialSumsNeon, vector1Neon, vector2Neon); } // Store partial sums int partialSums[transferSize]; vst1q_s32(partialSums, partialSumsNeon); // Sum up partial sums int result = 0; for(short i = 0; i < transferSize; i++) { result += partialSums[i]; } return result; }
在这里,我使用 vld1_s16
方法从内存加载数据。此方法将数组中的四个短整型(有符号 16 位整数或 s16
表示短整型)元素加载到 CPU 寄存器。
一旦元素进入 CPU 寄存器,我就会使用 vmlal
(乘法并累加)方法将它们相加。此方法将两个数组的元素相加,并将结果累加到第三个数组中。
在这里,此数组存储在 partialSumsNeon
变量中。为了初始化此变量,我使用了 vdupq_n_s32
(复制)方法,该方法将所有 CPU 寄存器设置为特定值。在这种情况下,值为 0。这是编写 int sum = 0
的向量化等价物。
一旦所有循环迭代完成,您就需要将结果总和写回内存。您可以使用 vget_lane
方法逐个元素地读取结果,或者使用 vst
方法存储整个向量。我选择了第二种选项。
一旦部分总和返回到内存,我就将它们相加以获得最终结果。
请注意,在 AArch64 上,您也可以使用
return vaddv_s32 (partialSumsNeon);
然后跳过第二个 for 循环。
整合
我们现在可以将所有代码放在一起。为此,我们将修改 MainActivity.stringFromJNI
方法。
extern "C" JNIEXPORT jstring JNICALL MainActivity.stringFromJNI ( JNIEnv* env, jobject /* this */) { // Ramp length and number of trials const int rampLength = 1024; const int trials = 10000; // Generate two input vectors // (0, 1, ..., rampLength - 1) // (100, 101, ..., 100 + rampLength-1) auto ramp1 = generateRamp(0, rampLength); auto ramp2 = generateRamp(100, rampLength); // Without NEON intrinsics // Invoke dotProduct and measure performance int lastResult = 0; auto start = now(); for(int i = 0; i < trials; i++) { lastResult = dotProduct(ramp1, ramp2, rampLength); } auto elapsedTime = msElapsedTime(start); // With NEON intrinsics // Invoke dotProductNeon and measure performance int lastResultNeon = 0; start = now(); for(int i = 0; i < trials; i++) { lastResultNeon = dotProductNeon(ramp1, ramp2, rampLength); } auto elapsedTimeNeon = msElapsedTime(start); // Clean up delete ramp1, ramp2; // Display results std::string resultsString = "----==== NO NEON ====----\nResult: " + to_string(lastResult) + "\nElapsed time: " + to_string((int)elapsedTime) + " ms" + "\n\n----==== NEON ====----\n" + "Result: " + to_string(lastResultNeon) + "\nElapsed time: " + to_string((int)elapsedTimeNeon) + " ms"; return env->NewStringUTF(resultsString.c_str()); }
MainActivity.stringFromJNI
方法按以下步骤进行。
首先,我们使用 generateRamp
方法创建两个等长向量。
接下来,我们使用非 Neon 方法 dotProduct
计算这些向量的点积。我们重复此计算多次(trials 常量),并使用 msElasedTime
测量计算时间。
然后,我们执行相同的操作,但现在使用启用了 Neon 的方法 dotProductNeon
。
最后,我们将这两个方法的结果以及计算时间合并到 resultsString
中。后者将在 TextView
中显示。请注意,要成功构建和运行上述代码,您需要一个 Arm-v7-A/Armv8-A 设备。
仅通过使用内置 Intrinsics 就实现了 7% 的性能提升。在 Arm 64 设备上可以实现 25% 的理论提升。
总结
在本文中,我们学习了如何为原生 C++ 开发设置 Android Studio,并为 Arm 驱动的移动设备利用 Neon Intrinsics。
在解释了 Neon Intrinsics 的原理之后,我们演示了两个等长向量点积的示例实现。然后,我们使用专用的 Neon Intrinsics 对该方法进行了向量化。通过这样做,我们展示了在使用 Neon Intrinsics 时需要采取的关键步骤,特别是将数据从内存加载到 CPU 寄存器,完成操作,然后将结果写回内存。
向量化代码从来都不是一件容易的事。但是,您可以使用 Neon Intrinsics 来简化它,以提高 3D 图形、信号和图像处理、音频编码和视频流等场景的性能,仅举几例。