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

Android 上的 Neon Intrinsics:如何截断 1D 信号的阈值处理和卷积

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2020 年 6 月 8 日

CPOL

9分钟阅读

viewsIcon

17915

在本文中,我将向您展示一种易于使用的方法,可用于编写高效代码,这些代码可用于信号和图像处理、神经网络或游戏应用程序。

单指令多数据 (SIMD) 架构允许您并行执行代码。SIMD 允许您在一条指令的过程中对整个数据序列或矢量执行相同的操作。因此,您可以使用 SIMD 显著提高 Android 移动或物联网应用程序的性能。

为了使开发人员能够轻松访问 SIMD 优化,处理器制造商提供了专用的开发人员工具。例如,Arm Neon 内联函数是内置函数,您可以从高级代码中访问它们。编译器将这些函数替换为几乎 1 对 1 映射的汇编指令。因此,正如我将在本文中演示的那样,只需稍作修改,您就可以轻松地将代码矢量化。

入门

在我最近的文章中,我展示了如何设置 Android Studio 以将 Arm Neon 内联函数用于 Android 应用程序。您从需要 Android 本机开发工具包 (NDK) 的本机 C++ 项目模板开始。该项目模板创建了一个引用本机 C++ 库的 Java 活动。该库必须包含 arm_neon.h 头文件,其中包含 Arm Neon 内联函数的声明。此外,您需要为 Arm Neon 内联函数启用构建支持。

在这里,我将这些步骤作为起点,向您展示这种方法可以轻松地用于编写高效代码,这些代码可用于信号和图像处理、神经网络或游戏应用程序。

具体来说,我将创建一个 Android 应用程序,该应用程序将使用 Neon 内联函数处理一维信号。该信号将是带有随机噪声的正弦波。我将展示如何实现该信号的截断阈值和卷积。阈值处理通常是各种图像处理算法的第一步,而卷积是主要的信号和图像处理工具。

和以前一样,我使用 Android Studio 并使用 Samsung SM-J710F 手机测试代码。完整的源代码可从 GitHub 存储库获取。

应用程序结构

我通过修改位于 app/src/main/res/layout 下的 activity_main.xml 来声明应用程序的 UI。如下所示,我使用一个线性布局来垂直对齐控件。这里使用额外的线性布局是为了将截断和卷积按钮彼此相邻放置。ScrollView 包装了所有控件,因此如果屏幕太小无法一次显示所有内容,用户可以向下滚动。

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"    
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context=".MainActivity">

    	<Button
            android:id="@+id/buttonGenerateSignal"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:onClick="buttonGenerateSignalClicked"
            android:text="Generate Signal"
            android:layout_margin="5dp"/>

    	<CheckBox
            android:id="@+id/checkboxUseNeon"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:text="Use Neon?" />

    	<LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal">

            <Button
                android:id="@+id/buttonTruncate"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center_horizontal"
                android:onClick="buttonTruncateClicked"
                android:text="Truncate"
                android:layout_margin="5dp"/>

            <Button
                android:id="@+id/buttonConvolution"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center_horizontal"
                android:onClick="buttonConvolutionClicked"
                android:text="Convolution"
                android:layout_margin="5dp"/>
        </LinearLayout>

        <com.jjoe64.graphview.GraphView
            android:id="@+id/graph"
            android:layout_width="match_parent"
            android:layout_height="350dp"
            android:layout_margin="10dp"/>

    	<TextView
            android:id="@+id/textViewProcessingTime"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:textSize="18sp" />
    </LinearLayout>
</ScrollView>

为了在处理前后绘制信号,我使用了 GraphView 库。通过在 build.gradle 的依赖项节点下包含以下行来安装此库

implementation 'com.jjoe64:graphview:4.2.2'

然后,通过使用 <com.jjoe64.graphview.GraphView> 属性将 GraphView 包含在 UI 中。完成此操作后,您将获得对 GraphView 实例的引用,就像使用任何其他 Android 控件一样。

我在 configureGraph 方法中执行了此操作,我在 onCreate 活动方法下调用了该方法(见下文)。然后,您可以使用对 GraphView 的引用来格式化绘图并添加数据系列。在本文中,我使用了三行系列(来自 GraphView 库的 LineGraphSeries 实例):一个用于显示输入信号,一个用于截断信号,最后一个用于卷积。

GraphView graph;
 
private LineGraphSeries<DataPoint> signalSeries = new LineGraphSeries<>(); 

private void configureGraph(){
    // Initialize graph
    graph = (GraphView) findViewById(R.id.graph);

    // Set bounds
    graph.getViewport().setXAxisBoundsManual(true);
    graph.getViewport().setMaxX(getSignalLength());

    graph.getViewport().setYAxisBoundsManual(true);
    graph.getViewport().setMinY(-150);
    graph.getViewport().setMaxY(150);

    // Configure series
    int thickness = 4;
    signalSeries.setTitle("Signal");
    signalSeries.setThickness(thickness);
    signalSeries.setColor(Color.BLUE);

    // Truncate, and convolution series are configured in the same way
    // Add series
    graph.addSeries(signalSeries);

    // Add legend
    graph.getLegendRenderer().setVisible(true);
    graph.getLegendRenderer().setAlign(LegendRenderer.LegendAlign.TOP);
}

设置图形后,我为按钮实现了事件处理程序。每个事件处理程序的结构都相似。它们使用适当的 LineGraphSeries 实例的 resetData 方法在图表中显示数据。例如,以下代码显示了输入信号和卷积后的数据

private LineGraphSeries<DataPoint> signalSeries = new LineGraphSeries<>();
private LineGraphSeries<DataPoint> signalAfterConvolutionSeries = 
    new LineGraphSeries<>();
 
public void buttonGenerateSignalClicked(View v) {
    signalSeries.resetData(getDataPoints(generateSignal()));
}
 
public void buttonConvolutionClicked(View v) {
    signalAfterConvolutionSeries.resetData(getDataPoints(convolution(useNeon())));

    displayProcessingTime();
}

请注意,与截断和卷积相关的事件处理程序还会获取信息,即处理是否应使用 Neon 内联函数或本机 C++ 完成。为此,我检查了应用程序界面中提供的“使用 Neon”复选框的状态

private boolean useNeon() {
    return checkBoxUseNeon.isChecked();
}

getDataPoints 是一个辅助函数,用于将字节数组(来自 native-lib)转换为 GraphView 绘制所需的 DataPoints 集合(详情请参阅代码仓库)。

另一个辅助函数 displayProcessingTime 用于显示图表下方标签中的代码执行时间。我用它来比较 Neon 和非 Neon 代码之间的处理性能。

调用本地库

信号、截断数据和卷积数据都来自本机 C++ 库。要从 C++ 库获取数据,您需要创建 Java 方法和本机库函数之间的绑定。我将向您展示如何使用 generateSignal 方法来实现这一点。

首先,在 Java 类中声明该方法并带有一个额外的 native 修饰符

public native byte[] generateSignal();

然后,要自动生成绑定,将光标放在方法名称中(这里是 generateSignal)。按 Alt+Enter 显示上下文帮助,并如下所示选择“创建 JNI 函数…”选项。

Android Studio 将使用空声明补充 native-lib.cpp,该声明看起来像这样

extern "C"
JNIEXPORT jbyteArray JNICALL
Java_com_example_neonintrinsicssamples_MainActivity_generateSignal(
    JNIEnv *env, jobject /* this */) {
}

输入信号

设置 UI 和 Java 到本地绑定后,使用 generateSignal 方法扩展 native-lib

#define SIGNAL_LENGTH 1024
#define SIGNAL_AMPLITUDE 100
#define NOISE_AMPLITUDE 25
 
int8_t inputSignal[SIGNAL_LENGTH];
 
void generateSignal() {
    auto phaseStep = 2 * M_PI / SIGNAL_LENGTH;

    for (int i = 0; i < SIGNAL_LENGTH; i++) {
        auto phase = i * phaseStep;
        auto noise = rand() % NOISE_AMPLITUDE;

    	inputSignal[i] = static_cast<int8_t>(SIGNAL_AMPLITUDE * sin(phase) + noise);
    }
}

此方法生成一个有噪声的正弦波(请参阅前面所示的应用程序 UI 图像中的蓝色曲线),并将结果存储在 inputSignal 全局变量中。

您可以使用 SIGNAL_LENGTHSIGNAL_AMPLITUDENOISE_AMPLITUDE 宏控制信号长度、其幅度和噪声贡献。但是请注意,inputSignal 的类型为 int8_t。设置过大的幅度会导致整数溢出。

要将生成的信号传递给 Java 代码进行绘图,您需要在可导出的 C 函数中调用 generateSignal 方法(该函数之前由 Android Studio 创建)

extern "C"
JNIEXPORT jbyteArray JNICALL
Java_com_example_neonintrinsicssamples_MainActivity_generateSignal(
    JNIEnv *env, jobject /* this */) {

    generateSignal();

    return nativeBufferToByteArray(env, inputSignal, SIGNAL_LENGTH);
}

这里有一个新事物,nativeBufferToByteArray。此方法获取指向本机 C++ 数组的指针,并将数组元素复制到 Java 数组(有关更多信息,请参阅此链接

jbyteArray nativeBufferToByteArray(JNIEnv *env, int8_t* buffer, int length) {
    auto byteArray = env->NewByteArray(length);

    env->SetByteArrayRegion(byteArray, 0, length, buffer);

    return byteArray;
}

截断

现在让我们转到通过截断进行的阈值处理。

此算法的工作原理如下:给定一个输入数组,它将所有高于预定义阈值 T 的元素替换为 T 值。所有低于阈值的项保持不变。

要实现这样的算法,您只需使用一个 for 循环。然后,在每次迭代中,您可以调用 std::min 方法来检查当前数组值是否高于阈值

#define THRESHOLD 50
 
int8_t inputSignalTruncate[SIGNAL_LENGTH];
 
void truncate() {
    for (int i = 0; i < SIGNAL_LENGTH; i++) {
        inputSignalTruncate[i] = std::min(inputSignal[i], (int8_t)THRESHOLD);
    }
}

在此示例中,阈值通过相应的宏设置为 50,截断信号存储在 inputSignalTruncate 全局变量中。

使用 Neon 进行截断

由于上述算法的每次迭代都是独立的,因此您可以直接应用 Neon 内联函数。为此,您只需将 for 循环分成几个段。每个段将并行处理几个输入元素。

您可以并行处理的项数取决于输入数据类型。这里,输入信号是 int8_t 元素的数组,因此每次迭代最多可以处理 16 个项(请参阅下面的 TRANSFER_SIZE 宏)。

使用 Neon 内联函数的一般模式是:首先将数据从内存加载到寄存器,使用 Neon SIMD 处理寄存器,然后将结果存储回内存。以下是在截断情况下遵循此方法的方法

#define TRANSFER_SIZE 16
 
void truncateNeon() {
    // Duplicate threshValue
    int8x16_t threshValueNeon = vdupq_n_s8(THRESHOLD);

    for (int i = 0; i < SIGNAL_LENGTH; i += TRANSFER_SIZE) {
    	// Load signal to registers
    	int8x16_t inputNeon = vld1q_s8(inputSignal + i);

    	// Truncate
    	uint8x16_t partialResult = vmin_s8(inputNeon, threshValueNeon);

    	// Store result in the output buffer
    	vst1q_s8(inputSignalTruncate + i, partialResult);
    }
}

您首先将循环分成 SIGNAL_LENGTH / TRANSFER_SIZE 段。然后,使用 vdupq_n_s8 Neon 函数将阈值复制到名为 threshValueNeon 的向量中(您可以在此处获取所有可用 Neon 函数的列表)。

之后,您使用 vld1q_s8 方法将输入信号块加载到寄存器中,计算阈值和输入信号片段之间的最小值,并将结果存储回 inputSignalTruncate 数组。

为了比较 Neon 和非 Neon 方法的性能,将 truncatetruncateNeon 方法放入 Java 绑定方法中

double processingTime;
 
extern "C"
JNIEXPORT jbyteArray JNICALL
Java_com_example_neonintrinsicssamples_MainActivity_truncate(JNIEnv *env, jobject thiz, jboolean useNeon) {

    auto start = now();

#if HAVE_NEON
    if(useNeon)
        truncateNeon();
    else
#endif

    truncate();

    processingTime = usElapsedTime(start);

    return nativeBufferToByteArray(env, inputSignalTruncate, SIGNAL_LENGTH);
}

您可以使用 chrono 库测量处理时间(请参阅随附代码),并将测量的执行时间存储在 processingTime 全局变量中。它使用以下绑定传递给 Java 代码

extern "C"
JNIEXPORT jdouble JNICALL
Java_com_example_neonintrinsicssamples_MainActivity_getProcessingTime(
    JNIEnv *env, jobject thiz) {

    return processingTime;

结果如下所示。

要获取它们,请在您的设备上运行应用程序并单击生成信号按钮。它绘制蓝色曲线。然后,点击截断按钮。会出现一条绿线。

最后,勾选“使用 Neon”复选框,然后再次点击截断按钮。如图所示,使用 Neon 内联函数,我将处理时间从 100 微秒缩短到 6 微秒,执行速度大约快 16 倍!我在没有任何重大代码更改的情况下实现了这一点——只是对单个循环进行了轻微修改,并使用了 Neon 内联函数。

卷积

最后,让我们看看如何使用 Neon 内联函数实现一维卷积。

您可以在此处找到卷积的形式定义。但是,简单来说,要计算卷积,您需要有输入信号。此外,您还需要定义所谓的核。该核通常比输入信号短得多,并且因应用程序而异。人们使用不同的核来平滑或过滤噪声信号或检测边缘。

2D 卷积也用于卷积神经网络中以查找图像特征。

在这里,我有一个 16 元素的核,每个元素的值都为 1。我沿着我的输入信号滑动这个核,并在每个位置将输入元素乘以所有核值,然后对结果乘积求和。归一化后,我的核将作为移动平均值工作。

这是此算法的 C++ 实现

#define KERNEL_LENGTH 16

// Kernel
int8_t kernel[KERNEL_LENGTH] = {1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
 
void convolution() {
    auto offset = -KERNEL_LENGTH / 2;

    // Get kernel sum (for normalization)
    auto kernelSum = getSum(kernel, KERNEL_LENGTH);

    // Calculate convolution
    for (int i = 0; i < SIGNAL_LENGTH; i++) {
        int convSum = 0;

    	 for (int j = 0; j < KERNEL_LENGTH; j++) {
            convSum += kernel[j] * inputSignal[i + offset + j];
    	 }

    	 inputSignalConvolution[i] = (uint8_t)(convSum / kernelSum);
    }
}

如您所见,此代码使用两个 for 循环——一个遍历输入信号元素,另一个遍历核。为了提高性能,我可以采用手动循环展开,并用硬编码索引替换嵌套的 for 循环,如下所示

convSum += kernel[0] * inputSignal[i + offset + 1];
convSum += kernel[1] * inputSignal[i + offset + 2];
…
convSum += kernel[15] * inputSignal[i + offset + 15];

但是,由于内核长度与我可以传输到 CPU 寄存器的项数相同,因此我可以使用 Neon 内联函数并完全展开第二个循环

void convolutionNeon() {
    auto offset = -KERNEL_LENGTH / 2;

    // Get kernel sum (for normalization)
    auto kernelSum = getSum(kernel, KERNEL_LENGTH);

    // Load kernel
    int8x16_t kernelNeon = vld1q_s8(kernel);

    // Buffer for multiplication result
    int8_t *mulResult = new int8_t[TRANSFER_SIZE];

    // Calculate convolution
    for (int i = 0; i < SIGNAL_LENGTH; i++) {
        // Load input
    	 int8x16_t inputNeon = vld1q_s8(inputSignal + i + offset);

    	 // Multiply
    	 int8x16_t mulResultNeon = vmulq_s8(inputNeon, kernelNeon);

    	 // Store and accumulate
    	 // On A64 the following lines of code can be replaced by a single instruction
    	 // auto convSum = vaddvq_s8(mulResultNeon)
    	 vst1q_s8(mulResult, mulResultNeon);
    	 auto convSum = getSum(mulResult, TRANSFER_SIZE);

    	 // Store result
    	 inputSignalConvolution[i] = (uint8_t) (convSum / kernelSum);
    }

    delete mulResult;
}

同样,我首先将数据加载到 CPU 寄存器(从核和输入信号)。我使用 Neon SIMD 处理它,并将结果存储回内存(这里是 inputSignalConvolution 数组)。

上述代码与 ARM v7 及更高版本兼容。但是,在 AARCH64 上,可以通过利用 vaddvq_s8 函数来进一步改进它,该函数对向量中的元素求和。我可以用它来对核下的值求和(请参阅代码中的注释)。

为了测试代码,我使用了另一个 Java 到本地绑定

extern "C"
JNIEXPORT jbyteArray JNICALL
Java_com_example_neonintrinsicssamples_MainActivity_convolution(
    JNIEnv *env, jobject thiz, jboolean useNeon) {

    auto start = now();

#if HAVE_NEON

    if(useNeon)
        convolutionNeon();
    else

#endif

    convolution();

    processingTime = usElapsedTime(start);

    return nativeBufferToByteArray(env, inputSignalConvolution, SIGNAL_LENGTH);
}

然后,重新运行应用程序后,我获得了相同的结果,但使用 Neon 内联函数优化后处理时间仅为一半(请参见下图中的底部标签——处理时间在图表下方以蓝色突出显示)。

总结

在本文中,我开发了一个 Android 应用程序,该应用程序使用本机 C++ 代码执行信号处理。我展示了如何轻松地将 Java 与本机代码结合起来以执行计算密集型工作。本机部分的性能通过 Arm Neon 内联函数得到了进一步增强。您看到 Neon 内联函数可以显著缩短处理时间。特别是对于带截断的阈值处理,处理时间缩短到原始处理时间的约 6%。

当您的迭代代码独立时,即后续迭代不依赖于先前结果时,您会发现 Arm Neon 内联函数很有用。流行的开源库(如 OpenCV)已经使用 Neon 内联函数来提高性能。例如,Carotene 存储库包含基于 Neon 内联函数的代码,该代码实现了许多图像处理算法。您可以使用此存储库来深入了解 Neon 内联函数如何帮助您的项目。

参考资料和有用链接

© . All rights reserved.