翻译:你的Java后端经验,就是AI Infra最好的预习材料。


一、你先要理解一件事

AI Infra 不是什么全新的知识体系。它是一个同构系统,只是运行在 GPU 上。

你的舒适区                    AI Infra 的真相
───────────────────────────────────────────────────────
Netty EventLoop 接收请求      SM(Streaming Multiprocessor)接收 Warp
ByteBuf 池化管理内存          PagedAttention 分块管理显存
epoll 事件驱动 IO            CUDA Stream 异步执行
JVM 的 GC 分代回收           KV Cache 的 Block Eviction
Kafka 的分区与批量            NCCL 的 Ring AllReduce
Tomcat 的连接池              Triton Server 的 Request Queue
Spring 的 AOP 代理            CUDA Graph 的 Kernel 捕获
CompletableFuture 异步编排    CUDA Stream 的依赖管理
JMX / Metrics 监控            Nsight Systems 性能分析

一模一样的问题,一模一样的抽象层次,区别只在 Scale 和物理介质。

这篇文章不会给你画路线图。这篇文章会带你看真实的代码,看同一个问题在两个世界的不同解法


二、ByteBuf 和 Tensor:同一个内存管理问题的两个版本

2.1 从一段 Netty 代码说起

// Netty 中你每天都在写的代码
ByteBuf buf = PooledByteBufAllocator.DEFAULT.directBuffer(4096);
// 你分配了 4096 字节的堆外内存
buf.writeBytes(data);
// ...
buf.release();  // 引用计数减一,池里回收

这个模式对你来说稀松平常。但你有没有想过——这段代码和 vLLM 分配一块 GPU 显存放在本质上是同一件事

Netty PooledByteBufAllocator           vLLM Block Manager
───────────────────────────────         ────────────────────
arena(内存区域)                        GPU 显存池(预分配)
chunk(大块内存,16MB)                  GPU block(每个 16KB-64KB)
page(内存页,8KB)                      token block(每个 16 个 token)
PoolThreadCache(线程级缓存)            Block Table(每个请求的页表)
引用计数(refCnt)                       Block 引用计数(共享 prefix cache)

2.2 看看 vLLM 的源码

# vllm/block_manager.py — 简化后的核心逻辑
class BlockAllocator:
    """这和 Netty 的 PoolArena 是同一类设计。"""
    
    def __init__(self, num_blocks: int):
        # 预分配所有 block,类似 Netty 的 PoolChunkList
        self.free_blocks = list(range(num_blocks))
        self.refcounts = [0] * num_blocks  # 引用计数,和 ByteBuf.refCnt() 一样
    
    def allocate(self) -> int:
        """从空闲列表拿一个 block。"""
        block = self.free_blocks.pop()
        self.refcounts[block] = 1
        return block
    
    def free(self, block: int):
        """释放并回收。"""
        self.refcounts[block] -= 1
        if self.refcounts[block] == 0:  # 参考 ByteBuf.release() 的 return true
            self.free_blocks.append(block)
    
    def allocate_mutiple(self, num_blocks: int) -> List[int]:
        """批量分配。和 PooledByteBufAllocator 的 allocate() 一样走批量路径。"""
        return [self.allocate() for _ in range(num_blocks)]

你的 Netty 知识直接等价:

你在用 PooledByteBufAllocator 时已经理解的事情:

  1. 预分配比按需分配快 — vLLM 启动时预分配所有显存 block
  2. 引用计数避免内存泄漏 — ByteBuf 的 refCnt() = block 的 refcounts[]
  3. 按大小分层管理 — Netty 的 Tiny/Small/Normal/Huge = vLLM 的 token block / sequence block / swap block
  4. ThreadLocal 减少竞争 — Netty 的 PoolThreadCache = vLLM 的 per-sequence block table

不同在哪:

  • vLLM 要处理 GPU 显存的碎片化——GPU 没有 compacting GC,碎片化是显存管理的头号难题
  • Netty 的 ByteBuf 可以 resize,GPU 显存分配后不能动态增长
  • GPU block 释放要等 kernel 执行完(异步),ByteBuf 释放是同步的

2.3 生产中的真实问题

你在 Java 后端的生产环境可能遇到过:

“Full GC 后老年代降不下来,MAT 分析发现是 ByteBuf 引用没释放”

vLLM 部署时完全一样的场景:

“推理服务跑了 2 小时后 OOM(显存溢出)。排查发现某个请求异常断开后,对应的 KV Cache block 的引用计数没有归零,Block Manager 永远不会回收这些 block”

排查思路完全相同:

  1. 定位所有引用的持有者(Java: GC Root → GPU: reference graph)
  2. 追踪引用链条(Java: heap dump → GPU: block table dump)
  3. 找到未释放的真实原因(Java: 回调没执行 → GPU: 客户端 gRPC 流异常断开)

2.4 面试视角

面试题:“vLLM 的 PagedAttention 显存管理是怎么解决碎片化问题的?”

你可以这样回答(面试官知道你是 Java 背景,他期待看到的是类比能力):

“它本质上在做一件事——把 GPU 显存从 JVM 的连续堆模式改成操作系统的分页模式。

类比我们的 Netty 应用:如果我们给每个请求分配一大块连续堆外内存,并发多了就容易碎片化。解决方案是预分配固定大小的 block,用 block table 做虚拟地址到物理地址的映射——这就是 PagedAttention。

具体做法:

  1. 所有显存预分成 16KB 的 block(类似 Netty 的 PoolChunk 分 page)
  2. 每个请求维护一个 block table(类似虚拟内存的页表)
  3. 逻辑上连续的 KV Cache,物理上可以是离散的 block
  4. Block 复用和 Copy-on-Write 共享 prefix(类似 JVM 的 String.intern)

这样显存利用率从 40% 提升到了 95% 以上。关键手段是消除外部碎片——这和 JVM 的 G1GC 用 Region 解决 CMS 碎片问题是同一个思路。”


三、epoll → CUDA Stream:事件驱动的异步执行

3.1 先看 epoll

你使用 Netty 时,NioEventLoop 的 main loop 是这样的:

// Netty 的 NioEventLoop.java — 简化版
while (true) {
    // 1. 等待事件
    selector.select();                     // ← epoll_wait()
    
    // 2. 处理 IO 事件
    Set<SelectionKey> keys = selector.selectedKeys();
    for (SelectionKey key : keys) {
        // 根据事件类型分发
        if (key.isReadable()) handleRead(key);
        if (key.isWritable()) handleWrite(key);
        if (key.isAcceptable()) handleAccept(key);
    }
    
    // 3. 处理定时任务
    runAllTasks(timeout);
}

这个模式叫事件驱动循环(event-driven loop)。你太熟悉了可能不觉得有什么特别。

3.2 但这就是 CUDA Stream 的核心模型

// CUDA Stream — 注意这是一回事
cudaStream_t stream;
cudaStreamCreate(&stream);

while (true) {
    // 1. 检查是否有 kernel 完成
    cudaError_t err = cudaStreamQuery(stream);   // ← 类似 epoll_wait 的非阻塞版
    
    // 2. 如果当前 batch 完成,调度下一批
    if (err == cudaSuccess) {
        launch_next_batch(stream);                 // ← 类似 handleWrite
    }
    
    // 3. 没有新请求时,异步拷贝数据
    cudaMemcpyAsync(dst, src, size, cudaMemcpyHostToDevice, stream);
    // 这只是 push 到队列,不会阻塞
    
    // 4. 检查有没有 callback 要执行
    // cudaStreamAddCallback(stream, my_callback, nullptr, 0);
    // ← 类似 runAllTasks
}

你要理解的关键:

  1. selector.select() 等待 IO 事件 = cudaStreamSynchronize() 等待 GPU 完成
  2. handleRead() / handleWrite() 处理不同事件 = 往不同 CUDA Stream 提交 kernel
  3. runAllTasks 处理定时任务 = CUDA callback 处理后处理逻辑

不同的技术栈,同一个抽象模型:事件循环 + 异步 IO + 任务队列。

3.3 更具体地看:为什么 CUDA Stream 就是 GPU 版的 EventLoop

# vLLM 推理引擎的核心循环 — vllm/engine/async_llm_engine.py
class AsyncLLMEngine:
    async def run_engine_loop(self):
        while True:
            # 1. 等待新请求到来  ← 类似 selector.select()
            request = await self.scheduler.wait_for_request()
            
            # 2. 调度到 GPU 执行
            result = await self._run_workers(
                "execute_model",        # ← 类似 handleRead 的角色
                seq_group_metadata=request
            )
            
            # 3. 处理结果,发送响应
            await self._process_result(result)

这就是一个加了 async/await 的 EventLoop

如果你的 Netty 背景让你理解:

  • 一个线程循环处理多个连接的 IO 事件
  • 事件到来时触发回调
  • 回调里不能做耗时操作(否则阻塞其他连接)

那你就已经理解了 GPU 推理的核心调度逻辑。

3.4 CUDA Stream 的并行

你还在 Java 里做过另一件事:

// 两个独立的异步任务并行执行
CompletableFuture.supplyAsync(this::fetchFromDB)
    .thenCombine(CompletableFuture.supplyAsync(this::fetchFromCache),
                (db, cache) -> merge(db, cache));

CUDA Stream 就是 GPU 版本的 CompletableFuture

cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);

// Stream 1: 数据传输
cudaMemcpyAsync(dst1, src1, size, H2D, stream1);
// Stream 2: 内核计算  
myKernel<<<grid, block, 0, stream2>>>(dst2, src2);
// 两个 Stream 在 GPU 上并行执行!

// 等待两者都完成(类似 CompletableFuture.allOf)
cudaDeviceSynchronize();  // 类似 CompletableFuture.allOf().join()

Netty EventLoop 不能并行——因为它是单线程的。 CUDA Stream 天生可以并行——因为 GPU 有几百个 SM。

模式完全一样:事件循环 + 异步任务 + 依赖编排。


四、Netty Reactor → GPU Warp Scheduler:同一个调度问题

4.1 Netty 的调度困境

Netty 里 EventLoop 是单线程的。所有 Channel 的 IO 事件在同一个线程上串行处理。

你遇到过这种问题:

// 问题:一个 Channel 的 handler 阻塞了
pipeline.addLast(new ChannelInboundHandlerAdapter() {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        Thread.sleep(1000);  // 阻塞 1 秒
    }
});
// 后果:在同一个 EventLoop 上的其他所有 Channel 都被阻塞了

解决思路:把 Channel 分散到不同的 EventLoop 上。

4.2 GPU 的 Warp 调度

GPU 上也有完全一样的命题——但它解决得更好。

GPU 的执行单位叫 Warp(32 个线程)。一个 SM(Streaming Multiprocessor)里有 4 个 Warp Scheduler,同一时刻只能发射一个 Warp 的指令。

但 GPU 有一个能力远超你的线程池

Warp 切换零成本。

CPU 线程切换:保存寄存器(几百个字节) + 刷新 TLB + 内核态切换 ≈ 1000+ cycles
GPU Warp 切换:选另一个 Warp 发射下一指令 ≈ 0 cycles(寄存器已驻留)

原因: GPU 为每个 Warp 预分配了完整的寄存器文件。切换就是换一个程序计数器(PC)。

4.3 具体到代码

// 一个 CUDA Kernel
__global__ void compute(float* data, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= N) return;
    
    // 这是一条指令,32 个线程同时执行
    float val = data[idx];
    
    // 如果当前 warp 里有的线程快有的线程慢了
    // → GPU 直接切换到另一个 warp,让这个 warp 等着
    // → 等这个 warp 的依赖满足了再切回来
    // → 这个过程没有指令开销
    data[idx] = val * 2.0f;
}

你写的 Netty handler 阻塞了 → 所有其他连接等着。 GPU 的一个 warp 等内存 → SM 切换到另一个 warp → 零成本。

这个差异背后就是 CPU 和 GPU 的根本哲学不同:

CPU(你的线程池)GPU(Warp Scheduler)
线程切换成本高(几百 cycles)
隐藏延迟方式减少阻塞用大量线程隐藏延迟
设计哲学让每个线程尽可能快让总体吞吐尽可能高
典型线程数几十几万到几十万

从这个角度看,GPU 设计就是"如果我把线程切换成本降为零,我该怎么调度"这个思想实验的工程实现。

4.4 一个让你豁然开朗的类比

// 你的线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    16, 32, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(10000)
);
// 100 个任务,每个任务可能 IO 阻塞
// 阻塞了 → 线程被占着 → 其他任务等 → 线程池满 → 拒绝策略触发
// 优化方向:异步化,减少阻塞

// GPU 的 "线程池"
// 16384 个线程(一个 SM 上的最大并发)
// 每个线程都可能等内存读取
// 等着呢 → warp scheduler 切到下一个 warp → 前面那个等着
// 只要你总线程数够多(occupancy 高),内存延迟就能被隐藏
// 优化方向:提高 occupancy,而不是减少等待

这是架构思想的根本差异:

你花了很多年学会"不要阻塞线程"。

GPU 的答案是:“阻塞就阻塞,再开几万个线程就行。”


五、Kafka 分区 → NCCL Ring AllReduce:同一个通信问题

5.1 从 Kafka 的 Rebalance 说起

你部署过 Kafka 消费者组:

Topic: "my-topic"(12 个分区)
消费者组:4 个消费者
→ 每个消费者处理 3 个分区

Rebalance 时:
  停止消费 → 重新分配分区 → 恢复消费
(这个停顿你遇到过,通常几秒到几十秒)

5.2 分布式训练的 AllReduce

分布式训练时,NCCL 做梯度同步,和 Kafka 的分区消费是同一个抽象:

Topic = 所有 GPU 需要同步的梯度张量
  │
  ├── Partition 0(梯度分片 0)→ GPU 0 负责 reduce
  ├── Partition 1(梯度分片 1)→ GPU 1 负责 reduce
  ├── Partition 2(梯度分片 2)→ GPU 2 负责 reduce
  └── Partition 3(梯度分片 3)→ GPU 3 负责 reduce

NCCL 的 Ring AllReduce 算法:

Step 1 — Scatter-Reduce(类似 Consumer 拉取自己的分区)
  GPU0: 分片 0 → GPU1,分片 1 → GPU2,分片 2 → GPU3,分片 3 → GPU0
  每个 GPU 把自己持有的分片传到下一个 GPU,收到后做 reduce

Step 2 — AllGather(类似广播最新的 offset)
  每个 GPU 把 reduce 后的完整分片沿 ring 广播给所有人

Kafka 消费者 → 每个处理一部分数据。 NCCL AllReduce → 每个 GPU 处理一部分梯度。

5.3 一个关键区别

Kafka 的分区分配是静态的(除非 rebalance)。

NCCL 的 Ring 是动态的,数据在环上转

Kafka 消费者组:
  [C0: P0,P1,P2] [C1: P3,P4,P5] [C2: P6,P7,P8] [C3: P9,P10,P11]
  ↑ 每个消费者只碰自己的分区

NCCL Ring:
  GPU0 ──→ GPU1 ──→ GPU2 ──→ GPU3
   ↑                        │
   └────────────────────────┘
  ↑ 数据转一圈,每个 GPU 都接触所有数据

为什么 NCCL 用 Ring?因为 GPU 的 NVLink 是点对点高速连接(600GB/s),不需要中心交换机。Ring 在点对点拓扑上效率最高。

这和分布式系统设计的原则完全一致:通信拓扑要匹配物理拓扑。


六、JVM JIT → TensorRT:编译优化的两个版本

6.1 JIT 在 JVM 里做了什么

JVM 的 C2 编译器做了这些优化:

  • 内联:把被频繁调用的方法体直接嵌入调用处
  • 循环展开:减少循环控制开销
  • 逃逸分析:判断对象是否逃逸出线程,决定是否栈上分配
  • 锁消除:检测到锁不会竞争,直接去掉

6.2 TensorRT 在 GPU 上做了什么

TensorRT 对一个推理计算图做的优化:

# 优化前 — 多个独立的 CUDA Kernel
def forward(x):
    # Kernel 1: 卷积
    x = conv2d(x, weight, bias)
    # 把全部 128MB 结果写回 HBM
    
    # Kernel 2: ReLU 激活
    x = relu(x)
    # 再读 128MB,写 128MB
    
    # Kernel 3: BatchNorm
    x = batchnorm(x)
    # 再读 128MB,写 128MB

# TensorRT 优化后 — 合并成一个 Kernel(类似 JVM 内联)
# conv + relu + batchnorm 在一个 Kernel 里完成
# 中间结果在 SRAM 里直接传递,不需要写回 HBM

TensorRT 的算子融合 = JVM 的方法内联。 都是消除多余操作。

JVM C2 优化TensorRT 对应
方法内联算子融合(Kernel Fusion)
死代码消除未使用的输出裁剪
循环展开调整 grid/block 大小
逃逸分析 → 栈上分配消除中间 Tensor(HBM 读写 → SRAM)
窥孔优化特定 GPU arch 的指令选择
分层编译(解释 → C1 → C2)INT8 校准 → FP16 → TF32

6.3 具体到源代码

想理解 TensorRT 的算子融合,先看一段 JIT 日志:

// JVM JIT 日志
// -XX:+PrintCompilation
// -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining

@ 20   java.lang.String::hashCode (67 bytes)   inline (hot)
  @ 28   java.lang.String::isLatin1 (19 bytes)   inline (hot)
  @ 34   java.lang.StringLatin1::hashCode (42 bytes)   inline (hot)

然后看 TensorRT 的融合日志:

[TensorRT] VERBOSE: Layer fusion:
  Fusing (conv2d_0) + (relu_1) → CBR_0
  Fusing (CBR_0) + (pool_2) → CBR_Pool_0
  Eliminated tensor: conv2d_0_output (128 MB)  ← 省了一次 HBM 读写
  Eliminated tensor: relu_1_output (128 MB)    ← 又省了一次 HBM 读写
  Total HBM savings: 256 MB per inference step

你不需要重新学编译器。你只需要把 JIT 知识迁移到 GPU 上下文。

6.4 工程价值

我在生产环境见过最离谱的事情:

一个团队花了两周优化推理延迟,各种调 PyTorch 代码,效果不明显。后来用 TensorRT 优化了下计算图——INT8 量化 + 算子融合,延迟直接降了 60%。没人花时间看 TensorRT 的优化选项,因为他们不懂"我的计算图为什么可以优化"。

如果你理解 JIT 的作用,你会知道:

  1. TensorRT 的 profile 模式和 JIT profiling 一样——需要跑 warmup 数据
  2. 你的模型结构影响融合效果——就像代码结构影响 JIT 内联
  3. 有些融合可能劣化——就像有些内联让 code cache 溢出
  4. 冷启动问题——就像 JIT 需要预热一样,TensorRT engine 第一次加载也要编译

七、一个完整的生产案例:从 Java 后端视角看推理服务部署

我们来走一个真实的推理服务部署的全流程,全部用你的 Java 经验透视

7.1 架构

客户端(HTTP/gRPC请求)
    ↓
Triton Inference Server(类比:Spring Cloud Gateway)
    ↓ 路由到不同模型
vLLM 实例(多模型,类似多个 Tomcat 实例)
    ↓ 每层 Transformer 的 GPU 计算
GPU(A100 × 8,类似 8 核 CPU 的超集)

7.2 请求到达Triton

# 你的 Spring Boot 经验:
# @PostMapping("/predict")
# public Prediction predict(@RequestBody Request req) {
#     return modelService.predict(req);
# }

# Triton 的等价格:
# 配置 triton/model_repository/gpt/1/config.pbtxt:
name: "gpt"
backend: "vllm"
max_batch_size: 64
input [
  { name: "input_ids", data_type: TYPE_INT64, dims: [-1] }
]
output [
  { name: "output_ids", data_type: TYPE_INT64, dims: [-1] }
]
instance_group [{ count: 4 }]  # ← 4 个模型实例,类似 4 个 Tomcat worker

你不需要学新架构。这就是 API 网关 + 负载均衡 + 多实例。

7.3 vLLM 收到请求后的处理

# 这对你来说应该很熟悉了
class AsyncLLMEngine:
    def __init__(self):
        self.scheduler = Scheduler()       # ← 类似 Disruptor 的 Sequencer
        self.block_manager = BlockAllocator()  # ← 类似 ByteBuf 池
        self.worker = ModelWorker()        # ← 类似业务 Service
        self.request_queue = asyncio.Queue()  # ← 类似 MQ
    
    async def add_request(self, request):
        await self.request_queue.put(request)  # 放到队列
        # 和 Kafka producer.send() 一样:投递完就返回
    
    async def step(self):
        """每一步推理——类似你的定时任务调度"""
        # 1. 从队列取请求
        requests = self.scheduler.schedule()  # ← 类似 MQ poll batch
        if not requests:
            return
        
        # 2. 准备输入数据的元数据
        #    类似 MyBatis 的 SQL 构建
        metadata = self._prepare_inputs(requests)
        
        # 3. 执行推理 — 实际走 CUDA kernel
        #    类似 JDBC execute
        outputs = self.worker.execute_model(metadata)
        
        # 4. 发送结果 — 类似 HTTP 响应
        await self._send_outputs(outputs)

这个循环和你写的 Kafka Consumer + 数据库写入的异步生产者-消费者代码,结构上没有区别。

7.4 生产中的真实调优经验

你在 Java 后端调优时遇到过:

“QPS 上不去,发现是线程池太小,任务都在队列里等”

vLLM 的等价问题:

“推理吞吐上不去,发现是 max_num_batched_tokens 太小,GPU 没吃饱”


你在 Java 后端调优时遇到过:

“接口响应慢,发现是数据库连接池不够,大多数时间在等连接”

vLLM 的等价问题:

“推理延迟高,发现是显存 block 不够,请求在等 block 释放”


你在 Java 后端调优时遇到过:

“GC 停顿导致接口毛刺,长尾延迟严重”

vLLM 的等价问题:

“一个请求产生大量 padding token,GPU 利用率震荡,推理时间不稳定”

调优的思路、方法和工具链不一样了,但问题本质完全一致。


八、从内存屏障到同步原语:同一个并发问题

8.1 Java 里的内存屏障

你写并发代码时依赖 volatile 和 happens-before:

volatile boolean ready = false;
// volatile 写入 → 内存屏障 → 其他线程立即可见

// 或者用 Unsafe 直接操作
Unsafe.putOrderedObject(arr, offset, value);  // StoreStore 屏障

8.2 CUDA 里的内存屏障

一样的,只是名字叫 __syncthreads()__threadfence()

__global__ void kernel(float* data) {
    __shared__ float cache[256];
    
    int idx = threadIdx.x;
    cache[idx] = data[idx];  // 写入 shared memory
    
    __syncthreads();  // ← 这是一个全 barrier
    // 保证:所有线程对 shared memory 的写入都已完成
    // 等价于:Java 的 volatile write + happens-before
    
    // 现在才能安全读取其他线程写的数据
    float sum = cache[0] + cache[1];  // 不会读到脏数据
}

你的 Java 并发知识在这里完全适用:

  • volatile read = 从主存(HBM)读到寄存器
  • volatile write StoreLoad 屏障 = __threadfence() 保证全局可见
  • synchronized block = __syncthreads() + 隐式 fence(块内所有线程加入)
  • CAS 操作 = atomicAdd() / atomicCAS()(GPU 版本)

九、你的第一个 CUDA Kernel:从 Java Stream 开始

9.1 你熟悉的

// Java 中你对集合做操作
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
list.parallelStream()
    .map(x -> x * 2)
    .filter(x -> x > 5)
    .collect(toList());
// 结果: [6, 8, 10, 12, 14, 16]

9.2 CUDA 版本

// CUDA Kernel
__global__ void process(int* in, int* out, int* count, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= N) return;
    
    // 这就是你的 lambda: x -> x * 2
    int val = in[idx] * 2;
    
    // 这就是你的 filter: x -> x > 5
    if (val > 5) {
        // GPU 版本的 filter → collect
        // atomicAdd 就像并行 stream 里对共享容器的并发 add
        int pos = atomicAdd(count, 1);
        out[pos] = val;
    }
}

// 启动 Kernel — 这就是你的 parallelStream()
// 1 block × 8 threads = 8 个并行线程
process<<<1, 8>>>(d_in, d_out, d_count, 8);

Java Stream 的 parallelStream() 和 CUDA Kernel 的 <<<grid, block>>> 都是同一件事:把数据分散到多个线程并行处理。

区别只是 scale——CUDA 让你从 8 个线程变成 100 万个线程。

9.3 如果你现在就想写第一个 CUDA 程序

// vecAdd.cu — 向量加法,你的第一个 GPU 程序
#include <cuda_runtime.h>
#include <stdio.h>

__global__ void vecAdd(float* A, float* B, float* C, int N) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

int main() {
    int N = 1 << 20;  // 1048576 个元素
    float *h_A, *h_B, *h_C;      // host (CPU) pointers
    float *d_A, *d_B, *d_C;      // device (GPU) pointers
    
    // 分配 CPU 内存
    h_A = (float*)malloc(N * sizeof(float));
    h_B = (float*)malloc(N * sizeof(float));
    h_C = (float*)malloc(N * sizeof(float));
    
    // 分配 GPU 显存 — 像 Netty 的 directBuffer 分配,但在另一块物理内存上
    cudaMalloc(&d_A, N * sizeof(float));
    cudaMalloc(&d_B, N * sizeof(float));  
    cudaMalloc(&d_C, N * sizeof(float));
    
    // 拷贝数据到 GPU — 像跨网络传输数据
    cudaMemcpy(d_A, h_A, N * sizeof(float), cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, N * sizeof(float), cudaMemcpyHostToDevice);
    
    // 启动 Kernel — 1024 blocks × 1024 threads = 1,048,576 线程
    vecAdd<<<1024, 1024>>>(d_A, d_B, d_C, N);
    
    // 等待 GPU 完成 — 像 Future.get()
    cudaDeviceSynchronize();
    
    // 拷贝结果回 CPU
    cudaMemcpy(h_C, d_C, N * sizeof(float), cudaMemcpyDeviceToHost);
    
    // 释放
    cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
    free(h_A); free(h_B); free(h_C);
    
    return 0;
}

编译:

nvcc -o vecAdd vecAdd.cu
./vecAdd

十、为什么你应该现在就开始

10.1 一个不太好听的实话

AI Infra 领域的很多从业者其实不懂系统。

他们会用 PyTorch 写模型、会用 vLLM 部署服务,但:

  • 不知道为什么显存用了几张卡就 OOM 了
  • 不知道为什么 batch_size=32batch_size=64 的吞吐差距不是 2 倍
  • 不知道为什么 RDMA 比 TCP 快那么多
  • 不知道为什么 numactl 绑了一下 GPU 推理就快了 20%

而这些问题对于系统工程师来说,都是基本功。

你还记得你第一次排查 Java 网络问题时的思路吗?

  1. 先看网络层有没有丢包
  2. 再看系统层 socket buffer 有没有满
  3. 再看应用层连接池有没有耗尽
  4. 最后定位到是 DNS 解析超时

这种系统级的问题排查能力,是 PyTorch 背景的工程师普遍欠缺的。

10.2 你能带来的价值

纯 AI 背景的工程师能:

  • 用 PyTorch 写一个 attention 实现
  • 在单卡上训一个模型
  • 调参知道 loss 不收敛怎么办

你能带来的额外价值:

  • 把推理服务当作高并发系统来设计
  • 理解 KV Cache 就是内存池,知道怎么管理
  • 知道 NCCL 通信的拓扑瓶颈,知道怎么优化
  • 知道 NUMA 对数据传输的影响,知道怎么配置
  • 能从系统层面诊断性能问题,而不是只看模型 LOSS

这不比只懂 PyTorch 值钱得多?

10.3 真实的市场信号

看看这些招聘岗位的要求:

AI Infra 工程师(某大厂,年薪 150W+)

  • 精通 C++/Go/Rust/Python
  • 理解 GPU 架构和 CUDA 编程
  • 理解分布式系统和高性能网络
  • 熟悉推理框架(vLLM/TensorRT/Triton)
  • 加分项:有高并发中间件开发经验

加点粗的那行不就是你吗?

推理引擎工程师

  • 能优化 Transformer 推理延迟
  • 熟悉 TensorRT API
  • 了解 GPU 内存层次和访存优化
  • 有 CUDA Kernel 调优经验
  • 加分项:有系统性能调优经验

你过去十年积累的经验——并发、分布式、性能分析、中间件——每一份都是"加分项"里的核心技能。


附录:立刻可以写的代码

  1. 写一个 Java 版本的 PagedAttention 模拟器

    • 用 ByteBuf 模拟 GPU 显存 block
    • 实现 allocate / free / reference counting
    • 理解碎片化问题
    • 200 行代码,全部是你已有的知识
  2. 写一个 Continuous Batching 模拟器

    • 用 Java 线程池模拟 GPU
    • 4 个 worker 线程 = 4 个 SM
    • 实现"每轮迭代检查新请求"的调度逻辑
    • 比较"等批次凑满" vs “持续追加"的吞吐差距
    • 300 行代码,全部是 Disruptor + MQ 的知识
  3. 部署 vLLM 并加日志

    • export VLLM_LOGGING_LEVEL=DEBUG
    • 观察每个请求在每层 Transformer 的耗时
    • 观察 Block Manager 的分配 / 释放
    • vllm/block_manager.py 里加 print
    • 这是你的"源码阅读"第一步
  4. 写一个小型 NCCL 模拟器

    • 用 Java Socket 模拟 GPU 节点
    • 实现 Ring AllReduce 的 Scatter-Reduce 和 AllGather
    • 4 个进程,每个有 4MB 数据
    • 看不同拓扑(Ring vs Tree vs Star)的通信时间
    • 200 行 Socket + Thread 代码

这些项目证明:你不需要重学计算机科学,你只需要把现有的知识加上 GPU 这个新维度。

写出来的话,你就已经有了 AI Infra 工程师的思维模型。