移动端高性能计算基础

阿凡达2018-06-28 16:26

移动端高性能计算基础

计算的概念

计算在数学上的概念: 计算是一种行为,通过已知量的可能的组合,获得新的量。计算的本质是集合之间的映射。

个人粗浅直白的理解是: 输入一个或多个数据,经过处理,输出一个或多个数据。如 1 + 2 就是一个计算,输入 2 个数据,输出 1 个数据 3

那到这里就会有很多疑问,在计算机上:

  • 高性能计算的概念是什么?
  • 全部的普通计算都能转成高性能计算实现么?如果不是的话,那哪些类型的计算可以呢?
  • 我们需要做哪些事情,来实现高性能计算?
  • Android框架或者是否存在第三方库为我们做了相关的工作

高性能计算的概念

高性能计算:通常使用很多处理器(作为单个机器的一部分)或者某一集群中组织的几台计算机(作为单个计算资源操作)的计算系统和环境。

在移动端,我们可以认为是通过同时启用移动设备的 CPUGPU 构成的异构计算资源,进行协同计算。

计算模型类型

从数据流和指令的角度把计算模型分为4类(费林分类法)

  1. 单指令单数据流 (SISD): 非并行计算的模型,典型例子就是单核 CPU,所有数据都被一个处理器顺次处理,某一时刻只能使用一个指令。
  2. 单指令多数据流 (SIMD): 指多个不同的数据同时被相同的执行、指令集或者算法处理,是 GPU 的计算模型。
  3. 多指令单数据流 (MISD): 在同一个数据流上执行不同的指令。
  4. 多指令多数据流 (MIMD): 是多核CPU的计算模型。

本文内容讨论的高性能计算则主要是在 SIMD 的基础上讨论,但这里并不需要严格按照 SIMD,只需要计算流程中的一部分内容符合 SIMD 我们就认为该实现过程就是一个高性能计算。

知道了计算模型的类型,我们就能知道并不是所有的计算类型都能实现为高性能计算。只有满足以下要求的算法(或者算法中的部分满足,其他部分通过CPU协调)才能够比较好的实现为高性能计算。

  1. 每个数据(数据包)都需要经过相同的流程来处理
  2. 数据之间并没有相干性,即某些数据的计算不依赖另外一些数据的计算结果
  3. 数据量庞大

如何实现高性能计算

这里首先了解的是图形显示流程,常用的通用计算也正是基于这个显示流程做修改而实现的。这里以OpenGL ES为例,其他的如Direct3D、CG的流程大体也相同。

OpenGL ES 2.0可编程渲染管线:

    顶点缓冲对象
        ↑
API → 基本处理 → 顶点着色器 → 图元装配 → 光栅化 → 片元着色器 → 裁剪测试

 → 深度测试&模板测试 → 颜色缓冲混合 → 抖动 → 帧缓冲

其中的顶点着色器片元着色器的处理过程,程序猿可以自行编写,且是分别在 GPU 中的顶点处理器和片元处理器(或者统一处理器)计算。

知道了这个流程,我们可以很容易联想到:

  1. 我们的高性能计算的主要算法过程是在 顶点着色器片元着色器 中处理的,一般都是 片元着色器
  2. 这个流程是用于显示,输入是顶点和纹理等数据,输出是帧缓冲,很明显并不是我们所需要的,因此我们还需要修改流程。

修改后的计算流程图:

                              顶点缓冲对象
                                  ↑
纹理数据 (顶点数据和纹理内部数据) → 基本处理 → 顶点着色器 → {{图元装配}} → 光栅化 → 片元着色器

 → {{裁剪测试}} → {{深度测试&模板测试}} → {{颜色缓冲混合}} → {{抖动}} → 纹理

其中 {{ }} 显示的部分并不是我们关心的内容,我们的程序会经过这几步骤,但逻辑上一般并不用生效。


public int[] increase(int[] input) {
    for (int i = 0; i < input.length; i++) {
        input[i]++;
    }
    return input;
}

这里的处理过程还是很模糊,对比一下上面的常规计算 (普通计算左,高性能计算右):

  1. 输入 int 数组 → 输入纹理数据
  2. for 循环语句 → 片段着色器
  3. 返回int数组 → 渲染到纹理(具体对应帧缓存对象 FBO),并读取
  4. 调用 → 绘制矩形
  5. 数组处理范围 → 坐标

为了保证我们输出到纹理的数据是完整正确的,另外需要注意的是:

  1. 绘制的矩形应该与投影平面平行,即正对摄像机
  2. 使用正交投影
  3. 矩形和纹理等大
  4. 视口和纹理图等大

性能提升效果

前面介绍了这么多,但终究只是理论介绍,我们并没有看到使用高性能计算究竟提升了多少。

写一个非常简单的图像处理的算法 (因为使用图像暂时效果比较明显,表达也比较容易,所以这里使用的是图像显示的 Demo,并不是说高性能计算只能用于显示相关)

算法基本流程是:

  • 读入一张图片
  • 光顺处理下,即将每个像素点和周围的8个像素点的颜色做一个平均,并将均值赋值给中间像素点
  • 将各个像素点置灰,即将每个像素点的rgb值求和并取平均值
  • 调整亮度,即将每个像素点的颜色的各通道值乘以0.9
  • 将像素数组取出设置给bitmap,并设置给ImageView

常规计算 ( java 代码) 实现


public class ImageGrayUtil {
    // 给出最终求和时的加权因子(为调整亮度)
    public static final float scaleFactor = 0.9f;

    public static Bitmap apply(Context context, Bitmap sentBitmap) {
        Bitmap bitmap = sentBitmap.copy(Bitmap.Config.ARGB_8888, true);

        int w = bitmap.getWidth();
        int h = bitmap.getHeight();

        int[] pix = new int[w * h];

        bitmap.getPixels(pix, 0, w, 0, 0, w, h);

        // 卷积内核中各个位置的值
        float kernalValue = 1.0f/9.0f;
        float k00 = kernalValue;
        float k10 = kernalValue;
        float k20 = kernalValue;
        float k01 = kernalValue;
        float k11 = kernalValue;
        float k21 = kernalValue;
        float k02 = kernalValue;
        float k12 = kernalValue;
        float k22 = kernalValue;

        for (int i = 0; i < w; i++) {
            for (int j = 0; j < h; j++) {
                // 获取卷积内核中各个元素对应像素的颜色值
                int[] p00 = mutiply(getARGB(pix, w, h, i - 1, j - 1), k00);
                int[] p10 = mutiply(getARGB(pix, w, h, i, j - 1), k10);
                int[] p20 = mutiply(getARGB(pix, w, h, i + 1, j - 1), k20);
                int[] p01 = mutiply(getARGB(pix, w, h, i - 1, j), k01);
                int[] p11 = mutiply(getARGB(pix, w, h, i, j), k11);
                int[] p21 = mutiply(getARGB(pix, w, h, i + 1, j), k21);
                int[] p02 = mutiply(getARGB(pix, w, h, i - 1, j + 1), k02);
                int[] p12 = mutiply(getARGB(pix, w, h, i, j + 1), k12);
                int[] p22 = mutiply(getARGB(pix, w, h, i + 1, j + 1), k22);

                int[] pixARGB = add(p00, p10, p20, p01, p11, p21, p02, p12, p22);

                setColor(pix, w, h, i, j, uniform(toGray(pixARGB)));
            }
        }
        bitmap.setPixels(pix, 0, w, 0, 0, w, h);

return bitmap;
}

// 获取颜色各通道值
private static int[] argbFromColor(@ColorInt int color) {
int[] argb = new int[4];
argb[0] = Color.alpha(color);
argb[1] = Color.red(color);
argb[2] = Color.green(color);
argb[3] = Color.blue(color);

return argb;
}

private static int getColor(int[] pix, int w, int h, int x, int y) {
if (x < 0 || x > w - 1) return 0;
if (y < 0 || y > h - 1) return 0;
return pix[y * w + x];
}

private static int[] setColor(int[] pix, int w, int h, int x, int y, int[] argb) {
if (x < 0 || x > w - 1) return pix;
if (y < 0 || y > h - 1) return pix;
pix[y * w + x] = Color.argb(argb[0], argb[1], argb[2], argb[3]);
return pix;
}

// 获取颜色置灰,排除alpha通道
private static int[] toGray(int[] argb) {
int v = 0;
for (int i=1; i<argb.length; i++) {
v += argb[i];
}
v /= 3;
return new int[]{v, v, v, v};
}

// 获取颜色各通道值
private static int[] getARGB(int[] pix, int w, int h, int x, int y) {
int color = getColor(pix, w, h, x, y);
return argbFromColor(color);
}

// 将数组的各元素和factor相乘
private static int[] mutiply(int[] argb, float factor) {
for (int i = 0; i < argb.length; i++) {
argb[i] = (int) (argb[i] * factor);
}
return argb;
}

// 将数组相加
private static int[] add(int[]... argbs) {
int[] result = new int[4];
for (int i = 0; i < argbs.length; i++) {
for (int j = 0; j < 4; j++) {
result[j] += argbs[i][j];
}
}

return result;
}

// 将颜色各通道值限制在0-255之间
private static int[] uniform(int[] argb) {
argb[0] = 255;
for (int i = 0; i < argb.length; i++) {
if (argb[i] < 0) argb[i] = 0;
if (argb[i] > 255) argb[i] = 255;

argb[i] *= scaleFactor;
}

return argb;
}
}

在系统 5.1.1 的 Nexus 5 手机,对 142KB 的正方形 png 图片做处理,实现结果如下:

说明:

上面的图片是输入图片,下面的图片是输出图片,显示的处理时间是 4234.676ms

高性能计算实现

片元处理器代码 gray_blur_f.glsl (用于处理数据) 如下:


precision mediump float;//给出默认的浮点精度

varying vec2 vTexCoord;//从顶点着色器传递过来的纹理坐标
uniform sampler2D sTexture;//纹理内容数据
uniform vec2 uPxD;           // pixel delta values

void main() {
    // 给出卷积内核中各个元素对应像素相对于待处理像素的纹理坐标偏移量
    vec2 offset0=vec2(-1.0,-1.0); vec2 offset1=vec2(0.0,-1.0); vec2 offset2=vec2(1.0,-1.0);
    vec2 offset3=vec2(-1.0,0.0); vec2 offset4=vec2(0.0,0.0); vec2 offset5=vec2(1.0,0.0);
    vec2 offset6=vec2(-1.0,1.0); vec2 offset7=vec2(0.0,1.0); vec2 offset8=vec2(1.0,1.0); 

    // 给出最终求和时的加权因子(为调整亮度)
    const float scaleFactor = 0.9;

    //卷积内核中各个位置的值
    float kernelValue = 1.0/9.0;
    float kernelValue0 = kernelValue; float kernelValue1 = kernelValue; float kernelValue2 = kernelValue;
    float kernelValue3 = kernelValue; float kernelValue4 = kernelValue; float kernelValue5 = kernelValue;
    float kernelValue6 = kernelValue; float kernelValue7 = kernelValue; float kernelValue8 = kernelValue;

    // 获取卷积内核中各个元素对应像素的颜色值
    vec4 p00 = texture2D(sTexture, vTexCoord + offset0.xy * uPxD.xy) * kernelValue0;
    vec4 p10 = texture2D(sTexture, vTexCoord + offset1.xy * uPxD.xy) * kernelValue1;
    vec4 p20 = texture2D(sTexture, vTexCoord + offset2.xy * uPxD.xy) * kernelValue2;
    vec4 p01 = texture2D(sTexture, vTexCoord + offset3.xy * uPxD.xy) * kernelValue3;
    vec4 p11 = texture2D(sTexture, vTexCoord + offset4.xy * uPxD.xy) * kernelValue4;
    vec4 p21 = texture2D(sTexture, vTexCoord + offset5.xy * uPxD.xy) * kernelValue5;
    vec4 p02 = texture2D(sTexture, vTexCoord + offset6.xy * uPxD.xy) * kernelValue6;
    vec4 p12 = texture2D(sTexture, vTexCoord + offset7.xy * uPxD.xy) * kernelValue7;
    vec4 p22 = texture2D(sTexture, vTexCoord + offset8.xy * uPxD.xy) * kernelValue8;

    //颜色求和
    vec4 clr = p00 + p01 + p02 +
               p10 + p11 + p12 +
               p20 + p21 + p22;

    // 灰度化
    float hd = (clr.r + clr.g + clr.b) / 3.0;

    //进行亮度加权后将最终颜色传递给管线
      gl_FragColor = vec4(hd) * scaleFactor;
}

java 代码 (用于读取数据并生成 bitmap) 如下:

public Bitmap saveFrameBufferToBitmap(int w, int h) {
    resultBm = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);

    IntBuffer ib = ByteBuffer.allocateDirect((int) (w * h * 4))
            .order(ByteOrder.nativeOrder()).asIntBuffer();
//    IntBuffer ibt = ByteBuffer.allocateDirect((int) (w * h * 4))
//            .order(ByteOrder.nativeOrder()).asIntBuffer();
    ib.rewind();
//    ibt.rewind();

    // 强制刷新数据至纹理缓冲区
    GLES20.glFinish();

    // 从纹理缓冲区读取数据
    GLES20.glReadPixels(0, 0, w, h, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, ib);

    // 数据上下翻转
       // 删除使用java代码上下翻转图片,而是在前面设置纹理坐标的时候,修改转向,实现上下翻转
       // 可以节省大量的时间
   // for (int i = 0; i < h; i++) {
   //     for (int j = 0; j < w; j++) {
   //         ibt.put((h - i - 1) * w + j, ib.get(i * w + j));
   //     }
   // }

    resultBm.copyPixelsFromBuffer(ib);

    return resultBm
}

在系统 5.1.1 的 Nexus 5 手机,对 142KB 的方形 png 图片做处理,实现结果如下:

说明:

上面的图片是输入图片,下面的图片是输出图片,显示的处理时间是257.13364ms

这里的时间并不仅仅包含片元处理器的代码执行时间,也已经包括从纹理缓存对象中读取数据并生成Bitmap的时间,读取数据的时间也会占用比较多的时间

PS:删除了使用java代码翻转图片数据的操作,而是在输入纹理顶点的时候,修改顺逆方向,达到相同的目的,节省了 200ms 左右

总结

综上,可以看到使用高性能计算的计算效率比普通的计算要提高了将近 16 倍,无愧于高性能这几个字。虽然 257.13364ms 也还是远大于 Android 16ms 的屏幕刷新时间,但我们可以通过其他手段处理,如在其他线程处理,或者根据特殊业务需求先做预处理,如高斯模糊效果实现方案及性能对比这篇文章中通过预先压缩图片的方式,让模糊算法的时间降低至 16ms 以内。

既然高性能计算的效率优势如此明显,很奇怪现在的移动端开发很少使用这套东西。

这里也必须客观的提一下高性能计算的缺点,根据自己粗浅的认知总结如下:

  • 符合高性能计算要求的算法要求较为苛刻,需要大数据输入,且数据处理过程之间无关联

说实话,在移动开发过程中,除了图像处理,其他的业务逻辑中真的很少能想到有什么需求需要这么做。很多时候,图像处理也是交给服务器(如 nos )处理了。当然通过 url 告诉服务器处理,那图片的加载速度就依赖于网络请求,如果产品策划无法忍受这一点的话,就可以考虑是否要使用本地高性能计算了

  • 整个流程,需要了解图形渲染管线的流程,需要知道如何设置摄像机、投影模式,如何触发离屏渲染等等图形相关的知识,且需要知道一种 shader language

上面的示例中,并没有给出显示设置相关的代码,而这部分代码也较为冗长难写。对于一般的移动端开发同学其实并不关心或者不感兴趣这些内容。所幸的是,在 Android 端已经出现了 RanderScript,将上述的复杂流程极度简化

  • GPU
    SIMD 计算模型的核心,而 GPU 相比 CPU 的特点是 GPU 并没有复杂的缓存系统、分支预测系统和各种控制逻辑,而是使用芯片上大多数的晶体管作为纯计算单元。可见,GPU 并不能处理复杂的计算逻辑。

对于程序员来说,比较明显的一点,就是大部分版本的 shader language 并不支持循环语句,当然以后会不会有就不得而知了

  • 使用高性能计算后,各种机型适配也可能会是一个比较头疼的事情

对于一些过时的老机器很可能没有 GPU,其次各个 GPU 制造厂商的的产品对 OpenGL ES 的支持程度也会有少许差别。当然,这些支持的差异,一般这边也不会涉及到。还有,因为 高性能计算 是利用了图形渲染管线的,很容易知道其应用程序的 Android SDK 要不低于 2.2。

其实移动端的高性能计算的使用还是相对少见,至少我支持的几个 Android 项目都没有涉及到,本文仅仅是作为科普讲解下一点点基础,让大家了解下什么是高性能计算,相信即便是使用过 RenderScript 的Android开发同学,也可能不清楚什么是高性能计算。

因为本文讲述的仅仅是基础,如果有大牛同学有自己的看法的话,欢迎指正出来,也帮助我成长。 O(∩_∩)O~

本文来自网易实践者社区,经作者张云龙授权发布。