Java中的GPU编程

从Java访问GPU揭示了强大的功能。它描述了GPU如何工作以及如何从Java访问。

对于Java程序员来说,GPU编程是一个空想世界。这是可以理解的,因为普通的Java任务不适用于GPU。但是,GPU具有数百万亿次性能,因此让我们探索它们的功能。
为了使该主题易于理解,我将花一些时间来解释GPU的体系结构以及一些有助于沉浸于铁编程中的小历史。

向我展示了GPU与CPU计算之间的区别之后,我将展示如何在Java世界中使用GPU。最后,我将描述可用于编写Java代码并在GPU上运行它们的主要框架和库,并给出一些代码示例。

有点背景


GPU由NVIDIA于1999年首次普及。它是一种特殊的处理器,旨在处理图形数据,然后将其传输到显示器。在许多情况下,这使某些计算可以减轻CPU的负担,从而释放CPU资源,从而加快这些卸载的计算速度。结果是可以处理较大的输入并以较高的输出分辨率进行呈现,从而使视觉呈现更具吸引力,并且帧频更加流畅。

2D / 3D处理的本质主要在于矩阵的操纵,这可以使用分布式方法来控制。什么是有效的图像处理方法?为了回答这个问题,让我们比较一下标准CPU架构(如图1所示)和GPU。

图片
图。 1. CPU体系结构块

在CPU中,实际的处理元素-寄存器,算术逻辑单元(ALU)和执行上下文-只是整个系统的一小部分。为了加快以不可预测的顺序进行的不定期付款,存在大量,快速且昂贵的缓存;各种类型的收藏家;和分支预测变量。

您不需要在GPU上拥有所有这些功能,因为数据是以可预测的方式接收的,并且GPU对数据执行的操作非常有限。因此,有可能使它们变得非常小,并且在图1中示出了具有类似于此的块架构的廉价处理器。 2.

图片
图2.简单GPU内核的块架构

因为这样的处理器更便宜,并且处理后的数据在并行块中,所以使它们中的许多并行工作很简单。它是根据多个指令,多个数据或MIMD(发音为“ mim-dee”)设计的。

第二种方法基于这样一个事实,即通常将一条指令应用于多条数据。这被称为单指令,多数据或SIMD(发音为“ sim-dee”)。在这种设计中,单个GPU包含多个ALU和执行上下文,其中很小的区域已传输到共享上下文数据,如图2所示。 3.

图片
图3.比较GPU块的MIMD风格架构(左起)和SIMD设计(右起)

混合SIMD和MIMD处理可提供我将绕过的最大带宽。在此设计中,您有多个并行运行的SIMD处理器,如图1所示。4.

图片
4.并行工作多个SIMD处理器;有16个内核和128个ALU。

由于您有一堆小型的简单处理器,因此可以对其进行编程,以在输出中获得特殊效果。

在GPU上运行程序


游戏中大多数早期的图形效果实际上都是在GPU上运行的硬编码小程序,并应用于CPU的数据流。

即使硬编码算法不足,这也是显而易见的,尤其是在视觉效果是主要魔术方向之一的游戏设计中。作为回应,大卖家打开了对GPU的访问权限,然后第三方开发人员可以对其进行编程。

一种典型的方法是用一种特殊的语言(通常是C的一个子类)编写一个称为着色器的小程序,并使用特殊的编译器针对所需的体系结构进行编译。选择“着色器”一词是因为着色器通常用于控制灯光和阴影效果,但这并不意味着它们可以控制其他特殊效果。

每个GPU供应商都有自己的编程语言和基础架构,以为其架构创建着色器。通过这种方法,已经创建了许多平台。

主要的是:

  • DirectCompute:从DirectX 10开始的Microsoft私有着色器语言/ API,它是Direct3D的一部分。
  • AMD FireStream:AMD过时的私有ATI / Radeon技术。
  • OpenACC:多供应商联盟,并行计算解决方案
  • ++ AMP: Microsoft C++
  • CUDA: Nvidia,
  • OpenL: , Apple, Khronos Group

在大多数情况下,使用GPU是低级编程。为了使开发人员更容易理解,为进行编码,提供了几种抽象。最著名的是Microsoft的DirectX和Khronos Group的OpenGL。这些是用于编写高级代码的API,然后可以针对GPU对其进行简化,对于程序员而言,可以在语义上进行简化。

据我所知,没有DirectX的Java基础结构,但是OpenGL有一个很好的解决方案。JSR 231始于2002年,面向GPU程序员,但在2008年被废弃,仅支持OpenGL 2.0。

在独立的JOCL项目(​​也支持OpenCL)中继续提供OpenGL支持,并且向观众开放。因此,著名的Minecraft游戏是使用JOCL编写的。

GPGPU即将到来


到目前为止,尽管Java和GPU确实应该有共同点。Java通常用于企业,数据科学和金融部门,那里有大量的计算,并且需要大量的计算能力。这就是通用GPU(GPGPU)的想法。当视频适配器的制造商开始提供对程序帧缓冲区的访问权限,从而使开发人员可以读取内容时,便开始沿此路径使用GPU的想法。一些黑客已经确定他们可以使用GPU的全部功能进行通用计算。
食谱是这样的:

  1. 将数据编码为栅格数组。
  2. 编写着色器来处理它们。
  3. 将它们都发送到图形卡。
  4. 从帧缓冲区获取结果
  5. 解码栅格阵列中的数据。

这是一个非常简单的解释。我不确定这是否可以在生产中使用,但确实可以。

然后,斯坦福研究所的大量研究开始简化GPU的使用。在2005年,他们制作了BrookGPU,这是一个小型生态系统,其中包含编程语言,编译器和运行时。

BrookGPU编译了使用Brook线程编程语言编写的程序,该程序是ANSI C变体,它可以将OpenGL v1.3 +,DirectX v9 +或AMD Close to Metal用于服务器计算部分,并且可以在Microsoft Windows和Linux上运行。为了进行调试,BrookGPU还可以在CPU上模拟虚拟图形卡。
但是,由于当时可用的设备,这种情况并未实现。在GPGPU世界中,您需要将数据复制到设备(在这种情况下,设备是指GPU及其所在的设备),等待GPU计算数据,然后将数据复制回控制程序。这造成很多延迟。在2000年代中期,当该项目处于积极开发阶段时,这些延迟还排除了将GPU大量用于基础计算的情况。

但是,许多公司已经看到了该技术的未来。多家视频适配器开发商开始向GPGPU提供其专有技术,而其他结成的联盟则提供了可用于大量硬件的,较不基本的通用编程模型。

现在,我已经告诉了您一切,让我们看看两种最成功的GPU计算技术-OpenCL和CUDA-以及Java如何与它们一起使用。

OpenCL和Java


像其他基础结构软件包一样,OpenCL提供了C的基本实现。从技术上讲,这可以使用Java本机接口(JNI)或Java本机访问(JNA)来获得,但是这种方法对于大多数开发人员来说太困难了。

幸运的是,这项工作已经由几个库完成:JOCL,JogAmp和JavaCL。不幸的是,JavaCL已成为一个死项目。但是,JOCL项目仍然活跃并且非常适应。我将在以下示例中使用它。

但是首先我必须解释一下OpenCL是什么。前面我提到过,OpenCL提供了一个非常基本的模型,适用于对各种设备进行编程-不仅是GPU和CPU,甚至是DSP处理器和FPGA。

让我们看一个最简单的例子:折叠向量可能是最明亮和最简单的例子。您有两个数字数组用于加法,一个用于结果。您从第一个数组中获取一个元素,并从第二个数组中获取一个元素,然后将总和放入结果数组中,如图2所示。 5.

图片
图5.将两个数组的元素相加并将和存储在结果数组中

如您所见,该操作非常一致并且仍然是分布式的。您可以将每个加法运算推入不同的核心GPU。这意味着,如果您具有2048个核(例如在Nvidia 1080上),则可以同时执行2048个加法运算。这意味着潜在的数百万亿次计算机等待着您。此代码包含一千万个数字,可从JOCL网站获取:

public class ArrayGPU {
    /**
     * The source code of the OpenCL program 
     */
    private static String programSource =
        "__kernel void "+
        "sampleKernel(__global const float *a,"+
        "             __global const float *b,"+
        "             __global float *c)"+
        "{"+
        "    int gid = get_global_id(0);"+
        "    c[gid] = a[gid] + b[gid];"+
        "}";
    
    public static void main(String args[])
    {
        int n = 10_000_000;
        float srcArrayA[] = new float[n];
        float srcArrayB[] = new float[n];
        float dstArray[] = new float[n];
        for (int i=0; i<n; i++)
        {
            srcArrayA[i] = i;
            srcArrayB[i] = i;
        }
        Pointer srcA = Pointer.to(srcArrayA);
        Pointer srcB = Pointer.to(srcArrayB);
        Pointer dst = Pointer.to(dstArray);


        // The platform, device type and device number
        // that will be used
        final int platformIndex = 0;
        final long deviceType = CL.CL_DEVICE_TYPE_ALL;
        final int deviceIndex = 0;

        // Enable exceptions and subsequently omit error checks in this sample
        CL.setExceptionsEnabled(true);

        // Obtain the number of platforms
        int numPlatformsArray[] = new int[1];
        CL.clGetPlatformIDs(0, null, numPlatformsArray);
        int numPlatforms = numPlatformsArray[0];

        // Obtain a platform ID
        cl_platform_id platforms[] = new cl_platform_id[numPlatforms];
        CL.clGetPlatformIDs(platforms.length, platforms, null);
        cl_platform_id platform = platforms[platformIndex];

        // Initialize the context properties
        cl_context_properties contextProperties = new cl_context_properties();
        contextProperties.addProperty(CL.CL_CONTEXT_PLATFORM, platform);
        
        // Obtain the number of devices for the platform
        int numDevicesArray[] = new int[1];
        CL.clGetDeviceIDs(platform, deviceType, 0, null, numDevicesArray);
        int numDevices = numDevicesArray[0];
        
        // Obtain a device ID 
        cl_device_id devices[] = new cl_device_id[numDevices];
        CL.clGetDeviceIDs(platform, deviceType, numDevices, devices, null);
        cl_device_id device = devices[deviceIndex];

        // Create a context for the selected device
        cl_context context = CL.clCreateContext(
            contextProperties, 1, new cl_device_id[]{device}, 
            null, null, null);
        
        // Create a command-queue for the selected device
        cl_command_queue commandQueue = 
            CL.clCreateCommandQueue(context, device, 0, null);

        // Allocate the memory objects for the input and output data
        cl_mem memObjects[] = new cl_mem[3];
        memObjects[0] = CL.clCreateBuffer(context,
            CL.CL_MEM_READ_ONLY | CL.CL_MEM_COPY_HOST_PTR,
            Sizeof.cl_float * n, srcA, null);
        memObjects[1] = CL.clCreateBuffer(context,
            CL.CL_MEM_READ_ONLY | CL.CL_MEM_COPY_HOST_PTR,
            Sizeof.cl_float * n, srcB, null);
        memObjects[2] = CL.clCreateBuffer(context,
            CL.CL_MEM_READ_WRITE,
            Sizeof.cl_float * n, null, null);
        
        // Create the program from the source code
        cl_program program = CL.clCreateProgramWithSource(context,
            1, new String[]{ programSource }, null, null);
        
        // Build the program
        CL.clBuildProgram(program, 0, null, null, null, null);
        
        // Create the kernel
        cl_kernel kernel = CL.clCreateKernel(program, "sampleKernel", null);
        
        // Set the arguments for the kernel
        CL.clSetKernelArg(kernel, 0,
            Sizeof.cl_mem, Pointer.to(memObjects[0]));
        CL.clSetKernelArg(kernel, 1,
            Sizeof.cl_mem, Pointer.to(memObjects[1]));
        CL.clSetKernelArg(kernel, 2,
            Sizeof.cl_mem, Pointer.to(memObjects[2]));
        
        // Set the work-item dimensions
        long global_work_size[] = new long[]{n};
        long local_work_size[] = new long[]{1};
        
        // Execute the kernel
        CL.clEnqueueNDRangeKernel(commandQueue, kernel, 1, null,
            global_work_size, local_work_size, 0, null, null);
        
        // Read the output data
        CL.clEnqueueReadBuffer(commandQueue, memObjects[2], CL.CL_TRUE, 0,
            n * Sizeof.cl_float, dst, 0, null, null);
        
        // Release kernel, program, and memory objects
        CL.clReleaseMemObject(memObjects[0]);
        CL.clReleaseMemObject(memObjects[1]);
        CL.clReleaseMemObject(memObjects[2]);
        CL.clReleaseKernel(kernel);
        CL.clReleaseProgram(program);
        CL.clReleaseCommandQueue(commandQueue);
        CL.clReleaseContext(context);

    }

    private static String getString(cl_device_id device, int paramName) {
        // Obtain the length of the string that will be queried
        long size[] = new long[1];
        CL.clGetDeviceInfo(device, paramName, 0, null, size);

        // Create a buffer of the appropriate size and fill it with the info
        byte buffer[] = new byte[(int)size[0]];
        CL.clGetDeviceInfo(device, paramName, buffer.length, Pointer.to(buffer), null);

        // Create a string from the buffer (excluding the trailing \0 byte)
        return new String(buffer, 0, buffer.length-1);
    }
}

此代码与Java代码不同,但与Java代码类似。我将进一步解释代码;现在不要花很多时间,因为我将简要讨论复杂的解决方案。

该代码将被记录下来,但让我们做一些演练。如您所见,该代码与C中的代码非常相似。这是正常的,因为JOCL只是OpenCL。首先,这是该行中的一些代码,并且此代码是最重要的部分:使用OpenCL对其进行编译,然后将其发送到视频卡,然后在其中执行。此代码称为内核。不要将此术语与OC Kernel混淆。这是设备代码。该代码用C的子集编写。

内核出现Java代码之后,用于安装和配置设备,拆分数据并为生成的数据创建适当的内存缓冲区。

总结一下:这是“主机代码”和“设备代码”,通常是语言绑定(在我们的示例中为Java)。由于主机控制设备,因此您始终会突出显示将在主机上运行的设备以及在设备上运行的设备。

前面的代码应显示与“ Hello World!”等效的GPU。如您所见,大多数内容都是巨大的。

让我们不要忘记SIMD功能。如果您的设备支持SIMD扩展,则可以使算术代码更快。例如,让我们看一下内核矩阵乘法代码。此代码在应用程序的简单Java行中。

__kernel void MatrixMul_kernel_basic(int dim,
                  __global float *A,
                  __global float *B,
                  __global float *C){

    int iCol = get_global_id(0);
    int iRow = get_global_id(1);
    float result = 0.0;
    for(int i=0; i< dim; ++i)
    {
          result +=
          A[iRow*dim + i]*B[i*dim + iCol];
    }
    C[iRow*dim + iCol] = result;
}

从技术上讲,此代码将处理OpenCL框架为您安装的数据,并提供您在准备部分中调用的说明。

如果您的视频卡支持SIMD指令并可以处理四个浮点数的向量,则小的优化可以将前面的代码转换为以下代码:

#define VECTOR_SIZE 4    
__kernel void MatrixMul_kernel_basic_vector4(
    size_t dim, // dimension is in single floats
    const float4 *A,
    const float4 *B,
    float4 *C)
{
    size_t globalIdx = get_global_id(0);
    size_t globalIdy = get_global_id(1);
    float4 resultVec = (float4){ 0, 0, 0, 0 };
    size_t dimVec = dim / 4;
    for(size_t i = 0; i < dimVec; ++i) {
        float4 Avector = A[dimVec * globalIdy + i];
        float4 Bvector[4];
        Bvector[0] = B[dimVec * (i * 4 + 0) + globalIdx];
        Bvector[1] = B[dimVec * (i * 4 + 1) + globalIdx];
        Bvector[2] = B[dimVec * (i * 4 + 2) + globalIdx];
        Bvector[3] = B[dimVec * (i * 4 + 3) + globalIdx];
        resultVec += Avector[0] * Bvector[0];
        resultVec += Avector[1] * Bvector[1];
        resultVec += Avector[2] * Bvector[2];
        resultVec += Avector[3] * Bvector[3];
    }

    C[dimVec * globalIdy + globalIdx] = resultVec;
}

使用此代码,您可以使性能提高一倍。

凉。您刚刚为Java世界打开了GPU!但是,作为Java开发人员,您真的要使用C代码并使用如此低级的细节来完成所有这些肮脏的工作吗?我不想。但是,既然您已经了解了GPU的使用方式,那么让我们看一下与我刚刚介绍的JOCL代码不同的另一种解决方案。

CUDA和Java


CUDA是Nvidia解决此编程问题的方法。CUDA为标准GPU操作提供了更多现成的库,例如矩阵,直方图甚至是深度神经网络。库列表与大量现成的解决方案一起出现了。这些全部来自JCuda项目:

  • JCublas:适用于矩阵的一切
  • JCufft:快速傅立叶变换
  • JCurand:随机数的一切
  • JCusparse:稀有矩阵
  • JCusolver:数字分解
  • JNvgraph:图的一切
  • JCudpp:原始并行数据的CUDA库和一些排序算法
  • JNpp:GPU图像处理
  • JCudnn:深度神经网络库

我正在考虑使用JCurand,它会生成随机数。您可以从Java代码使用此代码,而无需其他特殊的内核语言。例如:

...
int n = 100;
curandGenerator generator = new curandGenerator();
float hostData[] = new float[n];
Pointer deviceData = new Pointer();
cudaMalloc(deviceData, n * Sizeof.FLOAT);
curandCreateGenerator(generator, CURAND_RNG_PSEUDO_DEFAULT); 
curandSetPseudoRandomGeneratorSeed(generator, 1234);
curandGenerateUniform(generator, deviceData, n);
cudaMemcpy(Pointer.to(hostData), deviceData, 
        n * Sizeof.FLOAT, cudaMemcpyDeviceToHost);
System.out.println(Arrays.toString(hostData));
curandDestroyGenerator(generator);
cudaFree(deviceData);
...

它基于非常强大的数学运算法则,使用GPU创建了大量高质量的随机数。

在JCuda中,您还可以编写通用CUDA代码,并通过在类路径中调用一些JAR文件从Java调用它。有关出色的示例,请参见JCuda文档。

停留在低级代码之上


一切看起来很棒,但是有太多的代码,太多的安装,太多的不同语言无法全部运行。有没有办法至少部分使用GPU?

如果您不想考虑所有这些OpenCL,CUDA和其他不必要的事情,该怎么办?如果您只想用Java编程而不考虑所有不明显的事情怎么办? Aparapi项目可以提供帮助。 Aparapi基于“并行API”。我认为它是Hibernate的一部分,用于在底层使用OpenCL的GPU编程。让我们看一个向量加法的例子。

public static void main(String[] _args) {
    final int size = 512;
    final float[] a = new float[size];
    final float[] b = new float[size];

    /* fill the arrays with random values */
    for (int i = 0; i < size; i++){
        a[i] = (float) (Math.random() * 100);
        b[i] = (float) (Math.random() * 100);
    }
    final float[] sum = new float[size];

    Kernel kernel = new Kernel(){
        @Override public void run() {
I           int gid = getGlobalId();
            sum[gid] = a[gid] + b[gid];
        }
    };

    kernel.execute(Range.create(size));
    for(int i = 0; i < size; i++) {
        System.out.printf("%6.2f + %6.2f = %8.2f\n", a[i], b[i], sum[i])
    }
    kernel.dispose();
}

这是纯Java代码(摘自Aparapi文档),在这里和那里,您都可以看到术语Kernel和getGlobalId。您仍然需要了解如何对GPU进行编程,但是可以以更类似于Java的方式使用GPGPU方法。此外,Aparapi提供了一种在OpenCL层上使用OpenGL上下文的简便方法-从而使数据完全保留在图形卡上-从而避免了内存延迟问题。

如果您需要进行许多独立的计算,请查看Aparapi。有许多使用并行计算的示例。

此外,还有一个名为TornadoVM的项目-它会自动将适当的计算从CPU传输到GPU,从而提供了开箱即用的批量优化功能。

发现


在许多应用程序中,GPU可以带来一些优势,但是您可以说仍然存在一些障碍。但是,Java和GPU可以一起做伟大的事情。在本文中,我仅涉及这个广泛的话题。我打算展示各种用于从Java访问GPU的高级选项和低级选项。探索这一领域将带来巨大的性能优势,特别是对于需要并行执行多个计算的复杂任务。

源链接

All Articles