65.9K
CodeProject 正在变化。 阅读更多。
Home

Neon Intrinsics:Android 入门

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2020年5月5日

CPOL

9分钟阅读

viewsIcon

18800

在本文中,我们将学习如何为原生 C++ 开发设置 Android Studio,并为 Arm 驱动的移动设备利用 Neon Intrinsics。

不要重复你自己 (DRY) 是软件开发的主要原则之一,遵循此原则通常意味着通过函数重用代码。不幸的是,调用函数会产生额外的开销。为了减少这种开销,编译器会利用称为 Intrinsics 的内置函数,编译器会将高级编程语言 (C/C++) 中使用的 Intrinsics 替换为几乎一对一映射的汇编指令。为了进一步提高性能,您可以进入汇编代码的领域,但可以使用 Arm Neon Intrinsics。您通常可以避免编写汇编函数的复杂性。取而,您只需用高级语言编程并调用 arm_neon.h 头文件中声明的 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;
}

接下来,我们实现 msElapsedTimenow 方法,这些方法稍后将用于确定执行时间

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 图形、信号和图像处理、音频编码和视频流等场景的性能,仅举几例。

参考资料和有用链接

© . All rights reserved.