跳过正文
用代码反射把 Java 内核送到 GPU:HAT 与 Project Babylon 的工程切面
  1. 文章/

用代码反射把 Java 内核送到 GPU:HAT 与 Project Babylon 的工程切面

·5235 字·11 分钟
NeatGuyCoding
作者
NeatGuyCoding

用代码反射把 Java 内核送到 GPU:HAT 与 Project Babylon 的工程切面
#

要在 JVM 里写并行计算,常见路径是 parallelStream 或结构化并发——它们仍受 CPU 线程数约束。若要把同一份业务逻辑落到 GPU 的数万线程上,传统做法是手写 CUDA/OpenCL,再用 JNI 粘合;这对 Java 团队意味着第二套语言、第二套构建,且平台绑定明显。Project BabylonCode ReflectionHAT(Heterogeneous Accelerator Toolkit) 走另一条路:保留 Java 写法,在编译期生成可变换的 SSA 代码模型,经多阶段 IR 变换后交给可插拔后端(OpenCL、CUDA、纯 Java 顺序执行等)。

本文从工程视角拆解两条主线:(1)语义保真——在 AST 与字节码之间取得可变换的 IR;(2)设备可消费——把数组访问、过程间调用、buffer 读写模式降到后端与 FFI 能理解的 invoke 与访问类型。文中性能数字、IR 等价性等若无官方基准,一律标明边界;API 以 OpenJDK 孵化稿与 openjdk/babyloncode-reflection 分支为准。

Reflecting on HAT: A Project Babylon Case Study — Ruby Chen,Java Platform Group


为什么需要介于 AST 与字节码之间的代码模型
#

AST 保留过多句法噪声,难以做跨语言 lowering;字节码则为 JVM 执行优化,丢失大量源级语义(循环变 goto、窄类型提升等)。JEP draft 8361105Code Models 文章code model 定位为「保真度介于 AST 与 bytecode 之间」的不可变树:元素为 operation / body / block,采用 SSA,设计受 MLIR/LLVM 风格编译器影响。仅标注 @Reflect(孵化模块 jdk.incubator.code)的方法或 lambda 会由 javac 把模型写入 class file,运行时通过 Op.ofMethod 等 API 取出根节点 CoreOp.FuncOp,再 transform 生成新模型并可回写为字节码。

机制与约束:每个 block 必须以终止 op(return、分支等)结束;操作用 %n 引用 SSA 结果。变换是「遍历旧模型 + 用 block builder 构造新模型」,模型本身不可变。

最小示例(与 JEP 8361105 示例 同族):

import jdk.incubator.code.Op;
import jdk.incubator.code.Reflect;
import jdk.incubator.code.dialect.core.CoreOp;

@Reflect
static int squareKernel(int i, int[] array) {
    array[i] = array[i] * array[i];
    return 0;
}

// 运行时:Optional<CoreOp.FuncOp> m = Op.ofMethod(Square.class.getDeclaredMethod("squareKernel", ...));
// m.ifPresent(f -> f.transform(transformer));

遍历与构造:对已有 FuncOpelements() 流式访问 block/body/op;新建逻辑时用 Block.Builder 追加 op,并保证每个 block 以终止 op 结束。打印 SSA 文本(func @"squareit" (%0 : ...) -> { %1 = var.load ...; return %7; })是调试变换最可读的方式,与 IDE 里看到的 Java 源码并非一一逐行对应。

常见误区:把 code model 当成 AST 或反汇编 bytecode 的替代品;未标 @ReflectOp.ofMethod 返回 empty,后续图构建会直接失败;在 transform 中直接修改旧节点——应始终产出新 FuncOp

Code Reflection: Purpose of Code Models — AST 过高、字节码过低,Code model 为 goldilocks spot

Dissecting the Code Model: Bodies and Blocks — func、var.load、invoke、return 的 SSA 文本形态

HAT: Code Model — @Reflect 标注的 squareKernel 与宿主 square 分发结构

Mermaid diagram 1


HAT 栈:Panama 内存、代码反射与可插拔后端
#

HAT 用 Panama Foreign Function & Memory APIMemorySegmentMemoryLayout)描述堆外 buffer,用 Babylon 代码反射读取/变换 kernel 的 FuncOp,再通过 Accelerator / ComputeContextNDRange 派发到具体后端。官方 HAT READMEmatmul 文章 列出的运行后端包括 ffi-openclffi-cudajava-seq(CPU 顺序,便于调试)等;构建产物形如 hat-backend-ffi-opencl-1.0.jar。PTX、SPIR-V 在 Babylon 路线文中作为 GPU 代码生成探索出现,独立 SPIR-V/HIP 运行后端在已核对 README/Config 中未列出——若听到 HIP 后端,应视为未验证扩展名。

为什么:把「写什么」与「跑在哪」解耦,避免每个项目重写 JNI + 厂商 runtime 胶水。

怎么做(仓库 code-reflection 分支可复现):

cd hat
java @.bld
java @.run java-seq life
java @.run ffi-opencl life

底层 JVM 通常需要:--enable-preview --add-modules=jdk.incubator.code --enable-native-access=ALL-UNNAMED(见 README)。

hat.javabld 目标会编译 hat-corehat-optkl、各 hat-backend-ffi-*,并触发 cmake 构建 native 封装;终端 OCR 中反复出现的 We will need jextract to create jextracted backends 指向 OpenCL/OpenGL 等需要头文件绑定的路径,与 kernel IR 算法无关,但会挡住「第一次 clone 就跑 demo」的预期。

常见误区:以为换后端只改一个字符串即可而不重建 native 依赖;忽略预览/孵化模块开关导致 Op 类找不到;把 Config 里的 PTX 选项误解为独立「PTX 后端」——它表示 CUDA 路径可传递 PTX 而非 C99 源码(见 Config 注释)。

cmake hat-backend-ffi-1.0 — 多模块 jar 与 hat-example 一并编译

java @.bld — compiled 85 files to hat-core-1.0.jar 与 cmake hat-cmake-info-opencl

Mermaid diagram 2


三层边界:Kernel、Compute 与宿主
#

职责@Reflect(现行实现)
Kernel每线程逻辑;通过 KernelContextgix/giy 等取线程坐标必须
ComputeNDRangedispatchKernel必须ComputeContext 构造时 Op.ofMethod 为空会抛错)
宿主分配 buffer、创建 Accelerator、I/O通常不需要

演讲者观点称 Compute「通常不必标 @Reflect」——与当前 ComputeContext.java 及 Life/matmul 示例 不一致;凡走 accelerator.compute(...) 构建 compute 图的路径,Compute 入口在 code-reflection 分支上需要 @Reflect。文档强调对 compute 标 @Reflect 还便于检查可达 kernel 与数据流。

最小骨架(概念合并 README 与 Life 示例):

@Reflect static void squareKernel(int i, @RW S32Array a) {
    a.array(i, a.array(i) * a.array(i));
}

@Reflect static void squareCompute(ComputeContext cc, @RW S32Array a) {
    cc.dispatchKernel(NDRange.of1D(a.length()), (KernelContext kc, @RW S32Array buf) ->
        squareKernel((int) kc.gix(), buf));
}

参数上的 @RO / @RW / @WOMappableIface)表达程序员意图;自动 buffer tagger 在内联后从 invoke 推断的 AccessType 会与之对照或补充,供 FFI 决定主机侧 staging。Kernel 内应只保留可翻译的算术、分支与 buffer 访问;Control 一类宿主语义对象在 Life 示例中作为只读上下文传入,不参与设备写回。

常见误区:只给 kernel 标 @Reflect 却省略 compute;在 compute 里写重型 I/O 并期望被设备翻译;把「不必标 Reflect」的口语当成现行 API 合同。


数组视图:把 array.load 降到 buffer 的 invoke
#

设备后端天然理解「对某接口的 getter/setter 调用」,而不是 Java 二维数组的连续 array.load 链。HAT 用 array view 阶段(HATArrayViewPhase)在 SSA 上匹配 JavaOp.ArrayAccessOp.ArrayLoadOp / ArrayStoreOp,把索引 convlong,再替换为对 hat.buffer.*(如 S32Array::array(long):int)的 invoke

为什么:程序员侧可写 cellGrid.cell(idx) 或 matmul 中的 array(i),IR 侧统一为可分析的 invoke,与 Interface Mapper 一致。

机制:对 2dArray[kc.gix][kc.giy],未变换前 code model 会展开多步 pointer/array load(演讲者观点:维数越高链越长)。变换需识别「哪一次 load 产出最终标量」,沿 operand 收集下标(幻灯片 entry.transform + switch on ArrayLoadOp)。

Code Model: Using Arrays — 2D 访问生成多行 pointer arithmetic,需压缩 code model

Transformation: Example — case ArrayLoadOp,第二次 array load 返回 int 值

Code Model: Using Arrays — array.load 经 conv 变为 invoke S32Array::array(long):int

幻灯片中的变换伪代码与 JavaOp 密封层次一致:先 case ArrayLoadOp,沿 %12%14 等 operand 回溯,判定「第二次 load 才返回 int 标量」这类模式,再用 core reflection 解析正确的 getter 签名,把后续使用点改接到新的 %r = invoke ... 结果上。Store 路径对称处理。若跳过 conv,长索引在设备侧可能与 layout 假设不一致。

常见误区:以为接口名在所有 buffer 类型上统一叫 array()——Life 的 CellGrid 生成代码用 cell(long),与 F32Array.array(long) 并存;以具体 Iface 映射为准;在 transform 里硬编码设备 API 字符串而非走 Iface 映射表。


HAT dialect 与多维 IR 压缩
#

在 array view 之后,HAT 引入自定义 op(如 HATPtrOp)组成 HAT dialect,由 HATPhase 多阶段执行(含 memory、vector 等)。演讲者观点:经 array view 与 pointer 折叠后,设备端生成代码可与未使用 view 时语义等价——未在本文环境中运行 IR diff 验证

怎么做(变换入口形态,与 JEP FuncOp.transform 一致):

entry.transform(entry.funcName(), (bb, op) -> {
    switch (op) {
        case JavaOp.ArrayAccessOp.ArrayLoadOp alop -> {
            // 识别最终标量 load,收集下标,发射 pointer / invoke
        }
        default -> { /* copy or fold */ }
    }
});

KernelCallGraph 在 inline 之后调用 HATTier.transform(HATTier.KernelPhases, ...),把 array view、memory、vector 等阶段串成固定管线,而不是让应用代码随意拼 phase。对维护者而言,新增一种设备目标通常是加 backend 模块 与 lowering,而不是 fork 整条 Java 前端。

常见误区:在 transform 中手写设备代码字符串;应在 IR 层完成语义保留的 lowering,再交后端;跳过 phase 顺序导致 pointer op 折叠在 array view 之前执行。


内联与 buffer 标注:过程间追踪降为单遍
#

主机–设备 buffer 拷贝 往往贵于 kernel 本身。HAT 需要每个参数的 read-only / write-only / read-writeAccessType)以缩小传输。Kernel 若调用 helper(buf),跨调用的形参–buffer 映射会很快变复杂。

策略KernelCallGraph 与演讲一致):先 SSA.transform,再循环 Inliner.inline 直到无更多可内联 InvokeOp;对 内联后的单一 FuncOpBufferTagger;最后执行 HATTier.KernelPhases

内联约束:callee 含多个 return 时不能直接拼接——需在 callee 模型内合并为单一 return block,把 return 换成 branch(官方 Inliner Javadoc 同述)。

Buffer Tagging Across kernel calls — inline method invocations,single pass 分析

Code Reflection: Inliner — replace the return op with a branch to the return block

Buffer tagger 规则(已核对源码逻辑):

  • 有返回值的 getter invoke → 读(RO 或升级为 RW)
  • void setter invoke → 写
  • 同一 buffer 先读后写 → RW

Buffer Tagging Example — getter on s32Array,tags read-only

Buffer Tagging Example — setter on s32Array,Tag read-write

内联器处理 return 的典型流程(与孵化模块 Inliner 一致):遍历 callee body 的 op;若遇退出当前方法的 ReturnOp,先 compute 汇合多个 return 块,必要时新建带 block parameter 的 return block,再把原 return 替换为 branch;否则把 op append 到 caller 的 Block.Builder。这样 buffer tagger 看到的是单一、线性的 invoke 序列,而不是跨函数的虚调用图。

BufferTagger.getAccessList 返回与 内联后入口 block 形参 顺序对应的 AccessType 列表,供 optkl/FFI 映射到 OpenCL/CUDA 参数修饰。getter 带返回值记读;void setter 记写;同一 IfaceValue 先读后写升为 RWisReference 等路径避免把「仅传递引用」误判为读。

常见误区:手工在每个参数写 @RW 却与 IR 实际访问矛盾;未内联 helper 就期望 tagger 跨调用边传播;不同 SSA 槽(如 %4%5)代表不同 buffer 实例,不能合并。


运行时:HAT=MINIMIZE_COPIES 与后端选择
#

Config.MINIMIZE_COPIES(别名 MC)通过环境变量 HAT 或系统属性读取,注释标明主要作用于 FFI 路径以减少拷贝。演示中 Game of Life 用 java-seq 极慢,ffi-opencl 明显加速;再设 HAT=MINIMIZE_COPIES 时主讲称相对前一 OpenCL 运行约 ——演讲者现场演示观点,无官方 benchmark 表

export HAT=MINIMIZE_COPIES
java @.run ffi-opencl life

注意hat.java 帮助里曾出现 -DHAT=MINIMIZE_BUFFERS 字样,与 Config 不符;以 MINIMIZE_COPIES 为准。

java @.run java-seq life — We will need jextract to create jextracted backends

–enable-preview –add-modules=jdk.incubator.code –enable-native-access=ALL-UNNAMED

演示终端在 java @.run java-seq life 时可能打印 Java backend received computeContext该字符串未在本次核对的源码快照中定位)。java-seq 的价值是在同一套 @Reflect kernel 上验证 IR 与逻辑,而非性能基线;OpenCL 再配合 MINIMIZE_COPIES 才检验「标注 → 少拷贝」。

常见误区:在 java-seq 上期待拷贝优化;minimizeCopies() 面向 FFI;把演示倍速当作通用 SLA;使用帮助里的 MINIMIZE_BUFFERS 拼写。


Game of Life:同一规则,两种表面语法
#

Conway B3/S23 在 HAT 示例 life/Main.javaComputeLife 中实现:lifePerIdx 统计八邻域后

((count == 3) || ((count == 2) && (cell == ALIVE))) ? ALIVE : DEAD

演示可先写 val(cellGrid, ...) 辅助访问,再改为更接近直觉的 cellGrid.cell(...) / array view演讲者观点称重编译后行为一致,说明变换管线吸收语法差异。仓库当前以 cell(long) 与嵌入的 codeLifePerIdx OpenCL 片段对照为主,未在源码中并排保留两套等价路径供 diff

public static void lifePerIdx(int idx, Control control, CellGrid cellGrid)

@Reflect lifePerIdx — byte cell = cellGrid.array(idx: idx + from)

codeLifePerIdx — count == 3 与 Conway 存活规则字符串

life(KernelContext kc, ...) 在 NDRange 上对每个 gix 调用 lifePerIdx;八邻域读取在源码层可展开为大量 cellGrid.cell(...)val(CLWrapCellGrid, ...) 字符串(与嵌入的 codeLifePerIdx 对照),编译后应收敛为同一套设备访问模式。演讲者观点称两种写法重编译后行为一致——若你要验证,应在本地对两种源码各跑一次 golden 网格比对,而不是依赖幻灯片口述。

IDE 中可见 ComputeLife 嵌套类、@Reflecthat-05-accelerator-compute.md 文档并列,说明示例同时服务「Accelerator 计算模型」教学与 IR 实验;生产集成应锁定依赖的 Babylon JDK 构建,而非系统默认 JDK。

常见误区:把 val(...) 当成运行时函数留在设备路径上;在 kernel 内保留无法内联的 JDK 类库调用;未重建 hat-example-life-1.0.jar 就切换 array view 语法。


收束:代码反射在 HAT 中的位置
#

HAT 不是「把 Java 编译成 CUDA 字符串」的玩具,而是一条可核对的工程链:@Reflect 门槛 → SSA FuncOp → array view / HAT dialect → 内联 + buffer tagger → 可插拔 FFI 后端,底下是 Panama 的内存模型。对团队而言,价值在于用同一套 Java kernel 源码做 CPU 调试(java-seq)与 OpenCL/CUDA 加速,并用 MINIMIZE_COPIES 把 IR 上的 RO/RW 事实接到传输层——前提是接受孵化 API、预览特性与本地 native 构建成本。

若你正在评估是否引入这条栈,可按以下顺序自检:(1)本地 Babylon JDK 能否加载 jdk.incubator.code;(2)java @.bld 是否生成所需 hat-backend-ffi-*;(3)java-seq 能否跑通目标示例;(4)再切 ffi-openclHAT=MINIMIZE_COPIES 做传输层对比。前三步失败时,优先查 jextract/cmake,而不是改 kernel 算法。

未验证边界速览:≈2× 加速仅演示口述;HIP 后端Java backend received computeContext 日志串未在已抓取源码定位;array view 与无 view 设备代码等价为演讲者观点;Compute 可不标 @Reflect 与现行 ComputeContext 冲突。生产采纳前应在目标 JDK 分支上对照 JEP 与 openjdk/babylon 标签自行跑通 hat 示例与测试(如 TestArrayView 一类)。


参考与延伸阅读
#

相关文章