榨干机器硬件性能: JVM&GPU

随着过去几年机器学习 模型训练,以及区块链领域中(币圈和链圈) 对计算力的要求,人们对硬件计算速度的要求越来越高。很自然的,作为传统科学计算领域, 基于GPU的加速也获得了大量的关注:Tensorflow 底层利用GPU来计算;大量的挖矿软件( e.g., ethminer)直接对GPU暴力使用。在16,17年,由于币价格的爆发,对GPU显卡挖矿的需求,直接导致Nvida的股票价格翻了好几倍。

一 需求

然而很遗憾的是,目前GPU显卡API操作,主要是基于两大框架: Opencl 和CUDA。这两种框架实际上需要开发人员对框架底层有大量的了解, 主要体现在:

  1. 自己需要在底层实现kernel函数
  2. 自己申请,管理GPU的内存,并负责 Host memory和GPU memory的通讯。
  3. 自己去手动优化Kernle 方法的实现,比如基于数据类型的优化。

由于这种复杂性,GPU的应用(e.g, coin mining, Machine Learning) 对高层语言开发者, 比如作为目前最通用的编程平台JVM(Java或者其他JVM语言), 是一件非常复杂的实现. 开发者只能通过自己写JNI的方式,对GPU做封装,然后自己在上层通过Java调用(这种方式对于绝大部分的程序员来说,可行性不高)。

本文的目的并不真正深入J9对 GPU的具体技术细节,更多的还是从上层的科普角度出发。自己在14,15年,以及后来的17年了解 参与,讨论了部分J9这方面的调研和工作,包括后来15年在pppj也接触了Rice&IBM Tokyo RD那边的Akihiro和Kazuaki等博士 关于这方面的沟通,理了几遍代码。

首先,需要特别澄清的是:

  1. JVM Specification 并没有制定JVM要对GPU的支持。这个Feature只是IBM J9 Java8自己一个特有的属性。(Hotspot不清楚,谁知道的喊声?)
  2. J9 Java 8是最早开始支持 Cuda GPU的,至少我当时15年是,今天可能还有其它家的也支持(待考证)。
  3. 本文也区分另外一个Java & GPU的开源项目 aparapi 。Aparapi是在Java 语言中支持GPU, 但是需要开发者自己操作类似Kernel函数的,开发者需要知道GPu开发理论知识背景。而J9 不需要高层感知底下任何关于CUDA/OPENCL等知识, 它是直接在Runtime这一层去支持的。

本文阅读需要一些背景: 

  • 简单知道JVM以及bytecode
  • 简单知道GPU以及CUDA是什么,以及知道为什么会有GPU这玩意
  • 知道JIT 以及编译器是用来干什么的。

二 大概原理

J9 对 GPU 的支持主要是在以下两个方面

  1. CUDA GPU (或者严格说基于Nvida家的CUDA框架)。
  2. 支持仅限于 Java 8中的Stream API. 比如:
     LongStream.range(low, up).parallel().forEach(i -> <lambda>)

Java8中的 Stream API 是从高层应用中去抽象出来了一个Parallel ,而GPU本身是在物理硬件上实现了 Same Instruction Multiple Data (SIMD)的数据并行。所以,直接通过GPU实现上层并行的逻辑是一个很自然而然的想法。

大概的框架流程图如下:

1, J9 Interpreter 解释bytecode指令的时候,检测 并识别出来 Stream API中的foreach (for_loops) 以及lambda closure (这中间涉及到Invokedynamic).

2, JIT compiler (TR in J9) 此时进行优化,将产生两部分代码: Host machine code, and target GPU code(NVVM IR)。

  • Host Machine Code (i.e., CPU here)运行的,这部分将包括:在GPU申请内存,GPU-CPU 内存之间的相互复制,调用Nvida的driver 来编译,启动NVVM IR在GPU上的执行。
  • NVVM IR: 严格上来说是对应着lambda closure, 这部分最后会有变成Parallel Thread Execution (PTX) 指令,并最终由Nvida编译器生成具体GPU上的指令。综合起来,这块的转化为: 
bytecode-> NVVM IR->PTX instruction-> Nvida GPU  instruction.

三 优化方案

直接利用GPU实现上层Java 语言的并行,能提高上层应用的运效率。这个是在比较理想的状况下能够成立的。

所以,在对lambda closure-> NVVM转化的时候,JVM还需要做一些其他方面的优化。对于计算能力,或者说 对于计算速度的提升,或者说 在考虑榨干 现有硬件的条件下,一般是从两个方面去考虑:内存,指令。

3.1 内存 (Array aligning)

通过内存优化Runtime的性能,这本身是一个非常大的范围: 比如通过memory management, GC, cache, locality等种种。细说起来需要一本书来完成。

J9对GPU的支持中,我们说的内存优化是指的是GPU中的array(i.e., device memory)的处理, 而非host memory中的array。在cuda原先memory allocation方法中 。原先的管理方式是直接讲array object (array header and array body) 放入连续的一块地址中(starting from 0)。在新的优化,实际上重新对array object进行placement 使得body从128 的整数位(e.g., index 31) 开始。如下图:

这么做的理由主要是基于两种条件: a) 内存的操作更多的是对元素中read and write,对array header的操作并没有那么频繁(对比而言), 所以header的读写的重要性不高; b)  在128 index对齐后,读或者写可以在一个GPU指令周期内完成,而前者需要两个指令周期(第一个: 0-127, 第二个周期: 128-384 ).

当然,除了array aligning, J9 也对内存其他方面进行的优化(不细说了),比如基于jit对内存region 读写进行识别, 对指令进行re-order, 以达到high memory cache hit in GPU, 又或者减少不必要的复制指令 for copying from GPU memory to host memory,有或对 array header elimination.

3.2 指令优化(Lambda Closure Optimization)

指令优化属于传统的JIT 编译器方面的内容, 所以传统的JIT optimization(e.g. , Deadcode elimination, )基本上都可以拿过来用,毕竟lambda closure里面也是bytecode。这一部分就不需要细讲。

可以拿出来说的是 cross lambda method calling. Exactly speaking, the caller is inside of lambda while the callee is out of lambda closure. 比如下面这个例子:

public class Sample{
    public void myMethod(args...){}
    public void anotherMethod(){
        intSteam...forEach( i ->{
                myMethod(receiver, other-args);
                obj.anotherMethod();..
        });
    }
    public void anotherMethod(){}
} 

在这个例子中, myMethod 和anotherMethod调用都是跨lambda closure (直白点:myMethod, anotherMethod 都不是closure内的方法). 这就产生一个问题或者中间有两个gap:

a) GPU上高速执行的代码调用CPU上低速执行的代码 -_-!!

b) GPU上还要在runtime 决定 方法的具体实现方法(Java上若无显示标注方法是 invokevirtiual)。 =_=

 为了决定具体某个方法的实现,the receiver of a method call 和 virtual method table 需要在runtime时候从host memory 复制到device memory中去。为了使得程序跑的正确,这两个Gap将会kill GPU的效率。

  To resolve both Gaps, J9中JIT 编译器直接进行inline Caching (IC)优化(Akihiro认为是Method Inling). 在VM中,Inline Caching之前是SELF language中(For detail, please refer Dr Urs Hölzle’s PhD thesis [3])。

  简单的归纳下就是在生成NVVM的时候,先直接假定 the type of method call receiver是哪一种,然后将被调用的方法实现直接inline到方法调用处。为了生成代码的正确性,在原先call site之前插入一个guard (中文不知道如何翻译??)进行检测。若检测没有成功,则jmp到原先低速的方法(也就是这个时候GPU停下来去要求CPU执行:看当前receiver具体是哪路神仙(这个GPU也可以做,但是需要先copy from host memory to device memory),CPU执行callee’s method). 所以对于上面lambda内 obj.anotherMethod(); 变成了

if(receiver is someType){
    SomeType's anotherMethod real Implementation and will be executed at GPU kernel.
}else{
    invoke receiver.anotherMethod(..)  //execute on CPU
}

四 附注:

  • 性能benchmark结果可以参考文章4.
  • 本文的图来自4,5
  • 转载请保留原作者的名字

【1】Project Syncleus/aparapi

【2】CUDA Toolkit Documentation

【3】Urs Hölzle, Adaptive Optimization for Self: Reconciling High Performance with Exploratory Programming. Ph.D. thesis, Stanford, CA, USA, 1995, UMI Order No. GAX95-12396.

【4】Kazuaki Ishizaki, Akihiro Hayashi, Gita Koblents and Vivek Sarkar, Compiling and Optimizing Java 8 Programs for GPU Execution/

【5】Akihiro Hayashi, Kazuaki Ishizaki, and Gita Koblents, Machine-Learning-based Performance Heuristics for Runtime CPU/GPU Selection. PPPJ 2015.

来源:知乎 www.zhihu.com

作者:Shijie XU

【知乎日报】千万用户的选择,做朋友圈里的新鲜事分享大牛。
点击下载