适用场景
你已经有一个跑在 JVM 上的 Java 服务或离线计算程序,其中某段循环占了大部分 CPU 时间(典型如距离计算、向量相似度、图像逐像素处理、简单矩阵运算),希望通过 GPU 提速,又不想把代码重写成 C++/CUDA。TornadoVM 的思路是在 Java 方法上加注解,由一个 GraalVM 编译后端把这段方法编译成 PTX / OpenCL / SPIR-V / Metal 内核,再由 Java 侧的 TaskGraph 负责分配内存、传输数据、调度执行。本文覆盖从装环境、改写内核到做性能对照的完整流程。
要提醒的是:这段流程是否值得做,取决于计算密度。数据只有几千个元素、每次 PCIe 传输的时间比算的还多的工作负载,改动往往不会带来收益,甚至变慢。先用小规模数据把流程跑通,再决定是否继续推进。
环境与前置条件
- 操作系统:以 Linux x86_64 为主力支持平台;macOS(Apple Silicon,Metal 后端)和 Windows 支持程度有限,具体后端支持矩阵以官方文档当前版本为准。
- JDK:TornadoVM 需要与其匹配的 JDK 构建,官方安装脚本可以顺带下载对应 JDK;实践中常用 JDK 21 LTS 作为基线。版本对应关系请查官方文档,不要混用不同版本。
- GPU 与驱动:
- NVIDIA 显卡 + 官方驱动,使用 PTX 后端(需要 CUDA 运行库);
- Intel / AMD 显卡或集成显卡,走 OpenCL 后端(需要厂商 ICD);
- Apple Silicon 走 Metal 后端。
- 内存与显存:主机内存 16 GB 起更稳妥;显存入门 4 GB 可以跑通示例,做百万级样本的批量计算建议 8 GB 以上。
- 磁盘:TornadoVM + JDK + 构建产物,预留 5~10 GB。
- 前置检查工具:
nvidia-smi(NVIDIA)、clinfo(OpenCL 信息)、git、cmake/编译工具链(安装脚本需要本地编译部分组件)。
先做一次硬件确认:
```bash
nvidia-smi # 有输出即驱动基本正常
clinfo | head -n 40 # 能看到 Platform Name / Device Name 即 OpenCL 环境就绪
java -version # 记下版本,后面要和安装脚本的参数对齐
```
分步骤部署
第 1 步:获取 TornadoVM 源码或发行包
从官方代码托管仓库克隆源码(地址见官方文档),或者在发布页下载压缩包解压。安装过程会调用本地编译工具链,所以要把 build-essential、cmake 之类的工具先装好。
```bash
cd ~/work
git clone <官方仓库地址> TornadoVM
cd TornadoVM
ls bin/ # 应该能看到 tornadovm-installer 脚本
```
第 2 步:运行安装脚本,选定后端
安装脚本的参数在不同版本间会有调整,执行 ./bin/tornadovm-installer --help 以脚本自己的输出为准。常见用法如下:
```bash
以 OpenCL 后端为例
./bin/tornadovm-installer --jdk jdk21 --backend opencl
以 NVIDIA PTX 后端为例
./bin/tornadovm-installer --jdk jdk21 --backend ptx
```
这一步在做什么:下载/准备对应 JDK、编译 GraalVM 编译器和各后端 native 库、生成一个带版本号后缀的安装目录(形如 tornado-<backend>-<jdk>)。成功标志是脚本末尾打印安装路径,并且该目录下出现 bin/、share/java/ 等结构。
第 3 步:加载环境变量
```bash
source ./setvars.sh
```
这个脚本会设置 JAVA_HOME、TORNADOVM_HOME,并把 tornado 启动器加进 PATH。想让它长期生效,可以把 source 这一行写进 ~/.bashrc,注意写绝对路径。
第 4 步:确认设备可见
```bash
tornado --version
tornado --devices
```
--devices 会列出每个后端下可用的设备编号、名称、内存大小。只有这里列出了你要用的设备,后面的代码才能跑。 这一步失败时不要继续往下写代码,先解决驱动问题。
第 5 步:跑通官方自带示例
先用官方示例验证整条链路(示例类名以官方文档为准):
```bash
tornado -m tornado.examples/<官方示例模块名>
```
示例能正常结束,说明编译、调度、传输、回读整条链路都是通的。这一步是整个教程里最值得先做的一步,它把"环境问题"和"代码问题"隔离开了。
第 6 步:写第一个内核(向量加法)
新建 src/main/java/demo/VecAdd.java:
```java
package demo;
import uk.ac.manchester.tornado.api.TaskGraph;
import uk.ac.manchester.tornado.api.TornadoExecutionPlan;
import uk.ac.manchester.tornado.api.KernelContext;
import uk.ac.manchester.tornado.api.annotations.Parallel;
import uk.ac.manchester.tornado.api.enums.DataTransferMode;
public class VecAdd {
@Parallel
public static void vectorAdd(KernelContext ctx, float[] a, float[] b, float[] c) {
int i = ctx.globalIdx;
if (i < c.length) {
c[i] = a[i] + b[i];
}
}
public static void main(String[] args) {
int n = 1 << 20;
float[] a = new float[n];
float[] b = new float[n];
float[] c = new float[n];
for (int i = 0; i < n; i++) { a[i] = i; b[i] = i * 2; }
KernelContext ctx = new KernelContext();
TaskGraph graph = new TaskGraph("vadd")
.transferToDevice(DataTransferMode.EVERY_EXECUTION, a, b)
.task("t0", VecAdd::vectorAdd, ctx, a, b, c)
.transferToHost(DataTransferMode.EVERY_EXECUTION, c);
TornadoExecutionPlan plan = new TornadoExecutionPlan(graph.snapshot());
plan.execute();
System.out.println("c[1024] = " + c[1024]); // 期望 3072.0
}
}
```
要点说明:
- 内核方法必须是
public static,参数只放KernelContext和原始类型数组(或 TornadoVM 提供的数组包装类型,视版本而定); ctx.globalIdx是当前线程在全局网格中的序号,用它做下标。样本数远大于线程数时,改用 grid-stride 写法:for (int i = ctx.globalIdx; i < n; i += ctx.globalSize);TaskGraph里transferToDevice/transferToHost声明了数据的进出方向,snapshot()之后得到不可变的执行计划。
编译并运行:
```bash
jar 文件名随版本变化,先 ls 确认
ls $TORNADOVM_HOME/share/java/tornado/
javac -cp "$TORNADOVM_HOME/share/java/tornado/tornado-api.jar" \
-d target src/main/java/demo/VecAdd.java
tornado -cp target demo.VecAdd
```
想确认内核真的生成了,加 --printKernel 参数,启动器会把生成的后端代码打印出来:
```bash
tornado --printKernel -cp target demo.VecAdd
```
第 7 步:把真实热点循环改写成内核
假设你的热点是 k-means 的"样本分配"步骤:对每个样本,找最近的质心。纯 JVM 版本通常是二维数组 + 三层嵌套循环:
```java
// 原实现:points[i][j] 形式,内层逐维度比较
for (int i = 0; i < n; i++) {
float best = Float.MAX_VALUE; int bestK = 0;
for (int k = 0; k < K; k++) {
float d = 0;
for (int j = 0; j < D; j++) {
float diff = points[i][j] - centroids[k][j];
d += diff * diff;
}
if (d < best) { best = d; bestK = k; }
}
labels[i] = bestK;
}
```
改写成 GPU 内核,要做三件事:
1. 展平数据:GPU 内核里不要用二维数组,把 points 展平成长度 n*D 的一维 float[],下标自己算 i * D + j;质心同理展平成长度 K*D。
2. 把最外层循环换成线程号:i 来自 ctx.globalIdx,其余两层循环原样保留。
3. 去掉一切不支持的东西:内核里不要 new 对象、不要字符串拼接、不要 try/catch、不要 System.out.println。
```java
@Parallel
public static void assign(KernelContext ctx, float[] points, float[] centroids,
int[] labels, int n, int D, int K) {
int i = ctx.globalIdx;
if (i >= n) return;
int base = i * D;
float best = Float.MAX_VALUE;
int bestK = 0;
for (int k = 0; k < K; k++) {
int cbase = k * D;
float d = 0f;
for (int j = 0; j < D; j++) {
float diff = points[base + j] - centroids[cbase + j];
d += diff * diff;
}
if (d < best) { best = d; bestK = k; }
}
labels[i] = bestK;
}
```
调度侧:质心在一次迭代内不变,用 transferToDevice(DataTransferMode.FIRST_EXECUTION, ...) 只传第一次;样本和结果每次都传:
```java
TaskGraph graph = new TaskGraph("kmeans")
.transferToDevice(DataTransferMode.FIRST_EXECUTION, centroids)
.transferToDevice(DataTransferMode.EVERY_EXECUTION, points)
.task("assign", KMeans::assign, ctx, points, centroids, labels, n, D, K)
.transferToHost(DataTransferMode.EVERY_EXECUTION, labels);
TornadoExecutionPlan plan = new TornadoExecutionPlan(graph.snapshot());
```
如果热点里有求和、求最大值这类归约,TornadoVM 提供 @Reduce 注解声明归约操作(注解与枚举名在各版本间有差异,以官方文档为准),把它加在目标数组参数上即可,不要自己写原子操作。
第 8 步:做性能对照
对照要讲方法,否则数字没有意义:
- 同一份输入、同一台机器、同一个 JVM 堆配置;
- 必须预热:TornadoVM 第一次
execute()包含内核编译和设备初始化,往往几百毫秒甚至更久,前 20~50 次执行的结果要丢掉; - 分开计时:端到端时间(含传输)和纯内核时间要分开看,用
plan.withProfiler(ProfilerMode.CONSOLE)可以看到内核耗时和传输耗时; - 两个指标:吞吐(每秒处理样本数)和延迟(单次毫秒),都记录。
计时模板:
```java
int WARMUP = 30, ITERS = 200;
for (int i = 0; i < WARMUP; i++) plan.execute(); // 预热
long t0 = System.nanoTime();
for (int i = 0; i < ITERS; i++) plan.execute(); // execute 会阻塞到内核完成
long t1 = System.nanoTime();
double avgMs = (t1 - t0) / 1e6 / ITERS;
System.out.printf("avg = %.3f ms, throughput = %.1f samples/s%n", avgMs, n / (avgMs / 1000.0));
```
怎么解读结果:
- 小数据量(几万元素以内):常见情况是 GPU 版持平或更慢,传输和启动开销占主导。
- 数据量到百万级、每元素计算量大:常见情况是 GPU 开始领先,领先幅度随计算密度上升。
float通常比double在消费级显卡上快不少,如果精度允许,先试float。- 结果对不上时:先确认是浮点累加顺序导致的误差,再怀疑逻辑;给比对加个容差(如
1e-4相对误差)。
验证部署是否成功
按顺序跑这三条,全部符合预期才算部署完成:
```bash
1. 启动器可用
tornado --version
预期:打印版本信息,无异常堆栈
2. 设备可见
tornado --devices
预期:列出至少一个设备,含名称与内存大小
3. 自写程序跑通并输出正确结果
tornado -cp target demo.VecAdd
预期:c[1024] = 3072.0
```
再加一条正确性验证:用随机数据分别跑 JVM 版本和 GPU 版本,逐元素比对(浮点用容差),打印最大偏差。这条验证要一直保留在项目里,以后升级 TornadoVM 或换显卡时,它是你的第一道防线。
常见报错与解决
1. java.lang.NoClassDefFoundError: uk/ac/manchester/tornado/api/TaskGraph
→ 原因:编译或运行时 classpath 里没有 tornado-api jar,或者 setvars.sh 没 source。
→ 解决:
```bash
source ./setvars.sh
echo $TORNADOVM_HOME
ls $TORNADOVM_HOME/share/java/tornado/
javac -cp "$TORNADOVM_HOME/share/java/tornado/tornado-api.jar" -d target src/main/java/demo/VecAdd.java
```
2. tornado --devices 输出为空,或报 No OpenCL platforms found / No devices found
→ 原因:显卡驱动或 OpenCL ICD 加载器缺失;容器里没做设备透传;用户不在 video/render 组。
→ 解决:
```bash
sudo apt-get install -y ocl-icd-libopencl1 clinfo
clinfo | head -n 40 # 确认平台与设备是否被识别
sudo usermod -aG video,render $USER # 重新登录生效
nvidia-smi # NVIDIA 场景先确认驱动正常
容器场景需加 --gpus all 或 --device /dev/dri
```
3. java.lang.UnsatisfiedLinkError: libcuda.so / libOpenCL.so: cannot open shared object file
→ 原因:native 库不在动态链接器搜索路径。
→ 解决:
```bash
echo $LD_LIBRARY_PATH
sudo ldconfig # 系统库场景
export LD_LIBRARY_PATH=$TORNADOVM_HOME/lib:$LD_LIBRARY_PATH # 指向安装目录下的 so
```
4. 编译或首次执行报 TornadoBailoutException / ... not supported
→ 原因:内核方法里出现了后端不支持的东西,常见是对象创建、字符串、异常处理、lambda/接口调用、部分 Math 方法、非原始类型数组。
→ 解决:把内核改写成"只做算术 + 原始类型数组读写"的纯函数,循环和下标自己算;把打印、日志、异常处理全部搬到内核外面。用 --printKernel 看生成的后端代码定位到具体行。
5. OutOfMemoryError / 设备内存不足
→ 原因:一次性传输的数组超过显存;或者每次执行都 transferToDevice(EVERY_EXECUTION) 造成设备缓冲区反复增长。
→ 解决:
```bash
nvidia-smi # 看显存占用
```
把大数据切块处理,只传输本批数据;不变量改成 FIRST_EXECUTION;循环内不再新建数组,复用同一份设备缓冲。
后续维护
备份:需要备份的是三样东西——内核源码与 TaskGraph 定义、安装时用的脚本参数(--jdk / --backend 的选择)、以及 setvars.sh 涉及的环境变量。安装目录本身可以重装得到,不必进备份策略;但那份"JVM 参考实现 + 正确性比对脚本"一定要留着,它比任何文档都可靠。
升级:新版本装到新目录、用新 shell 加载,旧目录保留做回滚。升级后立刻跑一次正确性比对和性能对照,重点看两件事:API 是否有变化(TaskGraph、KernelContext 相关签名在版本间调整过)、以及内核耗时是否出现回退。版本与后端、JDK 的对应关系以官方文档当前版本为准。
日志与监控:调试期用 plan.withProfiler(ProfilerMode.CONSOLE) 看内核耗时和传输耗时,注意 profiler 本身有开销,不要在生产常开。运行期关注 nvidia-smi dmon(GPU 利用率和显存)与主机侧的 GC 日志,确认瓶颈到底在 GPU、在传输、还是在 JVM。若发现 GPU 利用率长期很低而端到端延迟高,先怀疑传输次数太多,而不是内核写得不好。
工程化建议:把"数据展平"和"内核调用"封装在一个类里,对外只暴露普通 Java 方法;在配置里留一个开关,检测不到可用设备时自动走 CPU 实现。这样即使某台机器驱动没装好,服务也能降级运行,而不是直接起不来。
