Compare commits
75 Commits
phase10
...
cf1e9e41db
| Author | SHA1 | Date | |
|---|---|---|---|
| cf1e9e41db | |||
| d33220498a | |||
| e631a71b68 | |||
| 24c49c31c2 | |||
| 5a16225c1f | |||
| 3a530956af | |||
| 76487b7963 | |||
| 9f1fbbb98b | |||
| e1eb77baa4 | |||
| 34e9bee375 | |||
| 3b9e32e6cd | |||
| 5157b2cd30 | |||
| ea5d8ba7ea | |||
| c0a81c84e7 | |||
| 3d6bb1918e | |||
| f2e60218b4 | |||
| 3ee8df2c0f | |||
| ae08896f46 | |||
|
|
1d0ec32e8d | ||
|
|
4368e79695 | ||
|
|
377a04b81f | ||
|
|
241009a96c | ||
| 0c6135aea3 | |||
| ffd90ce7fb | |||
| 3c9d5e260e | |||
| 99b212e6c1 | |||
| e11f15e009 | |||
| 9c98c169ff | |||
|
|
5cb3cf28f9 | ||
|
|
15c51f143e | ||
|
|
d29c39d74e | ||
|
|
9ad91a4a92 | ||
|
|
46bfb59f30 | ||
|
|
9a01c60100 | ||
|
|
c679f618fd | ||
|
|
cc4bd4cfe5 | ||
|
|
13ae3de69e | ||
|
|
6ce21345be | ||
|
|
1ab6ca9c09 | ||
| 11e0154e4d | |||
| d5dcf1a5ab | |||
| 824cc58daa | |||
| da3aaa134a | |||
| 859c0cc0b6 | |||
| c2362df1f1 | |||
| 7b8b520cda | |||
| a4a171d425 | |||
| 95eb61d639 | |||
| f17011129e | |||
| 453520d622 | |||
| 76fffb3b68 | |||
| 14a44b503e | |||
| 80157e614a | |||
| fc1900a745 | |||
| d52baa0006 | |||
| 4c3f914459 | |||
| 3f1c3d429a | |||
| 950ccf3822 | |||
| 7cb9ee3870 | |||
| 49c7653222 | |||
| 9bb5c5c328 | |||
| 986a289616 | |||
| a67e724119 | |||
| d5532ef209 | |||
| e207523e21 | |||
| 876d3f5d6a | |||
| 9783fcf410 | |||
| 6cc1c9332d | |||
| d67dda404e | |||
| ee68d3565d | |||
| d8493bd70f | |||
| 7d05ececa0 | |||
| da043554ba | |||
| 2be27d6d94 | |||
| 2d48f25e66 |
16
.gitignore
vendored
16
.gitignore
vendored
@@ -7,3 +7,19 @@
|
|||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
.env
|
.env
|
||||||
*.npy
|
*.npy
|
||||||
|
|
||||||
|
# llama.cpp baseline (cloned/submoduled by tools/setup-llama-cpp.sh)
|
||||||
|
/third_party/llama.cpp/build/
|
||||||
|
/third_party/llama.cpp/models/
|
||||||
|
*.gguf
|
||||||
|
|
||||||
|
# Claude Code runtime state
|
||||||
|
/.claude/
|
||||||
|
|
||||||
|
# Benchmark output + fetched datasets (transferred to GPU host, not committed)
|
||||||
|
/bench-out/
|
||||||
|
/tools/bench/data/
|
||||||
|
/tools/__pycache__/
|
||||||
|
/tools/bench/__pycache__/
|
||||||
|
/tools/bench/**/__pycache__/
|
||||||
|
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "third_party/llama.cpp"]
|
||||||
|
path = third_party/llama.cpp
|
||||||
|
url = https://github.com/ggerganov/llama.cpp
|
||||||
1214
Cargo.lock
generated
Normal file
1214
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ members = [
|
|||||||
"crates/xserv-kernels",
|
"crates/xserv-kernels",
|
||||||
"crates/xserv-model",
|
"crates/xserv-model",
|
||||||
"crates/xserv-tokenizer",
|
"crates/xserv-tokenizer",
|
||||||
|
"crates/xserv-server",
|
||||||
|
"crates/xserv-distributed",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -16,7 +18,14 @@ license = "MIT"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
half = "2"
|
half = "2"
|
||||||
smallvec = "1"
|
smallvec = "1"
|
||||||
|
libc = "0.2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
safetensors = "0.5"
|
safetensors = "0.5"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
axum = "0.8"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
rand = "0.8"
|
||||||
|
minijinja = { version = "2", features = ["builtins"] }
|
||||||
|
|||||||
197
README.md
Normal file
197
README.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# xserv
|
||||||
|
|
||||||
|
> 从零用 **Rust + CUDA** 构建的 LLM 推理引擎,目标是吃透 LLM Serving 全栈技术。
|
||||||
|
|
||||||
|
xserv 不依赖 PyTorch / vLLM / TensorRT 等现成框架,自己实现了张量抽象、CUDA kernel、
|
||||||
|
分词器、模型前向、KV cache、调度器和 OpenAI 兼容的 HTTP 服务。当前在单张 RTX 5090 上可以
|
||||||
|
跑通 **Qwen3-8B**(BF16),并提供一套与 **llama.cpp** 对比正确性和性能的标准 benchmark。
|
||||||
|
|
||||||
|
## 现状一览
|
||||||
|
|
||||||
|
- **模型**:GPT-2(124M)、Qwen3-8B(BF16)
|
||||||
|
- **性能**(RTX 5090,Qwen3-8B BF16,贪心解码,单流):约 **56 tok/s**,约为 HF transformers 的 1.4×、llama.cpp 的 ~0.6×
|
||||||
|
- **精度**:在 AIME 2025 / GSM8K 上与 llama.cpp 同权重对比基本持平(数值保真度验证通过)
|
||||||
|
- **服务**:OpenAI 兼容 `/v1/chat/completions`,支持 SSE 流式输出
|
||||||
|
- **关键能力**:自写 GEMM / Flash-Attention 2(SM120) / Paged-Attention kernel、
|
||||||
|
分页 KV cache(含 **CPU 换出/换入** 弹性显存)、连续批处理(continuous batching)、
|
||||||
|
CUDA Graph 解码、按显存自适应的 KV 池
|
||||||
|
|
||||||
|
> 这是一个以学习为主的项目,逐 Phase 推进,每步都做数值/端到端验证。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
xserv/
|
||||||
|
├── csrc/ # CUDA 源码 (.cu/.cuh)
|
||||||
|
│ ├── gemm/ # GEMM (naive / tiled / gemv)
|
||||||
|
│ ├── attention/ # Flash-Attention 2 (SM120)、Paged-Attention、causal mask
|
||||||
|
│ ├── normalization/ # LayerNorm / RMSNorm
|
||||||
|
│ ├── activation/ # GELU / SiLU
|
||||||
|
│ ├── embedding/ # embedding lookup / RoPE / transpose
|
||||||
|
│ └── reduce/ # softmax
|
||||||
|
├── crates/
|
||||||
|
│ ├── xserv-cuda/ # CUDA FFI、Stream、显存分配器、Pinned 内存、CUDA Graph
|
||||||
|
│ ├── xserv-tensor/ # Tensor 类型(strided 布局、BF16/F16/F32、CPU↔GPU)
|
||||||
|
│ ├── xserv-kernels/ # kernel registry(自写 kernel + cuBLAS 可切换)
|
||||||
|
│ ├── xserv-tokenizer/ # BPE 分词器
|
||||||
|
│ ├── xserv-model/ # 模型定义(GPT-2 / Qwen3)、权重加载、KV cache、采样
|
||||||
|
│ └── xserv-server/ # tokio + axum HTTP 服务、调度器
|
||||||
|
├── tools/ # 辅助脚本 + benchmark 套件(见下)
|
||||||
|
└── docs/ # 每个 Phase 的设计文档 + benchmark 报告
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- **GPU**:NVIDIA,计算能力 SM120(RTX 5090 / Blackwell)。其它架构需调整 `CUDA_ARCH`。
|
||||||
|
- **CUDA Toolkit**:12.9(`nvcc` 需在 `PATH`,构建 `.cu` 依赖它)
|
||||||
|
- **Rust**:edition 2024(建议较新的 stable 工具链)
|
||||||
|
- **模型**:HuggingFace 目录格式(含 `config.json`、`tokenizer.json`、`*.safetensors`)
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CUDA_HOME=/usr/local/cuda-12.9
|
||||||
|
export PATH=$CUDA_HOME/bin:$PATH
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
如果本地没有 GPU/CUDA,可用远端构建脚本把代码同步到带卡的机器上构建/运行/测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/sync-and-build.sh build # 远端 cargo build --release
|
||||||
|
./tools/sync-and-build.sh test # 远端 cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
(远端主机、目录、模型路径在 `tools/sync-and-build.sh` 顶部配置。)
|
||||||
|
|
||||||
|
## 基本用法
|
||||||
|
|
||||||
|
### 1. 启动 HTTP 服务(OpenAI 兼容)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/xserv-server /path/to/qwen3-8b \
|
||||||
|
--port 8080 \
|
||||||
|
--max-batch 4 \
|
||||||
|
--max-seq-len 8192 \
|
||||||
|
--swap-space-gb 8
|
||||||
|
```
|
||||||
|
|
||||||
|
参数说明:
|
||||||
|
|
||||||
|
| 参数 | 含义 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--port` | 监听端口 | 8080 |
|
||||||
|
| `--max-batch` | 解码批大小(并发上限) | 4 |
|
||||||
|
| `--max-seq-len` | 单序列最大长度 | 2048 |
|
||||||
|
| `--swap-space-gb` | KV 换出到 CPU 的 pinned 内存大小(0 关闭) | 8 |
|
||||||
|
|
||||||
|
请求示例(流式):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "qwen3-8b",
|
||||||
|
"messages": [{"role": "user", "content": "用一句话解释什么是注意力机制"}],
|
||||||
|
"max_tokens": 256,
|
||||||
|
"temperature": 0,
|
||||||
|
"stream": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
其它端点:`GET /health`、`GET /v1/models`。
|
||||||
|
|
||||||
|
### 2. 命令行推理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 单轮生成
|
||||||
|
cargo run --release --bin xserv-cli -- /path/to/qwen3-8b --max-tokens 256
|
||||||
|
|
||||||
|
# 交互式多轮对话
|
||||||
|
cargo run --release --bin xserv-chat -- /path/to/qwen3-8b
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 单机性能基准
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 输出每个 prompt 的 TTFT / TBT / TPOT(JSON)
|
||||||
|
cargo run --release --bin bench-qwen3 -- /path/to/qwen3-8b --gen-tokens 64 [--cuda-graph]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与 llama.cpp 对比 benchmark
|
||||||
|
|
||||||
|
`tools/bench/` 提供一套一键对比套件,把 xserv 和 **llama.cpp**(同一份 BF16 权重)放在
|
||||||
|
相同负载下,黑盒通过 OpenAI API 对比:
|
||||||
|
|
||||||
|
- **性能**:TTFT、TPOT、吞吐(单流 + 不同并发)
|
||||||
|
- **精度**:AIME 2025、GSM8K(标准数据集,exact-match 评分)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一次性准备(需联网的机器):拉取 llama.cpp 子模块 + 下载数据集
|
||||||
|
git submodule update --init third_party/llama.cpp # 固定在 tag b9371
|
||||||
|
HF_ENDPOINT=https://hf-mirror.com python3 -m tools.bench.fetch_datasets
|
||||||
|
|
||||||
|
# 一键对比(构建 llama.cpp + 转 GGUF + 构建 xserv + 跑两套 + 出报告)
|
||||||
|
./tools/sync-and-build.sh bench -- --max-seq-len 8192 --quality-limit 50
|
||||||
|
./tools/sync-and-build.sh fetch-bench-out
|
||||||
|
# 报告产物:bench-out/comparison-<时间戳>.{md,json}
|
||||||
|
```
|
||||||
|
|
||||||
|
设计细节见 `docs/16-llama-cpp-comparison.md`,结果报告见 `docs/benchmarks/llama-cpp-comparison.md`。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- `docs/00-roadmap.md`:总体路线图与各 Phase 设计
|
||||||
|
- `docs/01..15-*.md`:CUDA FFI / Tensor / GEMM / Attention / KV cache / 性能优化等每个 Phase 的设计文档
|
||||||
|
- `docs/16-llama-cpp-comparison.md`:llama.cpp 对比基准的设计
|
||||||
|
- `docs/17-tensor-parallelism.md`:张量并行(TP)设计
|
||||||
|
- `docs/18-pipeline-parallelism.md`:流水线并行(PP)设计
|
||||||
|
- `docs/benchmarks/`:各阶段的 benchmark 报告(含 `pp-sweep.md`)
|
||||||
|
|
||||||
|
## 多卡并行(TP / PP)
|
||||||
|
|
||||||
|
单机多卡,复用 NCCL(crate `xserv-distributed`)。两种切法正交、二选一:
|
||||||
|
|
||||||
|
- **张量并行 `--tp N`**:按 head / 中间维切每一层,层内用 AllReduce 聚合(每 token `2·层数` 次)。
|
||||||
|
- **流水线并行 `--pp N`**:按层切成 N 段,相邻段间用 NCCL **P2P** 传 hidden state(每 token 仅 `N-1` 次),
|
||||||
|
通信量远小于 AllReduce,对无 NVLink 的 PCIe 更友好。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 组内 GPU 0-3:4 卡张量并行 / 4 卡流水线并行
|
||||||
|
CUDA_VISIBLE_DEVICES=0,1,2,3 ./target/release/xserv-server /path/to/qwen3-8b --tp 4
|
||||||
|
CUDA_VISIBLE_DEVICES=0,1,2,3 ./target/release/xserv-server /path/to/qwen3-8b --pp 4
|
||||||
|
```
|
||||||
|
|
||||||
|
**PP 实测**(dash5,Qwen3-8B BF16,单流贪心;每卡显存为权重+最小 KV 池):
|
||||||
|
|
||||||
|
| 配置 | TTFT | TPOT | tok/s | 每卡显存 |
|
||||||
|
|------|------|------|-------|----------|
|
||||||
|
| 单卡 | 33ms | 17.4ms | 57.5 | 24.0 GB |
|
||||||
|
| PP=2 | 36ms | 18.1ms | 55.3 | 11.6 / 13.6 GB |
|
||||||
|
| PP=4 | 36ms | 17.9ms | 55.8 | 7.3 / 5.3 / 5.3 / 9.4 GB |
|
||||||
|
|
||||||
|
**质量对比**(AIME 2025 30 题 + GSM8K 30 题,贪心,xserv 在 GPU 0-3、llama.cpp 在 GPU 4-7 并行):
|
||||||
|
|
||||||
|
| 引擎 | PP | AIME | GSM8K |
|
||||||
|
|------|----|------|-------|
|
||||||
|
| xserv | 1/2/4 | 8 / 7 / 7 (/30) | 29/30 (96.7%) 全部一致 |
|
||||||
|
| llama | 1/2/4 | 7 / 7 / 7 (/30) | 29/30 (96.7%) 全部一致 |
|
||||||
|
|
||||||
|
正确性:hidden state 跨段是 **bit-exact BF16 P2P 拷贝**,PP=4 输出与单卡逐字节一致(用「单卡×2 vs
|
||||||
|
PP=4×2」对照确认——单卡自身因 cuBLAS 非确定性 run-to-run 会变,而 PP=4 可复现且落在某次单卡轨迹上)。
|
||||||
|
GSM8K 12 个格子全是 29/30,xserv 与 llama.cpp 完全一致;AIME 的 ±1 是长生成下贪心对 GEMM 抖动的敏感,
|
||||||
|
非 PP 或引擎效应。**收益在显存**(每卡权重+KV ≈ 1/N);v1 为串行流水线,单流 TPOT 基本持平、不优于单卡,
|
||||||
|
真正的吞吐提升需后续做 microbatch / 1F1B 重叠。完整数据见 `docs/benchmarks/pp-sweep.md`。
|
||||||
|
|
||||||
|
## 路线图(节选)
|
||||||
|
|
||||||
|
已完成 Phase 0–18:CUDA 基础设施 → Tensor → GEMM → Transformer kernels → Attention →
|
||||||
|
模型加载 → 分词器 → GPT-2 → KV cache → Qwen3-8B → Paged Attention → 连续批处理 →
|
||||||
|
HTTP API → Flash Attention 2 → 性能优化 → **张量并行(TP)** → **流水线并行(PP)**;
|
||||||
|
并加入了 **llama.cpp 对比基准** 与 **KV CPU 换出** 等基础设施。
|
||||||
|
|
||||||
|
后续方向:PP microbatch/1F1B 流水线重叠(吞吐收益)、2D TP×PP、投机解码、量化(FP8 / INT8)、多模态。
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::ffi;
|
use crate::ffi;
|
||||||
use crate::memory::GpuBuffer;
|
use crate::memory::GpuBuffer;
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Caching allocator that reuses freed GPU buffers instead of calling
|
/// Caching allocator that reuses freed GPU buffers instead of calling
|
||||||
@@ -84,6 +85,40 @@ impl Drop for CachingAllocator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static ALLOCATOR: RefCell<CachingAllocator> = RefCell::new(CachingAllocator::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate a GPU buffer through the caching allocator.
|
||||||
|
/// The returned buffer has `pooled = true` so it will be returned
|
||||||
|
/// to the pool on drop instead of calling cudaFree.
|
||||||
|
pub fn cached_alloc(size: usize) -> Result<GpuBuffer> {
|
||||||
|
ALLOCATOR.with(|cell| {
|
||||||
|
let mut buf = cell.borrow_mut().alloc(size)?;
|
||||||
|
buf.set_pooled(true);
|
||||||
|
Ok(buf)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free all cached (unused) GPU buffers back to the driver.
|
||||||
|
pub fn cached_trim() {
|
||||||
|
ALLOCATOR.with(|cell| {
|
||||||
|
cell.borrow_mut().trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a raw GPU pointer to the caching allocator's free list.
|
||||||
|
/// Called from `GpuBuffer::Drop` for pooled buffers. Takes raw pointer
|
||||||
|
/// and size to avoid re-triggering Drop.
|
||||||
|
pub fn return_to_pool(ptr: *mut u8, len: usize) {
|
||||||
|
ALLOCATOR.with(|cell| {
|
||||||
|
let mut alloc = cell.borrow_mut();
|
||||||
|
let bucket = bucket_size(len);
|
||||||
|
alloc.stats.current_allocated = alloc.stats.current_allocated.saturating_sub(len);
|
||||||
|
alloc.free_lists.entry(bucket).or_default().push((ptr, len));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Round up to next power-of-2, minimum 512 bytes.
|
/// Round up to next power-of-2, minimum 512 bytes.
|
||||||
fn bucket_size(size: usize) -> usize {
|
fn bucket_size(size: usize) -> usize {
|
||||||
let min = 512;
|
let min = 512;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::error::{self, Result};
|
use crate::error::{self, Result};
|
||||||
use crate::ffi;
|
use crate::ffi;
|
||||||
use std::ffi::CStr;
|
use std::ffi::CStr;
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DeviceInfo {
|
pub struct DeviceInfo {
|
||||||
@@ -44,10 +45,14 @@ pub fn current_device() -> Result<u32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn device_info(device: u32) -> Result<DeviceInfo> {
|
pub fn device_info(device: u32) -> Result<DeviceInfo> {
|
||||||
// Get device name from cudaGetDeviceProperties (only use the name field).
|
// Heap-allocate oversized buffer for cudaDeviceProp (layout varies by CUDA version).
|
||||||
let mut prop = unsafe { std::mem::zeroed::<ffi::CudaDeviceProp>() };
|
// CUDA 12.x struct is ~5-6 KB; use 32 KB to guard against future growth.
|
||||||
error::check(unsafe { ffi::cudaGetDeviceProperties(&mut prop, device as i32) })?;
|
let mut prop_buf = vec![0u8; 32768];
|
||||||
let name = unsafe { CStr::from_ptr(prop.name.as_ptr()) }
|
error::check(unsafe {
|
||||||
|
ffi::cudaGetDeviceProperties(prop_buf.as_mut_ptr(), device as i32)
|
||||||
|
})?;
|
||||||
|
// Name is always the first field: char[256].
|
||||||
|
let name = unsafe { CStr::from_ptr(prop_buf.as_ptr() as *const c_char) }
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.into_owned();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use std::os::raw::c_char;
|
|||||||
|
|
||||||
pub type CudaStream = *mut c_void;
|
pub type CudaStream = *mut c_void;
|
||||||
pub type CudaEvent = *mut c_void;
|
pub type CudaEvent = *mut c_void;
|
||||||
|
pub type CudaGraph = *mut c_void;
|
||||||
|
pub type CudaGraphExec = *mut c_void;
|
||||||
|
|
||||||
pub const CUDA_MEMCPY_H2D: i32 = 1;
|
pub const CUDA_MEMCPY_H2D: i32 = 1;
|
||||||
pub const CUDA_MEMCPY_D2H: i32 = 2;
|
pub const CUDA_MEMCPY_D2H: i32 = 2;
|
||||||
@@ -11,31 +13,16 @@ pub const CUDA_MEMCPY_D2D: i32 = 3;
|
|||||||
pub const CUDA_SUCCESS: i32 = 0;
|
pub const CUDA_SUCCESS: i32 = 0;
|
||||||
pub const CUDA_ERROR_OUT_OF_MEMORY: i32 = 2;
|
pub const CUDA_ERROR_OUT_OF_MEMORY: i32 = 2;
|
||||||
|
|
||||||
#[repr(C)]
|
/// cudaStreamCaptureMode::cudaStreamCaptureModeGlobal
|
||||||
pub struct CudaDeviceProp {
|
pub const CUDA_STREAM_CAPTURE_MODE_GLOBAL: i32 = 0;
|
||||||
pub name: [c_char; 256],
|
|
||||||
pub total_global_mem: usize,
|
|
||||||
pub shared_mem_per_block: usize,
|
|
||||||
pub regs_per_block: i32,
|
|
||||||
pub warp_size: i32,
|
|
||||||
pub max_threads_per_block: i32,
|
|
||||||
pub max_threads_dim: [i32; 3],
|
|
||||||
pub max_grid_size: [i32; 3],
|
|
||||||
pub clock_rate: i32,
|
|
||||||
pub total_const_mem: usize,
|
|
||||||
pub major: i32,
|
|
||||||
pub minor: i32,
|
|
||||||
// There are many more fields; we only read up to what we need.
|
|
||||||
// cudaDeviceProp is a large struct (~1KB). We pad the rest.
|
|
||||||
_pad: [u8; 4096],
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe extern "C" {
|
unsafe extern "C" {
|
||||||
// --- Device ---
|
// --- Device ---
|
||||||
pub fn cudaGetDeviceCount(count: *mut i32) -> i32;
|
pub fn cudaGetDeviceCount(count: *mut i32) -> i32;
|
||||||
pub fn cudaSetDevice(device: i32) -> i32;
|
pub fn cudaSetDevice(device: i32) -> i32;
|
||||||
pub fn cudaGetDevice(device: *mut i32) -> i32;
|
pub fn cudaGetDevice(device: *mut i32) -> i32;
|
||||||
pub fn cudaGetDeviceProperties(prop: *mut CudaDeviceProp, device: i32) -> i32;
|
/// Takes a raw pointer; caller provides a heap buffer large enough for any CUDA version.
|
||||||
|
pub fn cudaGetDeviceProperties(prop: *mut u8, device: i32) -> i32;
|
||||||
pub fn cudaDeviceSynchronize() -> i32;
|
pub fn cudaDeviceSynchronize() -> i32;
|
||||||
|
|
||||||
// --- Memory ---
|
// --- Memory ---
|
||||||
@@ -52,6 +39,7 @@ unsafe extern "C" {
|
|||||||
stream: CudaStream,
|
stream: CudaStream,
|
||||||
) -> i32;
|
) -> i32;
|
||||||
pub fn cudaMemset(devptr: *mut u8, value: i32, count: usize) -> i32;
|
pub fn cudaMemset(devptr: *mut u8, value: i32, count: usize) -> i32;
|
||||||
|
pub fn cudaMemsetAsync(devptr: *mut u8, value: i32, count: usize, stream: CudaStream) -> i32;
|
||||||
|
|
||||||
// --- Stream ---
|
// --- Stream ---
|
||||||
pub fn cudaStreamCreate(stream: *mut CudaStream) -> i32;
|
pub fn cudaStreamCreate(stream: *mut CudaStream) -> i32;
|
||||||
@@ -62,6 +50,18 @@ unsafe extern "C" {
|
|||||||
pub fn cudaGetLastError() -> i32;
|
pub fn cudaGetLastError() -> i32;
|
||||||
pub fn cudaGetErrorString(error: i32) -> *const c_char;
|
pub fn cudaGetErrorString(error: i32) -> *const c_char;
|
||||||
|
|
||||||
|
// --- CUDA Graphs ---
|
||||||
|
pub fn cudaStreamBeginCapture(stream: CudaStream, mode: i32) -> i32;
|
||||||
|
pub fn cudaStreamEndCapture(stream: CudaStream, graph: *mut CudaGraph) -> i32;
|
||||||
|
pub fn cudaGraphInstantiate(
|
||||||
|
graph_exec: *mut CudaGraphExec,
|
||||||
|
graph: CudaGraph,
|
||||||
|
flags: u64,
|
||||||
|
) -> i32;
|
||||||
|
pub fn cudaGraphLaunch(graph_exec: CudaGraphExec, stream: CudaStream) -> i32;
|
||||||
|
pub fn cudaGraphDestroy(graph: CudaGraph) -> i32;
|
||||||
|
pub fn cudaGraphExecDestroy(graph_exec: CudaGraphExec) -> i32;
|
||||||
|
|
||||||
// --- Our test kernel ---
|
// --- Our test kernel ---
|
||||||
pub fn launch_vecadd_f32(
|
pub fn launch_vecadd_f32(
|
||||||
a: *const f32,
|
a: *const f32,
|
||||||
|
|||||||
98
crates/xserv-cuda/src/graph.rs
Normal file
98
crates/xserv-cuda/src/graph.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//! CUDA Graphs: capture a sequence of kernel launches and replay them with
|
||||||
|
//! near-zero host-side overhead (~3-5 us per launch eliminated).
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ```ignore
|
||||||
|
//! let stream = CudaStream::new()?;
|
||||||
|
//! let mut graph = CudaGraph::new();
|
||||||
|
//!
|
||||||
|
//! // First call: capture
|
||||||
|
//! graph.begin_capture(&stream)?;
|
||||||
|
//! // ... launch kernels on `stream` ...
|
||||||
|
//! graph.end_capture(&stream)?;
|
||||||
|
//!
|
||||||
|
//! // Subsequent calls: replay
|
||||||
|
//! graph.launch(&stream)?;
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Requirements for captured kernels:
|
||||||
|
//! - All tensor shapes must be identical between capture and replay.
|
||||||
|
//! - No host-side branching during the captured section.
|
||||||
|
//! - Memory addresses used during capture must remain valid during replay.
|
||||||
|
|
||||||
|
use crate::error::{self, Result};
|
||||||
|
use crate::ffi;
|
||||||
|
use crate::stream::CudaStream;
|
||||||
|
|
||||||
|
/// RAII wrapper around a captured CUDA graph and its executable instance.
|
||||||
|
pub struct CudaGraph {
|
||||||
|
graph: ffi::CudaGraph,
|
||||||
|
exec: ffi::CudaGraphExec,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CudaGraph {
|
||||||
|
/// Create an empty graph handle (not yet captured).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
graph: std::ptr::null_mut(),
|
||||||
|
exec: std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a graph has been captured and instantiated.
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
!self.exec.is_null()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin capturing kernel launches on `stream`.
|
||||||
|
/// All subsequent kernel launches on this stream are recorded into the
|
||||||
|
/// graph instead of being executed.
|
||||||
|
pub fn begin_capture(&mut self, stream: &CudaStream) -> Result<()> {
|
||||||
|
// If we have an old graph, destroy it first
|
||||||
|
self.destroy_inner();
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaStreamBeginCapture(
|
||||||
|
stream.as_raw(),
|
||||||
|
ffi::CUDA_STREAM_CAPTURE_MODE_GLOBAL,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End capture and instantiate the executable graph.
|
||||||
|
pub fn end_capture(&mut self, stream: &CudaStream) -> Result<()> {
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaStreamEndCapture(stream.as_raw(), &mut self.graph)
|
||||||
|
})?;
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaGraphInstantiate(&mut self.exec, self.graph, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay the captured graph on `stream`.
|
||||||
|
/// Panics if no graph has been captured yet.
|
||||||
|
pub fn launch(&self, stream: &CudaStream) -> Result<()> {
|
||||||
|
assert!(self.is_ready(), "CudaGraph::launch called before capture");
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaGraphLaunch(self.exec, stream.as_raw())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn destroy_inner(&mut self) {
|
||||||
|
if !self.exec.is_null() {
|
||||||
|
unsafe { ffi::cudaGraphExecDestroy(self.exec) };
|
||||||
|
self.exec = std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
if !self.graph.is_null() {
|
||||||
|
unsafe { ffi::cudaGraphDestroy(self.graph) };
|
||||||
|
self.graph = std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CudaGraph {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.destroy_inner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for CudaGraph {}
|
||||||
@@ -2,11 +2,13 @@ pub mod allocator;
|
|||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod ffi;
|
pub mod ffi;
|
||||||
|
pub mod graph;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
|
|
||||||
pub use allocator::CachingAllocator;
|
pub use allocator::CachingAllocator;
|
||||||
pub use device::DeviceInfo;
|
pub use device::DeviceInfo;
|
||||||
pub use error::{CudaError, Result};
|
pub use error::{CudaError, Result};
|
||||||
|
pub use graph::CudaGraph;
|
||||||
pub use memory::{GpuBuffer, PinnedBuffer};
|
pub use memory::{GpuBuffer, PinnedBuffer};
|
||||||
pub use stream::CudaStream;
|
pub use stream::CudaStream;
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ use crate::ffi;
|
|||||||
use crate::stream::CudaStream;
|
use crate::stream::CudaStream;
|
||||||
|
|
||||||
/// RAII wrapper around a GPU memory allocation.
|
/// RAII wrapper around a GPU memory allocation.
|
||||||
|
///
|
||||||
|
/// When `owned` is true (the default), dropping frees the GPU memory.
|
||||||
|
/// A borrowed buffer (`owned = false`) does NOT free on drop — the
|
||||||
|
/// caller must ensure the backing allocation outlives all borrows.
|
||||||
|
///
|
||||||
|
/// When `pooled` is true, dropping returns the buffer to the caching
|
||||||
|
/// allocator's free list instead of calling cudaFree.
|
||||||
pub struct GpuBuffer {
|
pub struct GpuBuffer {
|
||||||
ptr: *mut u8,
|
ptr: *mut u8,
|
||||||
len: usize,
|
len: usize,
|
||||||
|
owned: bool,
|
||||||
|
pooled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuBuffer {
|
impl GpuBuffer {
|
||||||
@@ -13,7 +22,13 @@ impl GpuBuffer {
|
|||||||
assert!(len > 0, "cannot allocate 0 bytes on GPU");
|
assert!(len > 0, "cannot allocate 0 bytes on GPU");
|
||||||
let mut ptr = std::ptr::null_mut();
|
let mut ptr = std::ptr::null_mut();
|
||||||
error::check(unsafe { ffi::cudaMalloc(&mut ptr, len) })?;
|
error::check(unsafe { ffi::cudaMalloc(&mut ptr, len) })?;
|
||||||
Ok(Self { ptr, len })
|
Ok(Self { ptr, len, owned: true, pooled: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark this buffer as pooled (returned to caching allocator on drop)
|
||||||
|
/// or not. Called by `cached_alloc` after obtaining a buffer.
|
||||||
|
pub fn set_pooled(&mut self, pooled: bool) {
|
||||||
|
self.pooled = pooled;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
@@ -87,6 +102,70 @@ impl GpuBuffer {
|
|||||||
error::check(unsafe { ffi::cudaMemset(self.ptr, 0, self.len) })
|
error::check(unsafe { ffi::cudaMemset(self.ptr, 0, self.len) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy `count` bytes from `src` buffer at `src_offset` to this buffer at `dst_offset`.
|
||||||
|
pub fn copy_from_device_at(&mut self, src: &GpuBuffer, src_offset: usize, dst_offset: usize, count: usize) -> Result<()> {
|
||||||
|
assert!(src_offset + count <= src.len);
|
||||||
|
assert!(dst_offset + count <= self.len);
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaMemcpy(
|
||||||
|
self.ptr.add(dst_offset),
|
||||||
|
src.ptr.add(src_offset),
|
||||||
|
count,
|
||||||
|
ffi::CUDA_MEMCPY_D2D,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async copy `count` bytes from `src` at `src_offset` to `self` at `dst_offset` on `stream`.
|
||||||
|
pub fn copy_from_device_at_async(&mut self, src: &GpuBuffer, src_offset: usize, dst_offset: usize, count: usize, stream: &CudaStream) -> Result<()> {
|
||||||
|
assert!(src_offset + count <= src.len);
|
||||||
|
assert!(dst_offset + count <= self.len);
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaMemcpyAsync(
|
||||||
|
self.ptr.add(dst_offset),
|
||||||
|
src.ptr.add(src_offset),
|
||||||
|
count,
|
||||||
|
ffi::CUDA_MEMCPY_D2D,
|
||||||
|
stream.as_raw(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `count` bytes from this GPU buffer at `src_offset` to a host slice (D2H).
|
||||||
|
pub fn copy_to_host_at(&self, dst: &mut [u8], src_offset: usize, count: usize) -> Result<()> {
|
||||||
|
assert!(src_offset + count <= self.len, "src range out of bounds");
|
||||||
|
assert!(count <= dst.len(), "host dst too small");
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaMemcpy(
|
||||||
|
dst.as_mut_ptr(),
|
||||||
|
self.ptr.add(src_offset),
|
||||||
|
count,
|
||||||
|
ffi::CUDA_MEMCPY_D2H,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `count` bytes from a host slice to this GPU buffer at `dst_offset` (H2D).
|
||||||
|
pub fn copy_from_host_at(&mut self, src: &[u8], dst_offset: usize, count: usize) -> Result<()> {
|
||||||
|
assert!(dst_offset + count <= self.len, "dst range out of bounds");
|
||||||
|
assert!(count <= src.len(), "host src too small");
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaMemcpy(
|
||||||
|
self.ptr.add(dst_offset),
|
||||||
|
src.as_ptr(),
|
||||||
|
count,
|
||||||
|
ffi::CUDA_MEMCPY_H2D,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async zero fill on stream.
|
||||||
|
pub fn zero_async(&mut self, stream: &CudaStream) -> Result<()> {
|
||||||
|
error::check(unsafe {
|
||||||
|
ffi::cudaMemsetAsync(self.ptr, 0, self.len, stream.as_raw())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Consume the buffer without freeing GPU memory. Returns the raw pointer and length.
|
/// Consume the buffer without freeing GPU memory. Returns the raw pointer and length.
|
||||||
/// Caller is responsible for eventually calling cudaFree.
|
/// Caller is responsible for eventually calling cudaFree.
|
||||||
pub fn into_raw(self) -> (*mut u8, usize) {
|
pub fn into_raw(self) -> (*mut u8, usize) {
|
||||||
@@ -99,14 +178,29 @@ impl GpuBuffer {
|
|||||||
/// Reconstruct a GpuBuffer from a raw pointer + length.
|
/// Reconstruct a GpuBuffer from a raw pointer + length.
|
||||||
/// Safety: ptr must have been allocated with cudaMalloc, len must be correct.
|
/// Safety: ptr must have been allocated with cudaMalloc, len must be correct.
|
||||||
pub unsafe fn from_raw(ptr: *mut u8, len: usize) -> Self {
|
pub unsafe fn from_raw(ptr: *mut u8, len: usize) -> Self {
|
||||||
Self { ptr, len }
|
Self { ptr, len, owned: true, pooled: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a non-owning view of GPU memory. Dropping this buffer does NOT
|
||||||
|
/// call `cudaFree`. The caller must ensure the underlying allocation
|
||||||
|
/// outlives this borrow.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must point to a valid GPU allocation of at least `len` bytes that
|
||||||
|
/// will remain live for the lifetime of the returned `GpuBuffer`.
|
||||||
|
pub unsafe fn borrow_raw(ptr: *mut u8, len: usize) -> Self {
|
||||||
|
Self { ptr, len, owned: false, pooled: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for GpuBuffer {
|
impl Drop for GpuBuffer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if !self.ptr.is_null() {
|
if self.owned && !self.ptr.is_null() {
|
||||||
unsafe { ffi::cudaFree(self.ptr) };
|
if self.pooled {
|
||||||
|
crate::allocator::return_to_pool(self.ptr, self.len);
|
||||||
|
} else {
|
||||||
|
unsafe { ffi::cudaFree(self.ptr) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
crates/xserv-distributed/Cargo.toml
Normal file
8
crates/xserv-distributed/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "xserv-distributed"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
xserv-cuda = { path = "../xserv-cuda" }
|
||||||
|
half.workspace = true
|
||||||
13
crates/xserv-distributed/build.rs
Normal file
13
crates/xserv-distributed/build.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cuda_path = env::var("CUDA_HOME")
|
||||||
|
.or_else(|_| env::var("CUDA_PATH"))
|
||||||
|
.unwrap_or_else(|_| "/usr/local/cuda".to_string());
|
||||||
|
|
||||||
|
println!("cargo:rustc-link-search=native={cuda_path}/lib64");
|
||||||
|
// NCCL is typically installed as a system library.
|
||||||
|
println!("cargo:rustc-link-search=native=/usr/lib/x86_64-linux-gnu");
|
||||||
|
println!("cargo:rustc-link-lib=dylib=nccl");
|
||||||
|
println!("cargo:rustc-link-lib=dylib=cudart");
|
||||||
|
}
|
||||||
82
crates/xserv-distributed/src/ffi.rs
Normal file
82
crates/xserv-distributed/src/ffi.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
//! Minimal NCCL FFI bindings (hand-written, like the CUDA bindings).
|
||||||
|
//! Only the collectives we need for tensor parallelism.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
use xserv_cuda::ffi::CudaStream;
|
||||||
|
|
||||||
|
/// Opaque NCCL communicator handle (`ncclComm_t`).
|
||||||
|
pub type NcclComm = *mut c_void;
|
||||||
|
|
||||||
|
/// `ncclUniqueId` is a 128-byte opaque blob shared from rank 0 to all ranks.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct NcclUniqueId {
|
||||||
|
pub internal: [c_char; 128],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NcclUniqueId {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { internal: [0; 128] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ncclDataType_t (subset)
|
||||||
|
pub const NCCL_FLOAT32: i32 = 7;
|
||||||
|
pub const NCCL_BF16: i32 = 9;
|
||||||
|
|
||||||
|
// ncclRedOp_t
|
||||||
|
pub const NCCL_SUM: i32 = 0;
|
||||||
|
|
||||||
|
// ncclResult_t
|
||||||
|
pub const NCCL_SUCCESS: i32 = 0;
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
pub fn ncclGetUniqueId(uid: *mut NcclUniqueId) -> i32;
|
||||||
|
// ncclUniqueId is passed BY VALUE (a 128-byte struct) per the NCCL ABI.
|
||||||
|
pub fn ncclCommInitRank(comm: *mut NcclComm, nranks: i32, commid: NcclUniqueId, rank: i32) -> i32;
|
||||||
|
pub fn ncclCommDestroy(comm: NcclComm) -> i32;
|
||||||
|
pub fn ncclAllReduce(
|
||||||
|
sendbuff: *const c_void,
|
||||||
|
recvbuff: *mut c_void,
|
||||||
|
count: usize,
|
||||||
|
datatype: i32,
|
||||||
|
op: i32,
|
||||||
|
comm: NcclComm,
|
||||||
|
stream: CudaStream,
|
||||||
|
) -> i32;
|
||||||
|
// Point-to-point primitives for pipeline parallelism (Phase 18).
|
||||||
|
pub fn ncclSend(
|
||||||
|
sendbuff: *const c_void,
|
||||||
|
count: usize,
|
||||||
|
datatype: i32,
|
||||||
|
peer: i32,
|
||||||
|
comm: NcclComm,
|
||||||
|
stream: CudaStream,
|
||||||
|
) -> i32;
|
||||||
|
pub fn ncclRecv(
|
||||||
|
recvbuff: *mut c_void,
|
||||||
|
count: usize,
|
||||||
|
datatype: i32,
|
||||||
|
peer: i32,
|
||||||
|
comm: NcclComm,
|
||||||
|
stream: CudaStream,
|
||||||
|
) -> i32;
|
||||||
|
pub fn ncclGroupStart() -> i32;
|
||||||
|
pub fn ncclGroupEnd() -> i32;
|
||||||
|
pub fn ncclGetErrorString(result: i32) -> *const c_char;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn err_string(result: i32) -> String {
|
||||||
|
unsafe {
|
||||||
|
let p = ncclGetErrorString(result);
|
||||||
|
if p.is_null() {
|
||||||
|
return format!("nccl error {result}");
|
||||||
|
}
|
||||||
|
std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check(result: i32, what: &str) {
|
||||||
|
assert_eq!(result, NCCL_SUCCESS, "{what} failed: {}", err_string(result));
|
||||||
|
}
|
||||||
161
crates/xserv-distributed/src/lib.rs
Normal file
161
crates/xserv-distributed/src/lib.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
//! Tensor-parallel primitives for xserv.
|
||||||
|
//!
|
||||||
|
//! Process model: one OS thread per TP rank, each bound to one GPU. NCCL is
|
||||||
|
//! used for the collective (AllReduce); a hand-rolled P2P AllReduce may replace
|
||||||
|
//! it later as a learning exercise (see docs/17-tensor-parallelism.md).
|
||||||
|
|
||||||
|
pub mod ffi;
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
use ffi::{NcclComm, NcclUniqueId};
|
||||||
|
use xserv_cuda::device;
|
||||||
|
use xserv_cuda::GpuBuffer;
|
||||||
|
|
||||||
|
pub use ffi::NcclUniqueId as UniqueId;
|
||||||
|
|
||||||
|
/// The CUDA "null" (default) stream. The model's kernels and cuBLAS calls run
|
||||||
|
/// on it, so issuing NCCL on the same stream keeps AllReduce correctly ordered
|
||||||
|
/// after the producing matmul and before the consuming kernel — no extra sync.
|
||||||
|
const NULL_STREAM: xserv_cuda::ffi::CudaStream = std::ptr::null_mut();
|
||||||
|
|
||||||
|
/// Generate a unique id on one rank (typically rank 0) and broadcast the bytes
|
||||||
|
/// to all ranks out-of-band (e.g. via a shared variable across threads).
|
||||||
|
pub fn get_unique_id() -> NcclUniqueId {
|
||||||
|
let mut id = NcclUniqueId::default();
|
||||||
|
ffi::check(unsafe { ffi::ncclGetUniqueId(&mut id) }, "ncclGetUniqueId");
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-rank tensor-parallel context: NCCL communicator + a dedicated stream.
|
||||||
|
pub struct TpContext {
|
||||||
|
pub rank: usize,
|
||||||
|
pub world: usize,
|
||||||
|
pub device: u32,
|
||||||
|
comm: NcclComm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The NCCL communicator is owned by exactly one rank thread.
|
||||||
|
unsafe impl Send for TpContext {}
|
||||||
|
|
||||||
|
impl TpContext {
|
||||||
|
/// Initialize this rank. Must be called from the thread that will own this
|
||||||
|
/// rank's GPU work; binds the thread to `device` first. All ranks must call
|
||||||
|
/// this concurrently with the same `id` and `world`.
|
||||||
|
pub fn init(rank: usize, world: usize, id: NcclUniqueId, device: u32) -> Self {
|
||||||
|
device::set_device(device).expect("set_device");
|
||||||
|
let mut comm: NcclComm = std::ptr::null_mut();
|
||||||
|
// Wrap the concurrent inits in a group so they rendezvous without deadlock.
|
||||||
|
ffi::check(unsafe { ffi::ncclGroupStart() }, "ncclGroupStart(init)");
|
||||||
|
ffi::check(
|
||||||
|
unsafe { ffi::ncclCommInitRank(&mut comm, world as i32, id, rank as i32) },
|
||||||
|
"ncclCommInitRank",
|
||||||
|
);
|
||||||
|
ffi::check(unsafe { ffi::ncclGroupEnd() }, "ncclGroupEnd(init)");
|
||||||
|
Self { rank, world, device, comm }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place AllReduce(sum) over `count` BF16 elements in `buf`.
|
||||||
|
pub fn all_reduce_sum_bf16(&self, buf: &mut GpuBuffer, count: usize) {
|
||||||
|
self.all_reduce_sum_bf16_ptr(buf.as_mut_ptr() as *mut c_void, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place AllReduce(sum) directly on a device pointer (`count` BF16 elems),
|
||||||
|
/// issued on the null stream so it is ordered with the model's kernels.
|
||||||
|
/// Asynchronous: a later sync (e.g. the D2H logits copy) completes it.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must point to at least `count` BF16 elements of valid device memory
|
||||||
|
/// on this rank's device. The reduction is in-place (send == recv).
|
||||||
|
pub fn all_reduce_sum_bf16_ptr(&self, ptr: *mut c_void, count: usize) {
|
||||||
|
if self.world == 1 {
|
||||||
|
return; // nothing to reduce
|
||||||
|
}
|
||||||
|
ffi::check(
|
||||||
|
unsafe {
|
||||||
|
ffi::ncclAllReduce(
|
||||||
|
ptr as *const c_void,
|
||||||
|
ptr,
|
||||||
|
count,
|
||||||
|
ffi::NCCL_BF16,
|
||||||
|
ffi::NCCL_SUM,
|
||||||
|
self.comm,
|
||||||
|
NULL_STREAM,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"ncclAllReduce",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TpContext {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.comm.is_null() {
|
||||||
|
unsafe { ffi::ncclCommDestroy(self.comm) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-stage pipeline-parallel context: a NCCL communicator spanning all `P`
|
||||||
|
/// stages plus point-to-point send/recv of the hidden state to the neighbour
|
||||||
|
/// stages. Init is identical to `TpContext` (one comm across `world` ranks);
|
||||||
|
/// only the collective differs — PP hands off `[tokens, hidden]` between
|
||||||
|
/// consecutive stages instead of AllReducing within a layer.
|
||||||
|
pub struct PpContext {
|
||||||
|
pub stage: usize,
|
||||||
|
pub world: usize,
|
||||||
|
pub device: u32,
|
||||||
|
comm: NcclComm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The NCCL communicator is owned by exactly one stage thread.
|
||||||
|
unsafe impl Send for PpContext {}
|
||||||
|
|
||||||
|
impl PpContext {
|
||||||
|
/// Initialize this stage. Must be called from the thread that owns this
|
||||||
|
/// stage's GPU; binds the thread to `device` first. All stages call this
|
||||||
|
/// concurrently with the same `id` and `world`.
|
||||||
|
pub fn init(stage: usize, world: usize, id: NcclUniqueId, device: u32) -> Self {
|
||||||
|
device::set_device(device).expect("set_device");
|
||||||
|
let mut comm: NcclComm = std::ptr::null_mut();
|
||||||
|
ffi::check(unsafe { ffi::ncclGroupStart() }, "ncclGroupStart(init)");
|
||||||
|
ffi::check(
|
||||||
|
unsafe { ffi::ncclCommInitRank(&mut comm, world as i32, id, stage as i32) },
|
||||||
|
"ncclCommInitRank",
|
||||||
|
);
|
||||||
|
ffi::check(unsafe { ffi::ncclGroupEnd() }, "ncclGroupEnd(init)");
|
||||||
|
Self { stage, world, device, comm }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send `count` BF16 elements at `ptr` to `peer`, on the null stream so it is
|
||||||
|
/// ordered after the producing matmul. Asynchronous — a later `synchronize`
|
||||||
|
/// (the caller must do one before reusing/freeing the buffer) completes it.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must point to at least `count` BF16 elements of valid device memory.
|
||||||
|
pub fn send_bf16_ptr(&self, ptr: *const c_void, count: usize, peer: usize) {
|
||||||
|
ffi::check(
|
||||||
|
unsafe { ffi::ncclSend(ptr, count, ffi::NCCL_BF16, peer as i32, self.comm, NULL_STREAM) },
|
||||||
|
"ncclSend",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive `count` BF16 elements from `peer` into `ptr`, on the null stream.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must point to at least `count` BF16 elements of valid device memory.
|
||||||
|
pub fn recv_bf16_ptr(&self, ptr: *mut c_void, count: usize, peer: usize) {
|
||||||
|
ffi::check(
|
||||||
|
unsafe { ffi::ncclRecv(ptr, count, ffi::NCCL_BF16, peer as i32, self.comm, NULL_STREAM) },
|
||||||
|
"ncclRecv",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PpContext {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.comm.is_null() {
|
||||||
|
unsafe { ffi::ncclCommDestroy(self.comm) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/xserv-distributed/tests/allreduce.rs
Normal file
50
crates/xserv-distributed/tests/allreduce.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! 2-GPU AllReduce smoke test. Skips if fewer than 2 GPUs are present.
|
||||||
|
|
||||||
|
use half::bf16;
|
||||||
|
use std::thread;
|
||||||
|
use xserv_cuda::{device, GpuBuffer};
|
||||||
|
use xserv_distributed::{get_unique_id, TpContext};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allreduce_two_gpu_sum() {
|
||||||
|
let world = 2usize;
|
||||||
|
if device::device_count().unwrap_or(0) < world as i32 {
|
||||||
|
eprintln!("skip: need >= {world} GPUs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = get_unique_id();
|
||||||
|
let n = 4096usize;
|
||||||
|
|
||||||
|
let handles: Vec<_> = (0..world)
|
||||||
|
.map(|rank| {
|
||||||
|
let id = id;
|
||||||
|
thread::spawn(move || {
|
||||||
|
let ctx = TpContext::init(rank, world, id, rank as u32);
|
||||||
|
|
||||||
|
// Rank r fills its buffer with (r + 1).
|
||||||
|
let val = bf16::from_f32((rank + 1) as f32);
|
||||||
|
let host = vec![val; n];
|
||||||
|
let src = unsafe {
|
||||||
|
std::slice::from_raw_parts(host.as_ptr() as *const u8, n * 2)
|
||||||
|
};
|
||||||
|
let mut buf = GpuBuffer::alloc(n * 2).unwrap();
|
||||||
|
buf.copy_from_host(src).unwrap();
|
||||||
|
|
||||||
|
ctx.all_reduce_sum_bf16(&mut buf, n);
|
||||||
|
|
||||||
|
let mut out = vec![0u8; n * 2];
|
||||||
|
buf.copy_to_host(&mut out).unwrap();
|
||||||
|
let res = unsafe { std::slice::from_raw_parts(out.as_ptr() as *const bf16, n) };
|
||||||
|
(res[0].to_f32(), res[n - 1].to_f32())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// sum over ranks of (r+1) = 1 + 2 = 3
|
||||||
|
for h in handles {
|
||||||
|
let (first, last) = h.join().unwrap();
|
||||||
|
assert_eq!(first, 3.0, "AllReduce(sum) first element");
|
||||||
|
assert_eq!(last, 3.0, "AllReduce(sum) last element");
|
||||||
|
}
|
||||||
|
}
|
||||||
62
crates/xserv-distributed/tests/sendrecv.rs
Normal file
62
crates/xserv-distributed/tests/sendrecv.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
//! 2-GPU NCCL P2P send/recv smoke test for pipeline parallelism.
|
||||||
|
//! Stage 0 sends a known vector to stage 1, which verifies it. Skips if fewer
|
||||||
|
//! than 2 GPUs are present. Mirrors `allreduce.rs` (GpuBuffer + half only —
|
||||||
|
//! this crate does not depend on xserv-tensor).
|
||||||
|
|
||||||
|
use half::bf16;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::thread;
|
||||||
|
use xserv_cuda::{device, GpuBuffer};
|
||||||
|
use xserv_distributed::{get_unique_id, PpContext};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pp_send_recv_two_stages() {
|
||||||
|
let world = 2usize;
|
||||||
|
if device::device_count().unwrap_or(0) < world as i32 {
|
||||||
|
eprintln!("skip: need >= {world} GPUs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = get_unique_id();
|
||||||
|
let n = 4096usize; // one [1, hidden]-sized hand-off
|
||||||
|
|
||||||
|
let handles: Vec<_> = (0..world)
|
||||||
|
.map(|stage| {
|
||||||
|
let id = id;
|
||||||
|
thread::spawn(move || {
|
||||||
|
let pp = PpContext::init(stage, world, id, stage as u32);
|
||||||
|
let mut buf = GpuBuffer::alloc(n * 2).unwrap();
|
||||||
|
|
||||||
|
if stage == 0 {
|
||||||
|
// Fill with a known pattern and send to stage 1.
|
||||||
|
let host: Vec<bf16> = (0..n).map(|i| bf16::from_f32((i % 97) as f32)).collect();
|
||||||
|
let src = unsafe { std::slice::from_raw_parts(host.as_ptr() as *const u8, n * 2) };
|
||||||
|
buf.copy_from_host(src).unwrap();
|
||||||
|
pp.send_bf16_ptr(buf.as_mut_ptr() as *const c_void, n, 1);
|
||||||
|
device::synchronize().unwrap();
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// Receive into a zeroed buffer and read it back.
|
||||||
|
buf.copy_from_host(&vec![0u8; n * 2]).unwrap();
|
||||||
|
pp.recv_bf16_ptr(buf.as_mut_ptr() as *mut c_void, n, 0);
|
||||||
|
device::synchronize().unwrap();
|
||||||
|
let mut out = vec![0u8; n * 2];
|
||||||
|
buf.copy_to_host(&mut out).unwrap();
|
||||||
|
let res = unsafe { std::slice::from_raw_parts(out.as_ptr() as *const bf16, n) };
|
||||||
|
Some((res[0].to_f32(), res[1].to_f32(), res[n - 1].to_f32()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut checked = false;
|
||||||
|
for h in handles {
|
||||||
|
if let Some((first, second, last)) = h.join().unwrap() {
|
||||||
|
assert_eq!(first, 0.0, "recv[0]");
|
||||||
|
assert_eq!(second, 1.0, "recv[1]");
|
||||||
|
assert_eq!(last, ((n - 1) % 97) as f32, "recv[last]");
|
||||||
|
checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(checked, "stage 1 never verified the received buffer");
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ fn main() {
|
|||||||
println!("cargo:rustc-link-search=native={cuda_path}/lib64");
|
println!("cargo:rustc-link-search=native={cuda_path}/lib64");
|
||||||
println!("cargo:rustc-link-lib=dylib=cudart");
|
println!("cargo:rustc-link-lib=dylib=cudart");
|
||||||
println!("cargo:rustc-link-lib=dylib=cublas");
|
println!("cargo:rustc-link-lib=dylib=cublas");
|
||||||
|
println!("cargo:rustc-link-lib=dylib=cublasLt");
|
||||||
|
|
||||||
cc::Build::new()
|
cc::Build::new()
|
||||||
.cuda(true)
|
.cuda(true)
|
||||||
@@ -16,13 +17,23 @@ fn main() {
|
|||||||
.include("../../csrc")
|
.include("../../csrc")
|
||||||
.file("../../csrc/gemm/naive.cu")
|
.file("../../csrc/gemm/naive.cu")
|
||||||
.file("../../csrc/gemm/tiled.cu")
|
.file("../../csrc/gemm/tiled.cu")
|
||||||
|
.file("../../csrc/gemm/gemv.cu")
|
||||||
.file("../../csrc/normalization/rmsnorm.cu")
|
.file("../../csrc/normalization/rmsnorm.cu")
|
||||||
.file("../../csrc/normalization/layernorm.cu")
|
.file("../../csrc/normalization/layernorm.cu")
|
||||||
.file("../../csrc/activation/activations.cu")
|
.file("../../csrc/activation/activations.cu")
|
||||||
.file("../../csrc/reduce/softmax.cu")
|
.file("../../csrc/reduce/softmax.cu")
|
||||||
|
.file("../../csrc/reduce/argmax.cu")
|
||||||
.file("../../csrc/embedding/embedding.cu")
|
.file("../../csrc/embedding/embedding.cu")
|
||||||
.file("../../csrc/embedding/rope.cu")
|
.file("../../csrc/embedding/rope.cu")
|
||||||
.file("../../csrc/attention/causal_mask.cu")
|
.file("../../csrc/attention/causal_mask.cu")
|
||||||
|
.file("../../csrc/embedding/transpose.cu")
|
||||||
|
.file("../../csrc/attention/flash_attention.cu")
|
||||||
|
.file("../../csrc/attention/paged_attention.cu")
|
||||||
|
.file("../../csrc/attention/reshape_and_cache.cu")
|
||||||
|
.file("../../csrc/moe/moe_kernels.cu")
|
||||||
|
.file("../../csrc/quantization/dequant_fp8.cu")
|
||||||
|
.file("../../csrc/quantization/quantize_fp8.cu")
|
||||||
|
.file("../../csrc/quantization/mxfp4_gemm.cu")
|
||||||
.compile("xserv_kernels");
|
.compile("xserv_kernels");
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=../../csrc/");
|
println!("cargo:rerun-if-changed=../../csrc/");
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ unsafe extern "C" {
|
|||||||
fn launch_add_bf16(a: *const c_void, b: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
fn launch_add_bf16(a: *const c_void, b: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
||||||
fn launch_mul_f32(a: *const c_void, b: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
fn launch_mul_f32(a: *const c_void, b: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
||||||
fn launch_mul_bf16(a: *const c_void, b: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
fn launch_mul_bf16(a: *const c_void, b: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
||||||
|
fn launch_silu_mul_bf16(gate: *const c_void, up: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
||||||
|
fn launch_gpt_oss_glu_bf16(gate_up: *const c_void, out: *mut c_void, n_elements: i32,
|
||||||
|
alpha: f32, limit: f32, stream: *mut c_void);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_unary(x: &Tensor, f32_fn: unsafe extern "C" fn(*const c_void, *mut c_void, i32, *mut c_void),
|
fn dispatch_unary(x: &Tensor, f32_fn: unsafe extern "C" fn(*const c_void, *mut c_void, i32, *mut c_void),
|
||||||
bf16_fn: unsafe extern "C" fn(*const c_void, *mut c_void, i32, *mut c_void)) -> Tensor {
|
bf16_fn: unsafe extern "C" fn(*const c_void, *mut c_void, i32, *mut c_void)) -> Tensor {
|
||||||
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
||||||
let out = Tensor::zeros(x.shape(), x.dtype(), x.device());
|
let out = Tensor::empty(x.shape(), x.dtype(), x.device());
|
||||||
let n = x.numel() as i32;
|
let n = x.numel();
|
||||||
|
assert!(n <= i32::MAX as usize, "tensor too large for i32 kernel param ({n} elements)");
|
||||||
|
let n = n as i32;
|
||||||
unsafe {
|
unsafe {
|
||||||
match x.dtype() {
|
match x.dtype() {
|
||||||
DType::F32 => f32_fn(x.data_ptr() as _, out.data_ptr() as *mut c_void, n, std::ptr::null_mut()),
|
DType::F32 => f32_fn(x.data_ptr() as _, out.data_ptr() as *mut c_void, n, std::ptr::null_mut()),
|
||||||
@@ -26,7 +31,6 @@ fn dispatch_unary(x: &Tensor, f32_fn: unsafe extern "C" fn(*const c_void, *mut c
|
|||||||
_ => panic!("unsupported dtype"),
|
_ => panic!("unsupported dtype"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +41,10 @@ fn dispatch_binary(a: &Tensor, b: &Tensor,
|
|||||||
assert!(a.is_contiguous() && b.is_contiguous());
|
assert!(a.is_contiguous() && b.is_contiguous());
|
||||||
assert!(matches!(a.device(), Device::Cuda(_)));
|
assert!(matches!(a.device(), Device::Cuda(_)));
|
||||||
assert_eq!(a.dtype(), b.dtype());
|
assert_eq!(a.dtype(), b.dtype());
|
||||||
let out = Tensor::zeros(a.shape(), a.dtype(), a.device());
|
let out = Tensor::empty(a.shape(), a.dtype(), a.device());
|
||||||
let n = a.numel() as i32;
|
let n = a.numel();
|
||||||
|
assert!(n <= i32::MAX as usize, "tensor too large for i32 kernel param ({n} elements)");
|
||||||
|
let n = n as i32;
|
||||||
unsafe {
|
unsafe {
|
||||||
match a.dtype() {
|
match a.dtype() {
|
||||||
DType::F32 => f32_fn(a.data_ptr() as _, b.data_ptr() as _, out.data_ptr() as *mut c_void, n, std::ptr::null_mut()),
|
DType::F32 => f32_fn(a.data_ptr() as _, b.data_ptr() as _, out.data_ptr() as *mut c_void, n, std::ptr::null_mut()),
|
||||||
@@ -46,7 +52,6 @@ fn dispatch_binary(a: &Tensor, b: &Tensor,
|
|||||||
_ => panic!("unsupported dtype"),
|
_ => panic!("unsupported dtype"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +60,10 @@ pub fn silu(x: &Tensor) -> Tensor { dispatch_unary(x, launch_silu_f32, launch_si
|
|||||||
|
|
||||||
pub fn scale(x: &Tensor, scale_val: f32) -> Tensor {
|
pub fn scale(x: &Tensor, scale_val: f32) -> Tensor {
|
||||||
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
||||||
let out = Tensor::zeros(x.shape(), x.dtype(), x.device());
|
let out = Tensor::empty(x.shape(), x.dtype(), x.device());
|
||||||
let n = x.numel() as i32;
|
let n = x.numel();
|
||||||
|
assert!(n <= i32::MAX as usize, "tensor too large for i32 kernel param ({n} elements)");
|
||||||
|
let n = n as i32;
|
||||||
unsafe {
|
unsafe {
|
||||||
match x.dtype() {
|
match x.dtype() {
|
||||||
DType::F32 => launch_scale_f32(x.data_ptr() as _, out.data_ptr() as *mut c_void, scale_val, n, std::ptr::null_mut()),
|
DType::F32 => launch_scale_f32(x.data_ptr() as _, out.data_ptr() as *mut c_void, scale_val, n, std::ptr::null_mut()),
|
||||||
@@ -64,9 +71,59 @@ pub fn scale(x: &Tensor, scale_val: f32) -> Tensor {
|
|||||||
_ => panic!("unsupported dtype for scale"),
|
_ => panic!("unsupported dtype for scale"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add(a: &Tensor, b: &Tensor) -> Tensor { dispatch_binary(a, b, launch_add_f32, launch_add_bf16) }
|
pub fn add(a: &Tensor, b: &Tensor) -> Tensor { dispatch_binary(a, b, launch_add_f32, launch_add_bf16) }
|
||||||
pub fn mul(a: &Tensor, b: &Tensor) -> Tensor { dispatch_binary(a, b, launch_mul_f32, launch_mul_bf16) }
|
pub fn mul(a: &Tensor, b: &Tensor) -> Tensor { dispatch_binary(a, b, launch_mul_f32, launch_mul_bf16) }
|
||||||
|
|
||||||
|
/// Fused SiLU×Mul: out = silu(gate) * up (BF16 only)
|
||||||
|
/// Saves one HBM read + one HBM write compared to separate silu + mul.
|
||||||
|
pub fn silu_mul(gate: &Tensor, up: &Tensor) -> Tensor {
|
||||||
|
assert_eq!(gate.shape(), up.shape());
|
||||||
|
assert!(gate.is_contiguous() && up.is_contiguous());
|
||||||
|
assert!(matches!(gate.device(), Device::Cuda(_)));
|
||||||
|
assert_eq!(gate.dtype(), DType::BF16, "silu_mul requires BF16");
|
||||||
|
let out = Tensor::empty(gate.shape(), gate.dtype(), gate.device());
|
||||||
|
let n = gate.numel();
|
||||||
|
assert!(n <= i32::MAX as usize, "tensor too large for i32 kernel param ({n} elements)");
|
||||||
|
let n = n as i32;
|
||||||
|
unsafe {
|
||||||
|
launch_silu_mul_bf16(
|
||||||
|
gate.data_ptr() as *const c_void,
|
||||||
|
up.data_ptr() as *const c_void,
|
||||||
|
out.data_ptr() as *mut c_void,
|
||||||
|
n,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gpt-oss fused GLU activation (BF16 only).
|
||||||
|
/// Input: gate_up [rows, 2*D] with interleaved columns (gate=even, up=odd).
|
||||||
|
/// Output: [rows, D]
|
||||||
|
/// Computes: gate.clamp(max=limit) * sigmoid(gate * alpha) * (up.clamp(-limit,limit) + 1)
|
||||||
|
pub fn gpt_oss_glu(gate_up: &Tensor, alpha: f32, limit: f32) -> Tensor {
|
||||||
|
assert!(gate_up.is_contiguous());
|
||||||
|
assert!(matches!(gate_up.device(), Device::Cuda(_)));
|
||||||
|
assert_eq!(gate_up.dtype(), DType::BF16, "gpt_oss_glu requires BF16");
|
||||||
|
assert_eq!(gate_up.ndim(), 2);
|
||||||
|
let rows = gate_up.shape()[0];
|
||||||
|
let cols = gate_up.shape()[1];
|
||||||
|
assert_eq!(cols % 2, 0);
|
||||||
|
let d = cols / 2;
|
||||||
|
let out = Tensor::empty(&[rows, d], gate_up.dtype(), gate_up.device());
|
||||||
|
let n_elements = (rows * d) as i32;
|
||||||
|
unsafe {
|
||||||
|
launch_gpt_oss_glu_bf16(
|
||||||
|
gate_up.data_ptr() as *const c_void,
|
||||||
|
out.data_ptr() as *mut c_void,
|
||||||
|
n_elements,
|
||||||
|
alpha,
|
||||||
|
limit,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|||||||
65
crates/xserv-kernels/src/argmax.rs
Normal file
65
crates/xserv-kernels/src/argmax.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use std::ffi::c_void;
|
||||||
|
use xserv_tensor::{DType, Device, Tensor};
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn launch_argmax_bf16(logits: *const c_void, out_idx: *mut c_void,
|
||||||
|
rows: i32, cols: i32, stream: *mut c_void);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GPU argmax over the last dim of a [rows, cols] BF16 tensor.
|
||||||
|
///
|
||||||
|
/// Returns a host `Vec<u32>` of length `rows`. Internally:
|
||||||
|
/// - launches one kernel that writes [rows] i32 indices on device
|
||||||
|
/// - D2H copies just `rows * 4` bytes (vs `rows * cols * 2` for the
|
||||||
|
/// "copy logits to CPU then argmax" path it replaces)
|
||||||
|
///
|
||||||
|
/// This is the greedy-decode hot path: avoids touching the full
|
||||||
|
/// [B, vocab] logits buffer on the host every step.
|
||||||
|
pub fn argmax_bf16_to_host(logits: &Tensor) -> Vec<u32> {
|
||||||
|
assert_eq!(logits.ndim(), 2, "argmax expects a 2D [rows, cols] tensor");
|
||||||
|
assert_eq!(logits.dtype(), DType::BF16, "argmax kernel is BF16-only");
|
||||||
|
assert!(logits.is_contiguous(), "argmax requires contiguous input");
|
||||||
|
assert!(matches!(logits.device(), Device::Cuda(_)), "argmax requires GPU input");
|
||||||
|
|
||||||
|
let rows = logits.shape()[0];
|
||||||
|
let cols = logits.shape()[1];
|
||||||
|
assert!(rows <= i32::MAX as usize);
|
||||||
|
assert!(cols <= i32::MAX as usize);
|
||||||
|
|
||||||
|
// Output buffer: rows * i32. Pooled allocator so this is essentially free
|
||||||
|
// after the first call.
|
||||||
|
let bytes = rows * std::mem::size_of::<i32>();
|
||||||
|
let mut out = xserv_cuda::allocator::cached_alloc(bytes).expect("argmax out alloc");
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_argmax_bf16(
|
||||||
|
logits.data_ptr() as *const c_void,
|
||||||
|
out.as_mut_ptr() as *mut c_void,
|
||||||
|
rows as i32, cols as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut host_bytes = vec![0u8; bytes];
|
||||||
|
out.copy_to_host(&mut host_bytes).expect("argmax D2H");
|
||||||
|
drop(out); // returned to pool
|
||||||
|
|
||||||
|
let host_i32: &[i32] = unsafe {
|
||||||
|
std::slice::from_raw_parts(host_bytes.as_ptr() as *const i32, rows)
|
||||||
|
};
|
||||||
|
host_i32.iter().map(|&v| v as u32).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: argmax of a single row [1, cols] (or [cols] reshaped to [1, cols]).
|
||||||
|
pub fn argmax_bf16_single(logits: &Tensor) -> u32 {
|
||||||
|
let cols = *logits.shape().last().unwrap();
|
||||||
|
let rows = logits.numel() / cols;
|
||||||
|
assert_eq!(rows, 1, "argmax_bf16_single requires a single row");
|
||||||
|
let view = if logits.ndim() == 2 {
|
||||||
|
logits.clone()
|
||||||
|
} else {
|
||||||
|
logits.reshape(&[1, cols])
|
||||||
|
};
|
||||||
|
argmax_bf16_to_host(&view)[0]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,6 +10,127 @@ unsafe extern "C" {
|
|||||||
offset: i32, stream: *mut c_void);
|
offset: i32, stream: *mut c_void);
|
||||||
fn launch_causal_mask_bf16(scores: *mut c_void, batch: i32, rows: i32, cols: i32,
|
fn launch_causal_mask_bf16(scores: *mut c_void, batch: i32, rows: i32, cols: i32,
|
||||||
offset: i32, stream: *mut c_void);
|
offset: i32, stream: *mut c_void);
|
||||||
|
fn launch_flash_attention_bf16(
|
||||||
|
q: *const c_void, k: *const c_void, v: *const c_void, o: *mut c_void,
|
||||||
|
batch: i32, num_q_heads: i32, num_kv_heads: i32,
|
||||||
|
q_len: i32, kv_len: i32, head_dim: i32,
|
||||||
|
scale: f32, causal: i32, stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_flash_attention_sinks_bf16(
|
||||||
|
q: *const c_void, k: *const c_void, v: *const c_void, o: *mut c_void,
|
||||||
|
sinks: *const c_void,
|
||||||
|
batch: i32, num_q_heads: i32, num_kv_heads: i32,
|
||||||
|
q_len: i32, kv_len: i32, head_dim: i32,
|
||||||
|
scale: f32, causal: i32, window_size: i32, stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_decode_attention_bf16(
|
||||||
|
q: *const c_void, k: *const c_void, v: *const c_void, o: *mut c_void,
|
||||||
|
batch: i32, num_q_heads: i32, num_kv_heads: i32,
|
||||||
|
kv_len: i32, head_dim: i32,
|
||||||
|
scale: f32, causal: i32, stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_paged_decode_attention_bf16(
|
||||||
|
q: *const c_void,
|
||||||
|
k_cache: *const c_void,
|
||||||
|
v_cache: *const c_void,
|
||||||
|
o: *mut c_void,
|
||||||
|
block_tables: *const i32,
|
||||||
|
context_lens: *const i32,
|
||||||
|
batch: i32, num_q_heads: i32, num_kv_heads: i32,
|
||||||
|
head_dim: i32, max_blocks_per_seq: i32,
|
||||||
|
scale: f32, stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_paged_decode_attention_sinks_bf16(
|
||||||
|
q: *const c_void,
|
||||||
|
k_cache: *const c_void,
|
||||||
|
v_cache: *const c_void,
|
||||||
|
o: *mut c_void,
|
||||||
|
block_tables: *const i32,
|
||||||
|
context_lens: *const i32,
|
||||||
|
sinks: *const c_void,
|
||||||
|
batch: i32, num_q_heads: i32, num_kv_heads: i32,
|
||||||
|
head_dim: i32, max_blocks_per_seq: i32,
|
||||||
|
scale: f32, window_size: i32, stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_reshape_and_cache_bf16(
|
||||||
|
k_src: *const c_void, v_src: *const c_void,
|
||||||
|
k_pool: *mut c_void, v_pool: *mut c_void,
|
||||||
|
block_ids: *const c_void,
|
||||||
|
num_tokens: i32, num_heads: i32,
|
||||||
|
head_dim: i32, start_pos: i32, block_size: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_reshape_and_cache_batched_bf16(
|
||||||
|
k_src: *const c_void, v_src: *const c_void,
|
||||||
|
k_pool: *mut c_void, v_pool: *mut c_void,
|
||||||
|
block_tables: *const c_void, kv_lens: *const c_void,
|
||||||
|
batch: i32, num_heads: i32,
|
||||||
|
head_dim: i32, block_size: i32, max_blocks_per_seq: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scatter `[num_kv_heads, num_tokens, head_dim]` BF16 K/V into a paged
|
||||||
|
/// pool for a single sequence whose block table lives at `block_ids_gpu`
|
||||||
|
/// (int32, on device).
|
||||||
|
///
|
||||||
|
/// `k_pool_ptr`/`v_pool_ptr` point to one layer's pool, of logical shape
|
||||||
|
/// `[num_blocks_total, num_kv_heads, block_size, head_dim]`.
|
||||||
|
///
|
||||||
|
/// All pointers must be on the same GPU as the launching context.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Pointers must be valid GPU pointers with the documented layouts.
|
||||||
|
/// `block_ids_gpu` must contain at least `(start_pos + num_tokens + block_size - 1) / block_size`
|
||||||
|
/// valid physical block ids.
|
||||||
|
pub unsafe fn reshape_and_cache_bf16(
|
||||||
|
k_src: *const c_void, v_src: *const c_void,
|
||||||
|
k_pool_ptr: *mut c_void, v_pool_ptr: *mut c_void,
|
||||||
|
block_ids_gpu: *const i32,
|
||||||
|
num_tokens: usize, num_heads: usize,
|
||||||
|
head_dim: usize, start_pos: usize, block_size: usize,
|
||||||
|
stream: *mut c_void,
|
||||||
|
) {
|
||||||
|
unsafe {
|
||||||
|
launch_reshape_and_cache_bf16(
|
||||||
|
k_src, v_src,
|
||||||
|
k_pool_ptr, v_pool_ptr,
|
||||||
|
block_ids_gpu as *const c_void,
|
||||||
|
num_tokens as i32, num_heads as i32,
|
||||||
|
head_dim as i32, start_pos as i32, block_size as i32,
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batched scatter for the multi-sequence decode step. Reads
|
||||||
|
/// `block_tables` (`[batch, max_blocks_per_seq]` int32 — same buffer the
|
||||||
|
/// paged-attention kernel reads) and `kv_lens` (`[batch]` int32, current
|
||||||
|
/// seq_len + 1 — i.e., the index of the just-written token + 1) so the
|
||||||
|
/// caller doesn't need a separate per-step upload of block ids.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// All pointers must be on the same GPU. `block_tables` and `kv_lens` must
|
||||||
|
/// already be synced to the device for the active batch.
|
||||||
|
pub unsafe fn reshape_and_cache_batched_bf16(
|
||||||
|
k_src: *const c_void, v_src: *const c_void,
|
||||||
|
k_pool_ptr: *mut c_void, v_pool_ptr: *mut c_void,
|
||||||
|
block_tables_gpu: *const i32, kv_lens_gpu: *const i32,
|
||||||
|
batch: usize, num_heads: usize,
|
||||||
|
head_dim: usize, block_size: usize, max_blocks_per_seq: usize,
|
||||||
|
stream: *mut c_void,
|
||||||
|
) {
|
||||||
|
unsafe {
|
||||||
|
launch_reshape_and_cache_batched_bf16(
|
||||||
|
k_src, v_src,
|
||||||
|
k_pool_ptr, v_pool_ptr,
|
||||||
|
block_tables_gpu as *const c_void,
|
||||||
|
kv_lens_gpu as *const c_void,
|
||||||
|
batch as i32, num_heads as i32,
|
||||||
|
head_dim as i32, block_size as i32, max_blocks_per_seq as i32,
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_causal_mask(scores: &Tensor, offset: usize) {
|
fn apply_causal_mask(scores: &Tensor, offset: usize) {
|
||||||
@@ -33,7 +154,6 @@ fn apply_causal_mask(scores: &Tensor, offset: usize) {
|
|||||||
_ => panic!("unsupported dtype for causal mask"),
|
_ => panic!("unsupported dtype for causal mask"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Multi-head attention (naive, materializes S×S score matrix).
|
/// Multi-head attention (naive, materializes S×S score matrix).
|
||||||
@@ -75,3 +195,278 @@ pub fn attention(q: &Tensor, k: &Tensor, v: &Tensor, causal: bool) -> Tensor {
|
|||||||
// output = weights @ V → [B, H, q_len, head_dim]
|
// output = weights @ V → [B, H, q_len, head_dim]
|
||||||
batched_matmul(&weights, v)
|
batched_matmul(&weights, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode Attention — optimized for single-token decode (q_len=1).
|
||||||
|
///
|
||||||
|
/// q: [batch, num_q_heads, 1, head_dim] BF16, contiguous, GPU
|
||||||
|
/// k: [batch, num_kv_heads, kv_len, head_dim] BF16, contiguous, GPU
|
||||||
|
/// v: [batch, num_kv_heads, kv_len, head_dim] BF16, contiguous, GPU
|
||||||
|
///
|
||||||
|
/// Returns: [batch, num_q_heads, 1, head_dim] BF16
|
||||||
|
pub fn decode_attention(q: &Tensor, k: &Tensor, v: &Tensor) -> Tensor {
|
||||||
|
assert_eq!(q.ndim(), 4);
|
||||||
|
assert_eq!(q.shape()[2], 1, "decode_attention requires q_len == 1");
|
||||||
|
|
||||||
|
let batch = q.shape()[0];
|
||||||
|
let num_q_heads = q.shape()[1];
|
||||||
|
let head_dim = q.shape()[3];
|
||||||
|
let num_kv_heads = k.shape()[1];
|
||||||
|
let kv_len = k.shape()[2];
|
||||||
|
|
||||||
|
let scale = 1.0 / (head_dim as f32).sqrt();
|
||||||
|
let output = Tensor::empty(
|
||||||
|
&[batch, num_q_heads, 1, head_dim],
|
||||||
|
DType::BF16,
|
||||||
|
q.device(),
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_decode_attention_bf16(
|
||||||
|
q.data_ptr() as *const c_void,
|
||||||
|
k.data_ptr() as *const c_void,
|
||||||
|
v.data_ptr() as *const c_void,
|
||||||
|
output.data_ptr() as *mut c_void,
|
||||||
|
batch as i32,
|
||||||
|
num_q_heads as i32,
|
||||||
|
num_kv_heads as i32,
|
||||||
|
kv_len as i32,
|
||||||
|
head_dim as i32,
|
||||||
|
scale,
|
||||||
|
1, // causal (always 1 for decode)
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flash Attention 2 — O(1) extra memory, supports GQA natively.
|
||||||
|
/// Auto-dispatches to decode_attention when q_len == 1.
|
||||||
|
///
|
||||||
|
/// q: [batch, num_q_heads, q_len, head_dim] BF16, contiguous, GPU
|
||||||
|
/// k: [batch, num_kv_heads, kv_len, head_dim] BF16, contiguous, GPU
|
||||||
|
/// v: [batch, num_kv_heads, kv_len, head_dim] BF16, contiguous, GPU
|
||||||
|
///
|
||||||
|
/// Returns: [batch, num_q_heads, q_len, head_dim] BF16
|
||||||
|
pub fn flash_attention(q: &Tensor, k: &Tensor, v: &Tensor, causal: bool) -> Tensor {
|
||||||
|
assert_eq!(q.ndim(), 4);
|
||||||
|
assert_eq!(k.ndim(), 4);
|
||||||
|
assert_eq!(v.ndim(), 4);
|
||||||
|
assert!(q.is_contiguous() && k.is_contiguous() && v.is_contiguous());
|
||||||
|
assert_eq!(q.dtype(), DType::BF16, "flash_attention requires BF16");
|
||||||
|
assert_eq!(k.dtype(), DType::BF16);
|
||||||
|
assert_eq!(v.dtype(), DType::BF16);
|
||||||
|
|
||||||
|
let batch = q.shape()[0];
|
||||||
|
let num_q_heads = q.shape()[1];
|
||||||
|
let q_len = q.shape()[2];
|
||||||
|
let head_dim = q.shape()[3];
|
||||||
|
let num_kv_heads = k.shape()[1];
|
||||||
|
let kv_len = k.shape()[2];
|
||||||
|
|
||||||
|
assert_eq!(k.shape(), &[batch, num_kv_heads, kv_len, head_dim]);
|
||||||
|
assert_eq!(v.shape(), &[batch, num_kv_heads, kv_len, head_dim]);
|
||||||
|
assert!(num_q_heads % num_kv_heads == 0, "num_q_heads must be divisible by num_kv_heads");
|
||||||
|
assert!(head_dim <= 128, "flash_attention supports head_dim up to 128");
|
||||||
|
|
||||||
|
// Dispatch to specialized decode kernel for single-token generation
|
||||||
|
if q_len == 1 {
|
||||||
|
return decode_attention(q, k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = 1.0 / (head_dim as f32).sqrt();
|
||||||
|
let output = Tensor::empty(
|
||||||
|
&[batch, num_q_heads, q_len, head_dim],
|
||||||
|
DType::BF16,
|
||||||
|
q.device(),
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_flash_attention_bf16(
|
||||||
|
q.data_ptr() as *const c_void,
|
||||||
|
k.data_ptr() as *const c_void,
|
||||||
|
v.data_ptr() as *const c_void,
|
||||||
|
output.data_ptr() as *mut c_void,
|
||||||
|
batch as i32,
|
||||||
|
num_q_heads as i32,
|
||||||
|
num_kv_heads as i32,
|
||||||
|
q_len as i32,
|
||||||
|
kv_len as i32,
|
||||||
|
head_dim as i32,
|
||||||
|
scale,
|
||||||
|
if causal { 1 } else { 0 },
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flash attention for prefill with gpt-oss attention sinks + optional sliding window.
|
||||||
|
///
|
||||||
|
/// Same layout/contract as `flash_attention`, plus a per-head `sinks` tensor
|
||||||
|
/// ([num_q_heads] BF16, GPU) folded into the softmax denominator, and a
|
||||||
|
/// `window_size` (0 = full causal, >0 = sliding window). Always causal.
|
||||||
|
pub fn flash_attention_sinks(
|
||||||
|
q: &Tensor,
|
||||||
|
k: &Tensor,
|
||||||
|
v: &Tensor,
|
||||||
|
sinks: &Tensor,
|
||||||
|
window_size: usize,
|
||||||
|
) -> Tensor {
|
||||||
|
assert_eq!(q.ndim(), 4);
|
||||||
|
assert_eq!(k.ndim(), 4);
|
||||||
|
assert_eq!(v.ndim(), 4);
|
||||||
|
assert!(q.is_contiguous() && k.is_contiguous() && v.is_contiguous());
|
||||||
|
assert_eq!(q.dtype(), DType::BF16);
|
||||||
|
assert_eq!(k.dtype(), DType::BF16);
|
||||||
|
assert_eq!(v.dtype(), DType::BF16);
|
||||||
|
|
||||||
|
let batch = q.shape()[0];
|
||||||
|
let num_q_heads = q.shape()[1];
|
||||||
|
let q_len = q.shape()[2];
|
||||||
|
let head_dim = q.shape()[3];
|
||||||
|
let num_kv_heads = k.shape()[1];
|
||||||
|
let kv_len = k.shape()[2];
|
||||||
|
|
||||||
|
assert_eq!(k.shape(), &[batch, num_kv_heads, kv_len, head_dim]);
|
||||||
|
assert_eq!(v.shape(), &[batch, num_kv_heads, kv_len, head_dim]);
|
||||||
|
assert!(num_q_heads % num_kv_heads == 0);
|
||||||
|
assert!(head_dim <= 128);
|
||||||
|
assert_eq!(sinks.shape()[0], num_q_heads, "sinks must have num_q_heads entries");
|
||||||
|
|
||||||
|
let scale = 1.0 / (head_dim as f32).sqrt();
|
||||||
|
let output = Tensor::empty(&[batch, num_q_heads, q_len, head_dim], DType::BF16, q.device());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_flash_attention_sinks_bf16(
|
||||||
|
q.data_ptr() as *const c_void,
|
||||||
|
k.data_ptr() as *const c_void,
|
||||||
|
v.data_ptr() as *const c_void,
|
||||||
|
output.data_ptr() as *mut c_void,
|
||||||
|
sinks.data_ptr() as *const c_void,
|
||||||
|
batch as i32,
|
||||||
|
num_q_heads as i32,
|
||||||
|
num_kv_heads as i32,
|
||||||
|
q_len as i32,
|
||||||
|
kv_len as i32,
|
||||||
|
head_dim as i32,
|
||||||
|
scale,
|
||||||
|
1, // always causal
|
||||||
|
window_size as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paged decode attention.
|
||||||
|
///
|
||||||
|
/// q: [batch, num_q_heads, 1, head_dim] BF16, contiguous, GPU
|
||||||
|
/// k_cache_ptr / v_cache_ptr: pointers to [num_blocks, num_kv_heads, BLOCK_SIZE, head_dim] BF16 pools
|
||||||
|
/// block_tables_ptr: i32 [batch, max_blocks_per_seq] (rows already arranged for this batch)
|
||||||
|
/// context_lens_ptr: i32 [batch]
|
||||||
|
///
|
||||||
|
/// Returns: [batch, num_q_heads, 1, head_dim] BF16
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn paged_decode_attention(
|
||||||
|
q: &Tensor,
|
||||||
|
k_cache_ptr: *const c_void,
|
||||||
|
v_cache_ptr: *const c_void,
|
||||||
|
block_tables_ptr: *const i32,
|
||||||
|
context_lens_ptr: *const i32,
|
||||||
|
batch: usize,
|
||||||
|
num_q_heads: usize,
|
||||||
|
num_kv_heads: usize,
|
||||||
|
head_dim: usize,
|
||||||
|
max_blocks_per_seq: usize,
|
||||||
|
) -> Tensor {
|
||||||
|
assert_eq!(q.ndim(), 4);
|
||||||
|
assert_eq!(q.shape()[2], 1, "paged_decode_attention requires q_len == 1");
|
||||||
|
assert_eq!(q.dtype(), DType::BF16);
|
||||||
|
assert!(num_q_heads % num_kv_heads == 0, "GQA: num_q_heads must be divisible by num_kv_heads");
|
||||||
|
assert!(head_dim <= 128);
|
||||||
|
|
||||||
|
let scale = 1.0 / (head_dim as f32).sqrt();
|
||||||
|
let output = Tensor::empty(
|
||||||
|
&[batch, num_q_heads, 1, head_dim],
|
||||||
|
DType::BF16,
|
||||||
|
q.device(),
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_paged_decode_attention_bf16(
|
||||||
|
q.data_ptr() as *const c_void,
|
||||||
|
k_cache_ptr,
|
||||||
|
v_cache_ptr,
|
||||||
|
output.data_ptr() as *mut c_void,
|
||||||
|
block_tables_ptr,
|
||||||
|
context_lens_ptr,
|
||||||
|
batch as i32,
|
||||||
|
num_q_heads as i32,
|
||||||
|
num_kv_heads as i32,
|
||||||
|
head_dim as i32,
|
||||||
|
max_blocks_per_seq as i32,
|
||||||
|
scale,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paged decode attention with attention sinks and optional sliding window.
|
||||||
|
///
|
||||||
|
/// sinks_ptr: pointer to [num_q_heads] BF16 on GPU (or null for no sinks)
|
||||||
|
/// window_size: 0 = full attention, >0 = sliding window
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn paged_decode_attention_sinks(
|
||||||
|
q: &Tensor,
|
||||||
|
k_cache_ptr: *const c_void,
|
||||||
|
v_cache_ptr: *const c_void,
|
||||||
|
block_tables_ptr: *const i32,
|
||||||
|
context_lens_ptr: *const i32,
|
||||||
|
sinks_ptr: *const c_void,
|
||||||
|
batch: usize,
|
||||||
|
num_q_heads: usize,
|
||||||
|
num_kv_heads: usize,
|
||||||
|
head_dim: usize,
|
||||||
|
max_blocks_per_seq: usize,
|
||||||
|
window_size: usize,
|
||||||
|
) -> Tensor {
|
||||||
|
assert_eq!(q.ndim(), 4);
|
||||||
|
assert_eq!(q.shape()[2], 1);
|
||||||
|
assert_eq!(q.dtype(), DType::BF16);
|
||||||
|
assert!(num_q_heads % num_kv_heads == 0);
|
||||||
|
assert!(head_dim <= 128);
|
||||||
|
|
||||||
|
let scale = 1.0 / (head_dim as f32).sqrt();
|
||||||
|
let output = Tensor::empty(
|
||||||
|
&[batch, num_q_heads, 1, head_dim],
|
||||||
|
DType::BF16,
|
||||||
|
q.device(),
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_paged_decode_attention_sinks_bf16(
|
||||||
|
q.data_ptr() as *const c_void,
|
||||||
|
k_cache_ptr,
|
||||||
|
v_cache_ptr,
|
||||||
|
output.data_ptr() as *mut c_void,
|
||||||
|
block_tables_ptr,
|
||||||
|
context_lens_ptr,
|
||||||
|
sinks_ptr,
|
||||||
|
batch as i32,
|
||||||
|
num_q_heads as i32,
|
||||||
|
num_kv_heads as i32,
|
||||||
|
head_dim as i32,
|
||||||
|
max_blocks_per_seq as i32,
|
||||||
|
scale,
|
||||||
|
window_size as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|||||||
118
crates/xserv-kernels/src/dispatch.rs
Normal file
118
crates/xserv-kernels/src/dispatch.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
//! Low-level kernel dispatchers for CUDA Graph capture.
|
||||||
|
//! These functions write to pre-allocated output buffers and accept an explicit stream.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
// Re-declare the extern functions we need (same as in the individual modules)
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn launch_rmsnorm_bf16(x: *const c_void, gamma: *const c_void, out: *mut c_void,
|
||||||
|
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void);
|
||||||
|
fn launch_add_rmsnorm_bf16(x: *const c_void, residual: *const c_void, gamma: *const c_void,
|
||||||
|
normed_out: *mut c_void, sum_out: *mut c_void,
|
||||||
|
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void);
|
||||||
|
fn launch_silu_mul_bf16(gate: *const c_void, up: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
||||||
|
fn launch_add_bf16(a: *const c_void, b: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void);
|
||||||
|
fn launch_embedding_bf16(table: *const c_void, token_ids: *const c_void, out: *mut c_void,
|
||||||
|
num_tokens: i32, hidden_size: i32, vocab_size: i32, stream: *mut c_void);
|
||||||
|
fn launch_reshape_heads_bf16(inp: *const c_void, out: *mut c_void, seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_merge_heads_bf16(inp: *const c_void, out: *mut c_void, seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_transpose_hsd_to_shd_bf16(inp: *const c_void, out: *mut c_void, seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_transpose_shd_to_hsd_bf16(inp: *const c_void, out: *mut c_void, seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_rope_bf16(x: *mut c_void, cos_cache: *const c_void, sin_cache: *const c_void,
|
||||||
|
positions: *const c_void, num_tokens: i32, num_heads: i32,
|
||||||
|
head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_gemv_bf16(x: *const c_void, w: *const c_void, y_bf16: *mut c_void, y_fp32_buf: *mut c_void,
|
||||||
|
k: i32, n: i32, stream: *mut c_void);
|
||||||
|
fn launch_decode_attention_bf16(
|
||||||
|
q: *const c_void, k: *const c_void, v: *const c_void, o: *mut c_void,
|
||||||
|
batch: i32, num_q_heads: i32, num_kv_heads: i32,
|
||||||
|
kv_len: i32, head_dim: i32,
|
||||||
|
scale: f32, causal: i32, stream: *mut c_void,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw rmsnorm dispatch: writes to pre-allocated `out`.
|
||||||
|
pub unsafe fn rmsnorm_bf16(x: *const c_void, gamma: *const c_void, out: *mut c_void,
|
||||||
|
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void) {
|
||||||
|
launch_rmsnorm_bf16(x, gamma, out, rows, hidden_size, eps, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw add_rmsnorm dispatch.
|
||||||
|
pub unsafe fn add_rmsnorm_bf16(x: *const c_void, residual: *const c_void, gamma: *const c_void,
|
||||||
|
normed_out: *mut c_void, sum_out: *mut c_void,
|
||||||
|
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void) {
|
||||||
|
launch_add_rmsnorm_bf16(x, residual, gamma, normed_out, sum_out, rows, hidden_size, eps, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw silu_mul dispatch.
|
||||||
|
pub unsafe fn silu_mul_bf16(gate: *const c_void, up: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void) {
|
||||||
|
launch_silu_mul_bf16(gate, up, out, n, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw add dispatch.
|
||||||
|
pub unsafe fn add_bf16(a: *const c_void, b: *const c_void, out: *mut c_void, n: i32, stream: *mut c_void) {
|
||||||
|
launch_add_bf16(a, b, out, n, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw embedding dispatch.
|
||||||
|
pub unsafe fn embedding_bf16(table: *const c_void, token_ids: *const c_void, out: *mut c_void,
|
||||||
|
num_tokens: i32, hidden_size: i32, vocab_size: i32, stream: *mut c_void) {
|
||||||
|
launch_embedding_bf16(table, token_ids, out, num_tokens, hidden_size, vocab_size, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw reshape_heads dispatch.
|
||||||
|
pub unsafe fn reshape_heads_bf16(inp: *const c_void, out: *mut c_void,
|
||||||
|
seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void) {
|
||||||
|
launch_reshape_heads_bf16(inp, out, seq_len, num_heads, head_dim, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw merge_heads dispatch.
|
||||||
|
pub unsafe fn merge_heads_bf16(inp: *const c_void, out: *mut c_void,
|
||||||
|
seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void) {
|
||||||
|
launch_merge_heads_bf16(inp, out, seq_len, num_heads, head_dim, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw transpose HSD->SHD dispatch.
|
||||||
|
pub unsafe fn transpose_hsd_to_shd_bf16(inp: *const c_void, out: *mut c_void,
|
||||||
|
seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void) {
|
||||||
|
launch_transpose_hsd_to_shd_bf16(inp, out, seq_len, num_heads, head_dim, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw transpose SHD->HSD dispatch.
|
||||||
|
pub unsafe fn transpose_shd_to_hsd_bf16(inp: *const c_void, out: *mut c_void,
|
||||||
|
seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void) {
|
||||||
|
launch_transpose_shd_to_hsd_bf16(inp, out, seq_len, num_heads, head_dim, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw RoPE dispatch (in-place).
|
||||||
|
pub unsafe fn rope_bf16(x: *mut c_void, cos_cache: *const c_void, sin_cache: *const c_void,
|
||||||
|
positions: *const c_void, num_tokens: i32, num_heads: i32,
|
||||||
|
head_dim: i32, stream: *mut c_void) {
|
||||||
|
launch_rope_bf16(x, cos_cache, sin_cache, positions, num_tokens, num_heads, head_dim, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw GEMV dispatch (BF16, M=1). Caller must provide fp32 accumulator buffer.
|
||||||
|
pub unsafe fn gemv_bf16(x: *const c_void, w: *const c_void, y_bf16: *mut c_void,
|
||||||
|
y_fp32_buf: *mut c_void, k: i32, n: i32, stream: *mut c_void) {
|
||||||
|
launch_gemv_bf16(x, w, y_bf16, y_fp32_buf, k, n, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw decode attention dispatch.
|
||||||
|
pub unsafe fn decode_attention_bf16(q: *const c_void, k: *const c_void, v: *const c_void, o: *mut c_void,
|
||||||
|
batch: i32, num_q_heads: i32, num_kv_heads: i32,
|
||||||
|
kv_len: i32, head_dim: i32,
|
||||||
|
scale: f32, stream: *mut c_void) {
|
||||||
|
launch_decode_attention_bf16(q, k, v, o, batch, num_q_heads, num_kv_heads, kv_len, head_dim, scale, 1, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cuBLAS FFI
|
||||||
|
pub type CublasHandle = *mut c_void;
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn cublasSetStream_v2(handle: CublasHandle, stream: *mut c_void) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set cuBLAS stream. Must be called before any cuBLAS operations during graph capture.
|
||||||
|
pub unsafe fn set_cublas_stream(handle: CublasHandle, stream: *mut c_void) {
|
||||||
|
cublasSetStream_v2(handle, stream);
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ use xserv_tensor::{DType, Device, Tensor};
|
|||||||
|
|
||||||
unsafe extern "C" {
|
unsafe extern "C" {
|
||||||
fn launch_embedding_f32(table: *const c_void, token_ids: *const c_void, out: *mut c_void,
|
fn launch_embedding_f32(table: *const c_void, token_ids: *const c_void, out: *mut c_void,
|
||||||
num_tokens: i32, hidden_size: i32, stream: *mut c_void);
|
num_tokens: i32, hidden_size: i32, vocab_size: i32, stream: *mut c_void);
|
||||||
fn launch_embedding_bf16(table: *const c_void, token_ids: *const c_void, out: *mut c_void,
|
fn launch_embedding_bf16(table: *const c_void, token_ids: *const c_void, out: *mut c_void,
|
||||||
num_tokens: i32, hidden_size: i32, stream: *mut c_void);
|
num_tokens: i32, hidden_size: i32, vocab_size: i32, stream: *mut c_void);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embedding lookup: table[token_ids[i]] for each i.
|
/// Embedding lookup: table[token_ids[i]] for each i.
|
||||||
@@ -18,6 +18,9 @@ pub fn embedding(table: &Tensor, token_ids: &[u32]) -> Tensor {
|
|||||||
|
|
||||||
let hidden_size = table.shape()[1];
|
let hidden_size = table.shape()[1];
|
||||||
let num_tokens = token_ids.len();
|
let num_tokens = token_ids.len();
|
||||||
|
let vocab_size = table.shape()[0];
|
||||||
|
assert!(num_tokens <= i32::MAX as usize, "too many tokens for i32 kernel param");
|
||||||
|
assert!(hidden_size <= i32::MAX as usize, "hidden_size too large for i32 kernel param");
|
||||||
|
|
||||||
// Upload token_ids to GPU
|
// Upload token_ids to GPU
|
||||||
let ids_bytes = unsafe {
|
let ids_bytes = unsafe {
|
||||||
@@ -26,26 +29,29 @@ pub fn embedding(table: &Tensor, token_ids: &[u32]) -> Tensor {
|
|||||||
num_tokens * std::mem::size_of::<u32>(),
|
num_tokens * std::mem::size_of::<u32>(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let mut ids_gpu = GpuBuffer::alloc(ids_bytes.len()).expect("alloc token_ids");
|
let mut ids_gpu = xserv_cuda::allocator::cached_alloc(ids_bytes.len()).expect("alloc token_ids");
|
||||||
ids_gpu.copy_from_host(ids_bytes).unwrap();
|
ids_gpu.copy_from_host(ids_bytes).unwrap();
|
||||||
|
|
||||||
let out = Tensor::zeros(&[num_tokens, hidden_size], table.dtype(), table.device());
|
for &tid in token_ids {
|
||||||
|
assert!((tid as usize) < vocab_size, "token_id {tid} out of bounds (vocab_size={vocab_size})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = Tensor::empty(&[num_tokens, hidden_size], table.dtype(), table.device());
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
match table.dtype() {
|
match table.dtype() {
|
||||||
DType::F32 => launch_embedding_f32(
|
DType::F32 => launch_embedding_f32(
|
||||||
table.data_ptr() as _, ids_gpu.as_ptr() as _,
|
table.data_ptr() as _, ids_gpu.as_ptr() as _,
|
||||||
out.data_ptr() as *mut c_void,
|
out.data_ptr() as *mut c_void,
|
||||||
num_tokens as i32, hidden_size as i32, std::ptr::null_mut(),
|
num_tokens as i32, hidden_size as i32, vocab_size as i32, std::ptr::null_mut(),
|
||||||
),
|
),
|
||||||
DType::BF16 => launch_embedding_bf16(
|
DType::BF16 => launch_embedding_bf16(
|
||||||
table.data_ptr() as _, ids_gpu.as_ptr() as _,
|
table.data_ptr() as _, ids_gpu.as_ptr() as _,
|
||||||
out.data_ptr() as *mut c_void,
|
out.data_ptr() as *mut c_void,
|
||||||
num_tokens as i32, hidden_size as i32, std::ptr::null_mut(),
|
num_tokens as i32, hidden_size as i32, vocab_size as i32, std::ptr::null_mut(),
|
||||||
),
|
),
|
||||||
_ => panic!("unsupported dtype for embedding"),
|
_ => panic!("unsupported dtype for embedding"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use xserv_cuda::error::{self, Result};
|
use xserv_cuda::error::{self, Result};
|
||||||
|
use xserv_cuda::GpuBuffer;
|
||||||
use xserv_tensor::{DType, Device, Tensor};
|
use xserv_tensor::{DType, Device, Tensor};
|
||||||
|
|
||||||
|
const CUBLAS_WORKSPACE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
// GEMV: single-kernel, no FP32 temp buffer needed
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn launch_gemv_bf16(x: *const c_void, w: *const c_void, y_bf16: *mut c_void, y_fp32_buf: *mut c_void, k: i32, n: i32, stream: *mut c_void);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum GemmBackend {
|
pub enum GemmBackend {
|
||||||
Naive,
|
Naive,
|
||||||
@@ -18,7 +27,7 @@ unsafe extern "C" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- FFI: cuBLAS ---
|
// --- FFI: cuBLAS ---
|
||||||
type CublasHandle = *mut c_void;
|
pub type CublasHandle = *mut c_void;
|
||||||
|
|
||||||
#[allow(non_upper_case_globals)]
|
#[allow(non_upper_case_globals)]
|
||||||
const CUBLAS_OP_N: i32 = 0;
|
const CUBLAS_OP_N: i32 = 0;
|
||||||
@@ -34,6 +43,7 @@ unsafe extern "C" {
|
|||||||
fn cublasCreate_v2(handle: *mut CublasHandle) -> i32;
|
fn cublasCreate_v2(handle: *mut CublasHandle) -> i32;
|
||||||
fn cublasDestroy_v2(handle: CublasHandle) -> i32;
|
fn cublasDestroy_v2(handle: CublasHandle) -> i32;
|
||||||
fn cublasSetStream_v2(handle: CublasHandle, stream: *mut c_void) -> i32;
|
fn cublasSetStream_v2(handle: CublasHandle, stream: *mut c_void) -> i32;
|
||||||
|
fn cublasSetWorkspace_v2(handle: CublasHandle, workspace: *mut c_void, size: usize) -> i32;
|
||||||
fn cublasGemmEx(
|
fn cublasGemmEx(
|
||||||
handle: CublasHandle,
|
handle: CublasHandle,
|
||||||
transa: i32, transb: i32,
|
transa: i32, transb: i32,
|
||||||
@@ -63,13 +73,25 @@ unsafe extern "C" {
|
|||||||
|
|
||||||
pub struct CublasContext {
|
pub struct CublasContext {
|
||||||
handle: CublasHandle,
|
handle: CublasHandle,
|
||||||
|
/// Dedicated 32 MiB workspace owned by this handle. Held to keep the GPU
|
||||||
|
/// buffer alive for the lifetime of the handle; cuBLAS reads/writes into
|
||||||
|
/// it during GEMM. Dropped after `cublasDestroy_v2` so cuBLAS can't touch
|
||||||
|
/// freed memory.
|
||||||
|
_workspace: Option<GpuBuffer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CublasContext {
|
impl CublasContext {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let mut handle = std::ptr::null_mut();
|
let mut handle = std::ptr::null_mut();
|
||||||
error::check(unsafe { cublasCreate_v2(&mut handle) })?;
|
error::check(unsafe { cublasCreate_v2(&mut handle) })?;
|
||||||
Ok(Self { handle })
|
// Attach a per-handle workspace. cublasSetWorkspace requires the
|
||||||
|
// pointer to remain valid until destroy or until a new workspace is
|
||||||
|
// set, so we keep the GpuBuffer in this struct.
|
||||||
|
let mut workspace = GpuBuffer::alloc(CUBLAS_WORKSPACE_BYTES)?;
|
||||||
|
error::check(unsafe {
|
||||||
|
cublasSetWorkspace_v2(handle, workspace.as_mut_ptr() as *mut c_void, CUBLAS_WORKSPACE_BYTES)
|
||||||
|
})?;
|
||||||
|
Ok(Self { handle, _workspace: Some(workspace) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +100,34 @@ impl Drop for CublasContext {
|
|||||||
if !self.handle.is_null() {
|
if !self.handle.is_null() {
|
||||||
unsafe { cublasDestroy_v2(self.handle) };
|
unsafe { cublasDestroy_v2(self.handle) };
|
||||||
}
|
}
|
||||||
|
// _workspace drops here, after cublasDestroy_v2 has released the handle.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CUBLAS_CTX: RefCell<CublasContext> = RefCell::new(
|
||||||
|
CublasContext::new().expect("failed to create thread-local cuBLAS handle")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the thread-local cuBLAS handle for the duration of a closure.
|
||||||
|
fn with_cublas<F, R>(f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(CublasHandle) -> R,
|
||||||
|
{
|
||||||
|
CUBLAS_CTX.with(|cell| {
|
||||||
|
let ctx = cell.borrow();
|
||||||
|
f(ctx.handle)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the thread-local cuBLAS handle for use with dispatch module.
|
||||||
|
pub fn cublas_handle() -> CublasHandle {
|
||||||
|
CUBLAS_CTX.with(|cell| {
|
||||||
|
cell.borrow().handle
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Matrix multiplication: C = A @ B
|
/// Matrix multiplication: C = A @ B
|
||||||
/// A: [M, K], B: [K, N], C: [M, N]
|
/// A: [M, K], B: [K, N], C: [M, N]
|
||||||
/// All tensors must be contiguous and on the same GPU.
|
/// All tensors must be contiguous and on the same GPU.
|
||||||
@@ -97,7 +144,9 @@ pub fn matmul(a: &Tensor, b: &Tensor, backend: GemmBackend) -> Tensor {
|
|||||||
let n = b.shape()[1];
|
let n = b.shape()[1];
|
||||||
let dtype = a.dtype();
|
let dtype = a.dtype();
|
||||||
|
|
||||||
let c = Tensor::zeros(&[m, n], dtype, a.device());
|
// All backends (naive, tiled, cuBLAS with beta=0, custom GEMV) fully
|
||||||
|
// overwrite every element of C, so we skip the cudaMemset.
|
||||||
|
let c = Tensor::empty(&[m, n], dtype, a.device());
|
||||||
|
|
||||||
let a_ptr = a.data_ptr() as *const c_void;
|
let a_ptr = a.data_ptr() as *const c_void;
|
||||||
let b_ptr = b.data_ptr() as *const c_void;
|
let b_ptr = b.data_ptr() as *const c_void;
|
||||||
@@ -113,7 +162,6 @@ pub fn matmul(a: &Tensor, b: &Tensor, backend: GemmBackend) -> Tensor {
|
|||||||
_ => panic!("unsupported dtype for naive GEMM"),
|
_ => panic!("unsupported dtype for naive GEMM"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
}
|
}
|
||||||
GemmBackend::Tiled => {
|
GemmBackend::Tiled => {
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -123,40 +171,44 @@ pub fn matmul(a: &Tensor, b: &Tensor, backend: GemmBackend) -> Tensor {
|
|||||||
_ => panic!("unsupported dtype for tiled GEMM"),
|
_ => panic!("unsupported dtype for tiled GEMM"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
}
|
}
|
||||||
GemmBackend::CuBlas => {
|
GemmBackend::CuBlas => {
|
||||||
// cuBLAS uses column-major, but we have row-major tensors.
|
if m == 1 && dtype == DType::BF16 && n >= 256 {
|
||||||
// Trick: compute C^T = B^T @ A^T, which gives us C in row-major.
|
let mut fp32_buf = xserv_cuda::allocator::cached_alloc(n * 4).unwrap();
|
||||||
// cuBLAS sees our row-major data as column-major transposed.
|
unsafe {
|
||||||
let ctx = CublasContext::new().unwrap();
|
launch_gemv_bf16(
|
||||||
let alpha = 1.0f32;
|
a_ptr, b_ptr, c_ptr,
|
||||||
let beta = 0.0f32;
|
fp32_buf.as_mut_ptr() as *mut c_void,
|
||||||
|
k as i32, n as i32,
|
||||||
|
null_stream,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let alpha = 1.0f32;
|
||||||
|
let beta = 0.0f32;
|
||||||
|
|
||||||
let (a_type, b_type, c_type) = match dtype {
|
let (a_type, b_type, c_type) = match dtype {
|
||||||
DType::F32 => (CUDA_R_32F, CUDA_R_32F, CUDA_R_32F),
|
DType::F32 => (CUDA_R_32F, CUDA_R_32F, CUDA_R_32F),
|
||||||
DType::BF16 => (CUDA_R_16BF, CUDA_R_16BF, CUDA_R_16BF),
|
DType::BF16 => (CUDA_R_16BF, CUDA_R_16BF, CUDA_R_16BF),
|
||||||
_ => panic!("unsupported dtype for cuBLAS GEMM"),
|
_ => panic!("unsupported dtype for cuBLAS GEMM"),
|
||||||
};
|
};
|
||||||
|
|
||||||
unsafe {
|
with_cublas(|handle| unsafe {
|
||||||
cublasSetStream_v2(ctx.handle, null_stream);
|
cublasSetStream_v2(handle, null_stream);
|
||||||
// Row-major trick: swap A/B and transpose flags
|
error::check(cublasGemmEx(
|
||||||
// C(row-major) = A @ B <=> C^T(col-major) = B^T @ A^T
|
handle,
|
||||||
error::check(cublasGemmEx(
|
CUBLAS_OP_N, CUBLAS_OP_N,
|
||||||
ctx.handle,
|
n as i32, m as i32, k as i32,
|
||||||
CUBLAS_OP_N, CUBLAS_OP_N,
|
&alpha as *const f32 as *const c_void,
|
||||||
n as i32, m as i32, k as i32,
|
b_ptr, b_type, n as i32,
|
||||||
&alpha as *const f32 as *const c_void,
|
a_ptr, a_type, k as i32,
|
||||||
b_ptr, b_type, n as i32, // B as col-major = B^T
|
&beta as *const f32 as *const c_void,
|
||||||
a_ptr, a_type, k as i32, // A as col-major = A^T
|
c_ptr, c_type, n as i32,
|
||||||
&beta as *const f32 as *const c_void,
|
CUBLAS_COMPUTE_32F,
|
||||||
c_ptr, c_type, n as i32, // C as col-major = C^T
|
-1,
|
||||||
CUBLAS_COMPUTE_32F,
|
)).expect("cuBLAS GEMM failed");
|
||||||
-1, // default algo
|
});
|
||||||
)).expect("cuBLAS GEMM failed");
|
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +242,8 @@ pub fn batched_matmul(a: &Tensor, b: &Tensor) -> Tensor {
|
|||||||
let mut out_shape: Vec<usize> = a.shape()[..ndim - 2].to_vec();
|
let mut out_shape: Vec<usize> = a.shape()[..ndim - 2].to_vec();
|
||||||
out_shape.push(m);
|
out_shape.push(m);
|
||||||
out_shape.push(n);
|
out_shape.push(n);
|
||||||
let c = Tensor::zeros(&out_shape, a.dtype(), a.device());
|
// cuBLAS with beta=0 fully overwrites every element of C.
|
||||||
|
let c = Tensor::empty(&out_shape, a.dtype(), a.device());
|
||||||
|
|
||||||
let dtype = a.dtype();
|
let dtype = a.dtype();
|
||||||
let (a_type, b_type, c_type) = match dtype {
|
let (a_type, b_type, c_type) = match dtype {
|
||||||
@@ -206,12 +259,11 @@ pub fn batched_matmul(a: &Tensor, b: &Tensor) -> Tensor {
|
|||||||
let stride_b = (k * n) as i64;
|
let stride_b = (k * n) as i64;
|
||||||
let stride_c = (m * n) as i64;
|
let stride_c = (m * n) as i64;
|
||||||
|
|
||||||
let ctx = CublasContext::new().unwrap();
|
with_cublas(|handle| unsafe {
|
||||||
unsafe {
|
cublasSetStream_v2(handle, std::ptr::null_mut());
|
||||||
cublasSetStream_v2(ctx.handle, std::ptr::null_mut());
|
|
||||||
// Row-major trick: C = A @ B ⟺ C^T = B^T @ A^T (col-major)
|
// Row-major trick: C = A @ B ⟺ C^T = B^T @ A^T (col-major)
|
||||||
error::check(cublasGemmStridedBatchedEx(
|
error::check(cublasGemmStridedBatchedEx(
|
||||||
ctx.handle,
|
handle,
|
||||||
CUBLAS_OP_N, CUBLAS_OP_N,
|
CUBLAS_OP_N, CUBLAS_OP_N,
|
||||||
n as i32, m as i32, k as i32,
|
n as i32, m as i32, k as i32,
|
||||||
&alpha as *const f32 as *const c_void,
|
&alpha as *const f32 as *const c_void,
|
||||||
@@ -223,7 +275,6 @@ pub fn batched_matmul(a: &Tensor, b: &Tensor) -> Tensor {
|
|||||||
CUBLAS_COMPUTE_32F,
|
CUBLAS_COMPUTE_32F,
|
||||||
-1,
|
-1,
|
||||||
)).expect("cuBLAS batched GEMM failed");
|
)).expect("cuBLAS batched GEMM failed");
|
||||||
}
|
});
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ pub fn layernorm(x: &Tensor, gamma: &Tensor, beta: &Tensor, eps: f32) -> Tensor
|
|||||||
assert_eq!(beta.shape(), &[hidden_size]);
|
assert_eq!(beta.shape(), &[hidden_size]);
|
||||||
|
|
||||||
let rows = x.numel() / hidden_size;
|
let rows = x.numel() / hidden_size;
|
||||||
let out = Tensor::zeros(x.shape(), x.dtype(), x.device());
|
assert!(rows <= i32::MAX as usize, "too many rows for i32 kernel param");
|
||||||
|
assert!(hidden_size <= i32::MAX as usize, "hidden_size too large for i32 kernel param");
|
||||||
|
let out = Tensor::empty(x.shape(), x.dtype(), x.device());
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
match x.dtype() {
|
match x.dtype() {
|
||||||
@@ -34,6 +36,5 @@ pub fn layernorm(x: &Tensor, gamma: &Tensor, beta: &Tensor, eps: f32) -> Tensor
|
|||||||
_ => panic!("unsupported dtype for layernorm"),
|
_ => panic!("unsupported dtype for layernorm"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
pub mod activation;
|
pub mod activation;
|
||||||
|
pub mod argmax;
|
||||||
pub mod attention;
|
pub mod attention;
|
||||||
|
pub mod dispatch;
|
||||||
pub mod embedding;
|
pub mod embedding;
|
||||||
pub mod gemm;
|
pub mod gemm;
|
||||||
pub mod layernorm;
|
pub mod layernorm;
|
||||||
|
pub mod moe;
|
||||||
|
pub mod quantization;
|
||||||
pub mod rmsnorm;
|
pub mod rmsnorm;
|
||||||
pub mod rope;
|
pub mod rope;
|
||||||
pub mod softmax;
|
pub mod softmax;
|
||||||
|
pub mod transpose;
|
||||||
|
|
||||||
pub use activation::{add, gelu, mul, scale, silu};
|
pub use activation::{add, gelu, gpt_oss_glu, mul, scale, silu, silu_mul};
|
||||||
pub use attention::attention;
|
pub use argmax::{argmax_bf16_single, argmax_bf16_to_host};
|
||||||
|
pub use transpose::{merge_heads_gpu, repeat_kv_gpu, reshape_heads_gpu, strided_to_contiguous_gpu, transpose_for_rope_gpu, transpose_from_rope_gpu};
|
||||||
|
pub use attention::{attention, decode_attention, flash_attention, flash_attention_sinks, paged_decode_attention, paged_decode_attention_sinks, reshape_and_cache_bf16, reshape_and_cache_batched_bf16};
|
||||||
pub use embedding::embedding;
|
pub use embedding::embedding;
|
||||||
pub use gemm::{batched_matmul, matmul, GemmBackend};
|
pub use gemm::{batched_matmul, matmul, GemmBackend};
|
||||||
pub use layernorm::layernorm;
|
pub use layernorm::layernorm;
|
||||||
pub use rmsnorm::rmsnorm;
|
pub use rmsnorm::{add_rmsnorm, rmsnorm};
|
||||||
pub use rope::{rope_inplace, RopeCache};
|
pub use rope::{rope_inplace, RopeCache};
|
||||||
pub use softmax::softmax;
|
pub use softmax::softmax;
|
||||||
|
|
||||||
|
/// Register GPU kernels with the tensor crate. Call once at startup.
|
||||||
|
pub fn init() {
|
||||||
|
xserv_tensor::register_gpu_contiguous(strided_to_contiguous_gpu);
|
||||||
|
}
|
||||||
|
|||||||
223
crates/xserv-kernels/src/moe.rs
Normal file
223
crates/xserv-kernels/src/moe.rs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
use std::ffi::c_void;
|
||||||
|
use xserv_tensor::{DType, Device, Tensor};
|
||||||
|
|
||||||
|
use crate::gemm::{cublas_handle, CublasHandle};
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn launch_moe_topk_softmax_bf16(
|
||||||
|
router_logits: *const c_void,
|
||||||
|
topk_ids: *mut c_void, topk_weights: *mut c_void,
|
||||||
|
num_tokens: i32, num_experts: i32, top_k: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_moe_replicate_bf16(
|
||||||
|
x: *const c_void, x_rep: *mut c_void,
|
||||||
|
num_tokens: i32, hidden: i32, local_experts: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_moe_bias_add_3d_bf16(
|
||||||
|
x: *mut c_void, bias: *const c_void,
|
||||||
|
batch: i32, num_tokens: i32, dim: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_moe_weighted_sum_bf16(
|
||||||
|
expert_out: *const c_void,
|
||||||
|
topk_ids: *const c_void, topk_weights: *const c_void,
|
||||||
|
out: *mut c_void,
|
||||||
|
num_tokens: i32, hidden: i32, top_k: i32,
|
||||||
|
expert_start: i32, local_experts: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
|
||||||
|
fn cublasGemmStridedBatchedEx(
|
||||||
|
handle: CublasHandle,
|
||||||
|
transa: i32, transb: i32,
|
||||||
|
m: i32, n: i32, k: i32,
|
||||||
|
alpha: *const c_void,
|
||||||
|
a: *const c_void, a_type: i32, lda: i32, stride_a: i64,
|
||||||
|
b: *const c_void, b_type: i32, ldb: i32, stride_b: i64,
|
||||||
|
beta: *const c_void,
|
||||||
|
c: *mut c_void, c_type: i32, ldc: i32, stride_c: i64,
|
||||||
|
batch_count: i32,
|
||||||
|
compute_type: i32,
|
||||||
|
algo: i32,
|
||||||
|
) -> i32;
|
||||||
|
|
||||||
|
fn cublasSetStream_v2(handle: CublasHandle, stream: *mut c_void) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUDA_R_16BF: i32 = 14;
|
||||||
|
const CUBLAS_COMPUTE_32F: i32 = 68;
|
||||||
|
const CUBLAS_GEMM_DEFAULT: i32 = -1;
|
||||||
|
|
||||||
|
/// GPU top-k selection + softmax over router logits.
|
||||||
|
///
|
||||||
|
/// Input: router_logits [num_tokens, num_experts] BF16 on GPU
|
||||||
|
/// Output: (topk_ids [num_tokens, top_k] i32, topk_weights [num_tokens, top_k] f32)
|
||||||
|
pub fn moe_topk_softmax(
|
||||||
|
router_logits: &Tensor,
|
||||||
|
num_experts: usize,
|
||||||
|
top_k: usize,
|
||||||
|
) -> (Tensor, Tensor) {
|
||||||
|
assert_eq!(router_logits.ndim(), 2);
|
||||||
|
assert_eq!(router_logits.dtype(), DType::BF16);
|
||||||
|
assert!(router_logits.is_contiguous());
|
||||||
|
let num_tokens = router_logits.shape()[0];
|
||||||
|
assert_eq!(router_logits.shape()[1], num_experts);
|
||||||
|
|
||||||
|
let topk_ids = Tensor::empty(&[num_tokens, top_k], DType::F32, router_logits.device());
|
||||||
|
let topk_weights = Tensor::empty(&[num_tokens, top_k], DType::F32, router_logits.device());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_moe_topk_softmax_bf16(
|
||||||
|
router_logits.data_ptr() as *const c_void,
|
||||||
|
topk_ids.data_ptr() as *mut c_void,
|
||||||
|
topk_weights.data_ptr() as *mut c_void,
|
||||||
|
num_tokens as i32, num_experts as i32, top_k as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(topk_ids, topk_weights)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replicate x [num_tokens, hidden] → [local_experts, num_tokens, hidden].
|
||||||
|
pub fn moe_replicate(x: &Tensor, local_experts: usize) -> Tensor {
|
||||||
|
assert_eq!(x.ndim(), 2);
|
||||||
|
assert_eq!(x.dtype(), DType::BF16);
|
||||||
|
assert!(x.is_contiguous());
|
||||||
|
let num_tokens = x.shape()[0];
|
||||||
|
let hidden = x.shape()[1];
|
||||||
|
let out = Tensor::empty(&[local_experts, num_tokens, hidden], DType::BF16, x.device());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_moe_replicate_bf16(
|
||||||
|
x.data_ptr() as *const c_void,
|
||||||
|
out.data_ptr() as *mut c_void,
|
||||||
|
num_tokens as i32, hidden as i32, local_experts as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place 3D bias add: x [batch, num_tokens, dim] += bias [batch, dim].
|
||||||
|
pub fn moe_bias_add_3d(x: &Tensor, bias: &Tensor) {
|
||||||
|
assert_eq!(x.ndim(), 3);
|
||||||
|
assert_eq!(bias.ndim(), 2);
|
||||||
|
assert_eq!(x.dtype(), DType::BF16);
|
||||||
|
assert!(x.is_contiguous());
|
||||||
|
let batch = x.shape()[0];
|
||||||
|
let num_tokens = x.shape()[1];
|
||||||
|
let dim = x.shape()[2];
|
||||||
|
assert_eq!(bias.shape(), &[batch, dim]);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_moe_bias_add_3d_bf16(
|
||||||
|
x.data_ptr() as *mut c_void,
|
||||||
|
bias.data_ptr() as *const c_void,
|
||||||
|
batch as i32, num_tokens as i32, dim as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Weighted sum of expert outputs → [num_tokens, hidden].
|
||||||
|
///
|
||||||
|
/// expert_out: [local_experts, num_tokens, hidden] BF16
|
||||||
|
/// topk_ids: [num_tokens, top_k] i32 (global expert indices)
|
||||||
|
/// topk_weights: [num_tokens, top_k] f32
|
||||||
|
pub fn moe_weighted_sum(
|
||||||
|
expert_out: &Tensor,
|
||||||
|
topk_ids: &Tensor,
|
||||||
|
topk_weights: &Tensor,
|
||||||
|
expert_start: usize,
|
||||||
|
local_experts: usize,
|
||||||
|
top_k: usize,
|
||||||
|
) -> Tensor {
|
||||||
|
assert_eq!(expert_out.ndim(), 3);
|
||||||
|
assert_eq!(expert_out.dtype(), DType::BF16);
|
||||||
|
let num_tokens = expert_out.shape()[1];
|
||||||
|
let hidden = expert_out.shape()[2];
|
||||||
|
|
||||||
|
let out = Tensor::empty(&[num_tokens, hidden], DType::BF16, expert_out.device());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_moe_weighted_sum_bf16(
|
||||||
|
expert_out.data_ptr() as *const c_void,
|
||||||
|
topk_ids.data_ptr() as *const c_void,
|
||||||
|
topk_weights.data_ptr() as *const c_void,
|
||||||
|
out.data_ptr() as *mut c_void,
|
||||||
|
num_tokens as i32, hidden as i32, top_k as i32,
|
||||||
|
expert_start as i32, local_experts as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strided batched GEMM for MoE expert forward.
|
||||||
|
/// C[b] = A[b] @ B[b] for b in 0..batch
|
||||||
|
///
|
||||||
|
/// A: [batch, M, K] BF16 contiguous
|
||||||
|
/// B: [batch, K, N] BF16 contiguous
|
||||||
|
/// Returns C: [batch, M, N] BF16
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn batched_gemm_strided(a: &Tensor, b: &Tensor) -> Tensor {
|
||||||
|
assert_eq!(a.ndim(), 3);
|
||||||
|
assert_eq!(b.ndim(), 3);
|
||||||
|
assert_eq!(a.dtype(), DType::BF16);
|
||||||
|
assert_eq!(b.dtype(), DType::BF16);
|
||||||
|
assert!(a.is_contiguous() && b.is_contiguous());
|
||||||
|
assert_eq!(a.shape()[0], b.shape()[0]);
|
||||||
|
assert_eq!(a.shape()[2], b.shape()[1]);
|
||||||
|
|
||||||
|
let batch = a.shape()[0];
|
||||||
|
let m = a.shape()[1];
|
||||||
|
let k = a.shape()[2];
|
||||||
|
let n = b.shape()[2];
|
||||||
|
|
||||||
|
let c = Tensor::empty(&[batch, m, n], DType::BF16, a.device());
|
||||||
|
|
||||||
|
let alpha: f32 = 1.0;
|
||||||
|
let beta: f32 = 0.0;
|
||||||
|
|
||||||
|
// cuBLAS column-major: we compute C^T = B^T @ A^T
|
||||||
|
// A is [batch, M, K] row-major → A^T is [K, M] col-major, lda=K
|
||||||
|
// B is [batch, K, N] row-major → B^T is [N, K] col-major, ldb=N? No...
|
||||||
|
//
|
||||||
|
// Actually for row-major: A[M,K] in memory = col-major A^T[K,M] with lda=K.
|
||||||
|
// So we call cublasGemmStridedBatchedEx with:
|
||||||
|
// transa=N, transb=N
|
||||||
|
// m=N, n=M, k=K (because cuBLAS sees col-major)
|
||||||
|
// A_cublas = B_row (pointer), lda=N
|
||||||
|
// B_cublas = A_row (pointer), ldb=K
|
||||||
|
// C_cublas = C_row (pointer), ldc=N
|
||||||
|
|
||||||
|
let stride_a = (m * k) as i64;
|
||||||
|
let stride_b = (k * n) as i64;
|
||||||
|
let stride_c = (m * n) as i64;
|
||||||
|
|
||||||
|
let handle = cublas_handle();
|
||||||
|
unsafe {
|
||||||
|
cublasSetStream_v2(handle, std::ptr::null_mut());
|
||||||
|
let status = cublasGemmStridedBatchedEx(
|
||||||
|
handle,
|
||||||
|
0, 0, // CUBLAS_OP_N, CUBLAS_OP_N
|
||||||
|
n as i32, m as i32, k as i32,
|
||||||
|
&alpha as *const f32 as *const c_void,
|
||||||
|
b.data_ptr() as *const c_void, CUDA_R_16BF, n as i32, stride_b,
|
||||||
|
a.data_ptr() as *const c_void, CUDA_R_16BF, k as i32, stride_a,
|
||||||
|
&beta as *const f32 as *const c_void,
|
||||||
|
c.data_ptr() as *mut c_void, CUDA_R_16BF, n as i32, stride_c,
|
||||||
|
batch as i32,
|
||||||
|
CUBLAS_COMPUTE_32F,
|
||||||
|
CUBLAS_GEMM_DEFAULT,
|
||||||
|
);
|
||||||
|
assert_eq!(status, 0, "cublasGemmStridedBatchedEx failed: {status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
c
|
||||||
|
}
|
||||||
485
crates/xserv-kernels/src/quantization.rs
Normal file
485
crates/xserv-kernels/src/quantization.rs
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use xserv_cuda::GpuBuffer;
|
||||||
|
use xserv_tensor::{DType, Tensor};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FFI: custom CUDA kernels
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn launch_dequant_fp8e4m3_to_bf16(
|
||||||
|
src: *const c_void,
|
||||||
|
scales: *const c_void,
|
||||||
|
dst: *mut c_void,
|
||||||
|
num_experts: i32, rows: i32, cols: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_quantize_bf16_to_fp8e4m3_rowwise(
|
||||||
|
src: *const c_void,
|
||||||
|
dst: *mut c_void,
|
||||||
|
scales: *mut c_void,
|
||||||
|
num_rows: i32, cols: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_rowwise_scale_moe_bf16(
|
||||||
|
data: *mut c_void,
|
||||||
|
a_scales: *const c_void,
|
||||||
|
b_scales: *const c_void,
|
||||||
|
num_rows: i32, cols: i32, tokens: i32,
|
||||||
|
stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_batched_gemv_mxfp4_bf16(
|
||||||
|
x: *const c_void, w_packed: *const c_void, w_scales: *const c_void, y: *mut c_void,
|
||||||
|
e: i32, n: i32, k: i32, stream: *mut c_void,
|
||||||
|
);
|
||||||
|
fn launch_dequant_mxfp4_to_bf16_t(
|
||||||
|
w_packed: *const c_void, w_scales: *const c_void, out: *mut c_void,
|
||||||
|
e: i32, n: i32, k: i32, stream: *mut c_void,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FFI: cuBLASLt
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type CublasLtHandle = *mut c_void;
|
||||||
|
type CublasLtMatmulDesc = *mut c_void;
|
||||||
|
type CublasLtMatrixLayout = *mut c_void;
|
||||||
|
type CublasLtMatmulPreference = *mut c_void;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct CublasLtMatmulAlgo {
|
||||||
|
data: [u64; 8],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct CublasLtMatmulHeuristicResult {
|
||||||
|
algo: CublasLtMatmulAlgo,
|
||||||
|
workspace_size: usize,
|
||||||
|
state: i32,
|
||||||
|
_reserved: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn cublasLtCreate(handle: *mut CublasLtHandle) -> i32;
|
||||||
|
fn cublasLtDestroy(handle: CublasLtHandle) -> i32;
|
||||||
|
fn cublasLtMatmulDescCreate(desc: *mut CublasLtMatmulDesc, compute_type: i32, scale_type: i32) -> i32;
|
||||||
|
fn cublasLtMatmulDescDestroy(desc: CublasLtMatmulDesc) -> i32;
|
||||||
|
fn cublasLtMatmulDescSetAttribute(desc: CublasLtMatmulDesc, attr: i32, buf: *const c_void, size: usize) -> i32;
|
||||||
|
fn cublasLtMatrixLayoutCreate(layout: *mut CublasLtMatrixLayout, dtype: i32, rows: u64, cols: u64, ld: i64) -> i32;
|
||||||
|
fn cublasLtMatrixLayoutDestroy(layout: CublasLtMatrixLayout) -> i32;
|
||||||
|
fn cublasLtMatrixLayoutSetAttribute(layout: CublasLtMatrixLayout, attr: i32, buf: *const c_void, size: usize) -> i32;
|
||||||
|
fn cublasLtMatmulPreferenceCreate(pref: *mut CublasLtMatmulPreference) -> i32;
|
||||||
|
fn cublasLtMatmulPreferenceDestroy(pref: CublasLtMatmulPreference) -> i32;
|
||||||
|
fn cublasLtMatmulPreferenceSetAttribute(pref: CublasLtMatmulPreference, attr: i32, buf: *const c_void, size: usize) -> i32;
|
||||||
|
fn cublasLtMatmulAlgoGetHeuristic(
|
||||||
|
handle: CublasLtHandle, desc: CublasLtMatmulDesc,
|
||||||
|
a_layout: CublasLtMatrixLayout, b_layout: CublasLtMatrixLayout,
|
||||||
|
c_layout: CublasLtMatrixLayout, d_layout: CublasLtMatrixLayout,
|
||||||
|
pref: CublasLtMatmulPreference,
|
||||||
|
requested: i32,
|
||||||
|
results: *mut CublasLtMatmulHeuristicResult,
|
||||||
|
found: *mut i32,
|
||||||
|
) -> i32;
|
||||||
|
fn cublasLtMatmul(
|
||||||
|
handle: CublasLtHandle, desc: CublasLtMatmulDesc,
|
||||||
|
alpha: *const c_void,
|
||||||
|
a: *const c_void, a_layout: CublasLtMatrixLayout,
|
||||||
|
b: *const c_void, b_layout: CublasLtMatrixLayout,
|
||||||
|
beta: *const c_void,
|
||||||
|
c: *const c_void, c_layout: CublasLtMatrixLayout,
|
||||||
|
d: *mut c_void, d_layout: CublasLtMatrixLayout,
|
||||||
|
algo: *const CublasLtMatmulAlgo,
|
||||||
|
workspace: *mut c_void, workspace_size: usize,
|
||||||
|
stream: *mut c_void,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cuBLASLt constants
|
||||||
|
const CUBLAS_COMPUTE_32F: i32 = 68;
|
||||||
|
const CUDA_R_32F: i32 = 0;
|
||||||
|
const CUDA_R_16BF: i32 = 14;
|
||||||
|
const CUDA_R_8F_E4M3: i32 = 28;
|
||||||
|
|
||||||
|
// MatmulDesc attributes
|
||||||
|
const CUBLASLT_MATMUL_DESC_A_SCALE_POINTER: i32 = 17;
|
||||||
|
const CUBLASLT_MATMUL_DESC_B_SCALE_POINTER: i32 = 18;
|
||||||
|
const CUBLASLT_MATMUL_DESC_A_SCALE_MODE: i32 = 31;
|
||||||
|
const CUBLASLT_MATMUL_DESC_B_SCALE_MODE: i32 = 32;
|
||||||
|
|
||||||
|
// MatrixLayout attributes
|
||||||
|
const CUBLASLT_MATRIX_LAYOUT_BATCH_COUNT: i32 = 5;
|
||||||
|
const CUBLASLT_MATRIX_LAYOUT_STRIDED_BATCH_OFFSET: i32 = 6;
|
||||||
|
|
||||||
|
// Scale modes
|
||||||
|
const CUBLASLT_MATMUL_MATRIX_SCALE_SCALAR: i32 = 0;
|
||||||
|
const CUBLASLT_MATMUL_MATRIX_SCALE_OUTER_VEC_32F: i32 = 3;
|
||||||
|
|
||||||
|
// MatmulPreference attributes
|
||||||
|
const CUBLASLT_MATMUL_PREF_MAX_WORKSPACE_BYTES: i32 = 1;
|
||||||
|
|
||||||
|
const WORKSPACE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
const CUBLASLT_MATMUL_DESC_TRANSA: i32 = 3;
|
||||||
|
|
||||||
|
/// A fully-prepared FP8 matmul plan for one (M, N, K) shape: the matmul
|
||||||
|
/// descriptor, the four matrix layouts, and the heuristically-chosen algo.
|
||||||
|
/// Built once per shape and reused across every expert and every forward
|
||||||
|
/// pass — the heuristic search and descriptor/layout creation are the
|
||||||
|
/// expensive parts, so doing them once instead of per-expert-per-layer is
|
||||||
|
/// the difference between FP8 being faster or slower than BF16.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Fp8Plan {
|
||||||
|
desc: CublasLtMatmulDesc,
|
||||||
|
a_layout: CublasLtMatrixLayout,
|
||||||
|
b_layout: CublasLtMatrixLayout,
|
||||||
|
c_layout: CublasLtMatrixLayout,
|
||||||
|
d_layout: CublasLtMatrixLayout,
|
||||||
|
algo: CublasLtMatmulAlgo,
|
||||||
|
workspace_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CublasLtContext {
|
||||||
|
handle: CublasLtHandle,
|
||||||
|
workspace: GpuBuffer,
|
||||||
|
/// Persistent device scalar holding 1.0, used as the A/B scale pointer.
|
||||||
|
/// Scales are applied post-GEMM, so the in-GEMM scales stay 1.0.
|
||||||
|
one_buf: GpuBuffer,
|
||||||
|
/// Cache of prepared matmul plans keyed by (M, N, K, batch).
|
||||||
|
plans: HashMap<(usize, usize, usize, usize), Fp8Plan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CublasLtContext {
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut handle = std::ptr::null_mut();
|
||||||
|
let status = unsafe { cublasLtCreate(&mut handle) };
|
||||||
|
assert_eq!(status, 0, "cublasLtCreate failed: {status}");
|
||||||
|
let workspace = GpuBuffer::alloc(WORKSPACE_BYTES).expect("alloc cublasLt workspace");
|
||||||
|
let mut one_buf = GpuBuffer::alloc(4).expect("alloc cublasLt fp8 scale");
|
||||||
|
one_buf.copy_from_host(&1.0f32.to_le_bytes()).expect("init fp8 scale");
|
||||||
|
Self { handle, workspace, one_buf, plans: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the cached strided-batched plan for (m, n, k, batch), building it on
|
||||||
|
/// first use.
|
||||||
|
fn plan(&mut self, m: usize, n: usize, k: usize, batch: usize) -> Fp8Plan {
|
||||||
|
if let Some(p) = self.plans.get(&(m, n, k, batch)) {
|
||||||
|
return *p;
|
||||||
|
}
|
||||||
|
let one_ptr = self.one_buf.as_ptr() as *const c_void;
|
||||||
|
let plan = unsafe { build_fp8_plan(self.handle, one_ptr, m, n, k, batch) };
|
||||||
|
self.plans.insert((m, n, k, batch), plan);
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CublasLtContext {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Tear down cached plans before destroying the handle.
|
||||||
|
for (_, p) in self.plans.drain() {
|
||||||
|
unsafe {
|
||||||
|
cublasLtMatrixLayoutDestroy(p.a_layout);
|
||||||
|
cublasLtMatrixLayoutDestroy(p.b_layout);
|
||||||
|
cublasLtMatrixLayoutDestroy(p.c_layout);
|
||||||
|
cublasLtMatrixLayoutDestroy(p.d_layout);
|
||||||
|
cublasLtMatmulDescDestroy(p.desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.handle.is_null() {
|
||||||
|
unsafe { cublasLtDestroy(self.handle) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a strided-batched FP8 matmul plan for `batch` experts of one
|
||||||
|
/// (m, n, k) shape. Row-major → cuBLASLt col-major mapping (transA=T,
|
||||||
|
/// transB=N, m_lt=N, n_lt=M, k_lt=K). A/B scale pointers stay at 1.0 — both
|
||||||
|
/// the per-expert weight scale and the per-token activation scale are applied
|
||||||
|
/// post-GEMM in a fused kernel, which lets all experts run in one matmul.
|
||||||
|
unsafe fn build_fp8_plan(
|
||||||
|
handle: CublasLtHandle,
|
||||||
|
one_ptr: *const c_void,
|
||||||
|
m: usize,
|
||||||
|
n: usize,
|
||||||
|
k: usize,
|
||||||
|
batch: usize,
|
||||||
|
) -> Fp8Plan {
|
||||||
|
let m_lt = n as u64;
|
||||||
|
let n_lt = m as u64;
|
||||||
|
let k_lt = k as u64;
|
||||||
|
|
||||||
|
let mut desc: CublasLtMatmulDesc = std::ptr::null_mut();
|
||||||
|
cublasLtMatmulDescCreate(&mut desc, CUBLAS_COMPUTE_32F, CUDA_R_32F);
|
||||||
|
|
||||||
|
// transA=T (required for FP8 on Blackwell)
|
||||||
|
let trans_a: i32 = 1;
|
||||||
|
cublasLtMatmulDescSetAttribute(desc, CUBLASLT_MATMUL_DESC_TRANSA, &trans_a as *const i32 as _, 4);
|
||||||
|
let ptr_sz = std::mem::size_of::<*const c_void>();
|
||||||
|
cublasLtMatmulDescSetAttribute(desc, CUBLASLT_MATMUL_DESC_A_SCALE_POINTER, &one_ptr as *const _ as _, ptr_sz);
|
||||||
|
cublasLtMatmulDescSetAttribute(desc, CUBLASLT_MATMUL_DESC_B_SCALE_POINTER, &one_ptr as *const _ as _, ptr_sz);
|
||||||
|
|
||||||
|
// Per-expert strides in ELEMENTS for the strided-batch layout.
|
||||||
|
let stride_a = (n * k) as i64; // weights [N, K]
|
||||||
|
let stride_b = (m * k) as i64; // activations [M, K]
|
||||||
|
let stride_c = (m * n) as i64; // output [M, N]
|
||||||
|
let bc = batch as i32;
|
||||||
|
let set_batch = |layout: CublasLtMatrixLayout, stride: i64| {
|
||||||
|
cublasLtMatrixLayoutSetAttribute(layout, CUBLASLT_MATRIX_LAYOUT_BATCH_COUNT,
|
||||||
|
&bc as *const i32 as _, 4);
|
||||||
|
cublasLtMatrixLayoutSetAttribute(layout, CUBLASLT_MATRIX_LAYOUT_STRIDED_BATCH_OFFSET,
|
||||||
|
&stride as *const i64 as _, 8);
|
||||||
|
};
|
||||||
|
|
||||||
|
// "A" layout (weights, transposed): physical (K, N) col-major, ld=K
|
||||||
|
let mut a_layout: CublasLtMatrixLayout = std::ptr::null_mut();
|
||||||
|
cublasLtMatrixLayoutCreate(&mut a_layout, CUDA_R_8F_E4M3, k_lt, m_lt, k as i64);
|
||||||
|
set_batch(a_layout, stride_a);
|
||||||
|
// "B" layout (activations): physical (K, M) col-major, ld=K
|
||||||
|
let mut b_layout: CublasLtMatrixLayout = std::ptr::null_mut();
|
||||||
|
cublasLtMatrixLayoutCreate(&mut b_layout, CUDA_R_8F_E4M3, k_lt, n_lt, k as i64);
|
||||||
|
set_batch(b_layout, stride_b);
|
||||||
|
// "C"/"D" layout (output): physical (N, M) col-major, ld=N
|
||||||
|
let mut c_layout: CublasLtMatrixLayout = std::ptr::null_mut();
|
||||||
|
cublasLtMatrixLayoutCreate(&mut c_layout, CUDA_R_16BF, m_lt, n_lt, m_lt as i64);
|
||||||
|
set_batch(c_layout, stride_c);
|
||||||
|
let mut d_layout: CublasLtMatrixLayout = std::ptr::null_mut();
|
||||||
|
cublasLtMatrixLayoutCreate(&mut d_layout, CUDA_R_16BF, m_lt, n_lt, m_lt as i64);
|
||||||
|
set_batch(d_layout, stride_c);
|
||||||
|
|
||||||
|
let mut pref: CublasLtMatmulPreference = std::ptr::null_mut();
|
||||||
|
cublasLtMatmulPreferenceCreate(&mut pref);
|
||||||
|
let ws_bytes = WORKSPACE_BYTES as u64;
|
||||||
|
cublasLtMatmulPreferenceSetAttribute(pref, CUBLASLT_MATMUL_PREF_MAX_WORKSPACE_BYTES, &ws_bytes as *const u64 as _, 8);
|
||||||
|
|
||||||
|
let mut heuristic = std::mem::zeroed::<CublasLtMatmulHeuristicResult>();
|
||||||
|
let mut found: i32 = 0;
|
||||||
|
let status = cublasLtMatmulAlgoGetHeuristic(
|
||||||
|
handle, desc, a_layout, b_layout, c_layout, d_layout,
|
||||||
|
pref, 1, &mut heuristic, &mut found,
|
||||||
|
);
|
||||||
|
assert!(status == 0 && found > 0,
|
||||||
|
"cublasLtMatmulAlgoGetHeuristic failed for batched FP8 GEMM (m={m}, n={n}, k={k}, batch={batch}): status={status}, found={found}");
|
||||||
|
cublasLtMatmulPreferenceDestroy(pref);
|
||||||
|
|
||||||
|
Fp8Plan {
|
||||||
|
desc, a_layout, b_layout, c_layout, d_layout,
|
||||||
|
algo: heuristic.algo,
|
||||||
|
workspace_size: heuristic.workspace_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CUBLASLT_CTX: RefCell<CublasLtContext> = RefCell::new(CublasLtContext::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// Dequantize a 3D FP8 E4M3 tensor to BF16 using per-expert FP32 scales.
|
||||||
|
///
|
||||||
|
/// src: [num_experts, rows, cols] FP8E4M3, contiguous, GPU
|
||||||
|
/// scales: [num_experts] F32, contiguous, GPU
|
||||||
|
///
|
||||||
|
/// Returns: [num_experts, rows, cols] BF16
|
||||||
|
pub fn dequant_fp8_to_bf16(src: &Tensor, scales: &Tensor) -> Tensor {
|
||||||
|
assert_eq!(src.ndim(), 3, "dequant_fp8_to_bf16: src must be 3D");
|
||||||
|
assert_eq!(src.dtype(), DType::FP8E4M3);
|
||||||
|
assert!(src.is_contiguous());
|
||||||
|
assert_eq!(scales.ndim(), 1);
|
||||||
|
assert_eq!(scales.dtype(), DType::F32);
|
||||||
|
assert!(scales.is_contiguous());
|
||||||
|
|
||||||
|
let num_experts = src.shape()[0];
|
||||||
|
let rows = src.shape()[1];
|
||||||
|
let cols = src.shape()[2];
|
||||||
|
assert_eq!(scales.shape()[0], num_experts);
|
||||||
|
|
||||||
|
let out = Tensor::empty(&[num_experts, rows, cols], DType::BF16, src.device());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_dequant_fp8e4m3_to_bf16(
|
||||||
|
src.data_ptr() as *const c_void,
|
||||||
|
scales.data_ptr() as *const c_void,
|
||||||
|
out.data_ptr() as *mut c_void,
|
||||||
|
num_experts as i32, rows as i32, cols as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dynamically quantize a contiguous BF16 tensor to FP8 E4M3 with per-row scales.
|
||||||
|
///
|
||||||
|
/// src: [num_rows, cols] or [batch, rows, cols] BF16, contiguous, GPU
|
||||||
|
/// Treats the tensor as 2D (flattens leading dims into num_rows).
|
||||||
|
///
|
||||||
|
/// Returns: (fp8_data [same shape] FP8E4M3, scales [total_rows] F32)
|
||||||
|
pub fn quantize_bf16_to_fp8_rowwise(src: &Tensor) -> (Tensor, Tensor) {
|
||||||
|
assert_eq!(src.dtype(), DType::BF16);
|
||||||
|
assert!(src.is_contiguous());
|
||||||
|
assert!(src.ndim() >= 2);
|
||||||
|
|
||||||
|
let cols = src.shape()[src.ndim() - 1];
|
||||||
|
let num_rows: usize = src.shape()[..src.ndim() - 1].iter().product();
|
||||||
|
|
||||||
|
let fp8_out = Tensor::empty(src.shape(), DType::FP8E4M3, src.device());
|
||||||
|
let scales = Tensor::empty(&[num_rows], DType::F32, src.device());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_quantize_bf16_to_fp8e4m3_rowwise(
|
||||||
|
src.data_ptr() as *const c_void,
|
||||||
|
fp8_out.data_ptr() as *mut c_void,
|
||||||
|
scales.data_ptr() as *mut c_void,
|
||||||
|
num_rows as i32, cols as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(fp8_out, scales)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FP8 batched GEMM via cuBLASLt (transA=T required on Blackwell).
|
||||||
|
///
|
||||||
|
/// Computes: C[b] = scale_a[b] * scale_b[b] * (A_fp8[b] @ B_fp8_T[b]^T)
|
||||||
|
/// effectively C[b] = A[b, M, K] @ W[b, K, N] but W is stored transposed
|
||||||
|
/// as [b, N, K] for cuBLASLt FP8 compatibility.
|
||||||
|
///
|
||||||
|
/// a_fp8: [batch, M, K] FP8E4M3 (activations, quantized per-row)
|
||||||
|
/// a_scales: [batch * M] F32 (per-token activation scales, applied post-GEMM)
|
||||||
|
/// b_fp8_t: [batch, N, K] FP8E4M3 (weights, TRANSPOSED for cuBLASLt)
|
||||||
|
/// b_scales: [batch] F32 (per-expert scalar weight scales, applied in-GEMM)
|
||||||
|
///
|
||||||
|
/// Returns: [batch, M, N] BF16
|
||||||
|
pub fn batched_gemm_fp8(
|
||||||
|
a_fp8: &Tensor,
|
||||||
|
a_scales: &Tensor,
|
||||||
|
b_fp8_t: &Tensor,
|
||||||
|
b_scales: &Tensor,
|
||||||
|
) -> Tensor {
|
||||||
|
assert_eq!(a_fp8.ndim(), 3);
|
||||||
|
assert_eq!(b_fp8_t.ndim(), 3);
|
||||||
|
assert_eq!(a_fp8.dtype(), DType::FP8E4M3);
|
||||||
|
assert_eq!(b_fp8_t.dtype(), DType::FP8E4M3);
|
||||||
|
assert!(a_fp8.is_contiguous() && b_fp8_t.is_contiguous());
|
||||||
|
assert_eq!(a_fp8.shape()[0], b_fp8_t.shape()[0]);
|
||||||
|
// b_fp8_t is [batch, N, K] transposed, so b_fp8_t.shape[2] == K == a_fp8.shape[2]
|
||||||
|
assert_eq!(a_fp8.shape()[2], b_fp8_t.shape()[2]);
|
||||||
|
|
||||||
|
let batch = a_fp8.shape()[0];
|
||||||
|
let m = a_fp8.shape()[1]; // tokens
|
||||||
|
let k = a_fp8.shape()[2]; // hidden
|
||||||
|
let n = b_fp8_t.shape()[1]; // out_dim (from transposed weight)
|
||||||
|
|
||||||
|
// a_scales: [batch * M] per-token activation scales (applied post-GEMM, per row).
|
||||||
|
// b_scales: [batch] per-expert scalar weight scales (applied in-GEMM via B-scale ptr).
|
||||||
|
assert_eq!(a_scales.shape()[0], batch * m);
|
||||||
|
assert_eq!(b_scales.shape()[0], batch);
|
||||||
|
|
||||||
|
let c = Tensor::empty(&[batch, m, n], DType::BF16, a_fp8.device());
|
||||||
|
|
||||||
|
CUBLASLT_CTX.with(|cell| {
|
||||||
|
let mut ctx = cell.borrow_mut();
|
||||||
|
let handle = ctx.handle;
|
||||||
|
let ws_ptr = ctx.workspace.as_ptr() as *mut c_void;
|
||||||
|
// Cached strided-batched plan: heuristic + descriptor/layout creation
|
||||||
|
// happen once per (m, n, k, batch). All experts run in ONE matmul.
|
||||||
|
let plan = ctx.plan(m, n, k, batch);
|
||||||
|
|
||||||
|
// alpha=1, beta=0, in-GEMM scales=1.0. The unscaled result
|
||||||
|
// D_raw[e] = A_fp8[e] @ B_fp8[e]^T
|
||||||
|
// is recovered to the real value by the fused post-scale kernel below.
|
||||||
|
let alpha: f32 = 1.0;
|
||||||
|
let beta: f32 = 0.0;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let status = cublasLtMatmul(
|
||||||
|
handle, plan.desc,
|
||||||
|
&alpha as *const f32 as _,
|
||||||
|
b_fp8_t.data_ptr() as *const c_void, // cuBLASLt "A" = weights
|
||||||
|
plan.a_layout,
|
||||||
|
a_fp8.data_ptr() as *const c_void, // cuBLASLt "B" = activations
|
||||||
|
plan.b_layout,
|
||||||
|
&beta as *const f32 as _,
|
||||||
|
c.data_ptr() as *const c_void, // C (unused with beta=0)
|
||||||
|
plan.c_layout,
|
||||||
|
c.data_ptr() as *mut c_void, // D = output
|
||||||
|
plan.d_layout,
|
||||||
|
&plan.algo,
|
||||||
|
ws_ptr,
|
||||||
|
plan.workspace_size,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
assert_eq!(status, 0, "batched cublasLtMatmul FP8 failed: status={status}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Post-GEMM: recover the real result in one pass.
|
||||||
|
// c[e, t, :] *= a_scales[e*M + t] * b_scales[e]
|
||||||
|
// (per-token activation scale × per-expert weight scale). BF16's relative
|
||||||
|
// error is scale-invariant, so applying the scale here is precision-
|
||||||
|
// equivalent to folding it into the GEMM epilogue.
|
||||||
|
let total_rows = (batch * m) as i32;
|
||||||
|
unsafe {
|
||||||
|
launch_rowwise_scale_moe_bf16(
|
||||||
|
c.data_ptr() as *mut c_void,
|
||||||
|
a_scales.data_ptr() as *const c_void,
|
||||||
|
b_scales.data_ptr() as *const c_void,
|
||||||
|
total_rows, n as i32, m as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MXFP4 W4A16 (weight-only 4-bit) for MoE experts
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// MXFP4 W4A16 batched GEMV for decode (M=1).
|
||||||
|
///
|
||||||
|
/// x: [E, K] BF16 (per-expert activation; replicated across experts)
|
||||||
|
/// w_packed: [E, N, K/2] byte tensor — two E2M1 nibbles per byte (lo = even k)
|
||||||
|
/// w_scales: [E, N, K/32] byte tensor — UE8M0 scale per 32-element block
|
||||||
|
///
|
||||||
|
/// Returns: [E, N] BF16, where y[e,n] = sum_k x[e,k] * dequant(W[e,n,k]).
|
||||||
|
pub fn batched_gemv_mxfp4(x: &Tensor, w_packed: &Tensor, w_scales: &Tensor, n: usize, k: usize) -> Tensor {
|
||||||
|
assert_eq!(x.dtype(), DType::BF16);
|
||||||
|
assert!(x.is_contiguous());
|
||||||
|
let e = x.shape()[0];
|
||||||
|
assert_eq!(x.shape()[x.ndim() - 1], k, "GEMV K mismatch");
|
||||||
|
|
||||||
|
let y = Tensor::empty(&[e, n], DType::BF16, x.device());
|
||||||
|
unsafe {
|
||||||
|
launch_batched_gemv_mxfp4_bf16(
|
||||||
|
x.data_ptr() as *const c_void,
|
||||||
|
w_packed.data_ptr() as *const c_void,
|
||||||
|
w_scales.data_ptr() as *const c_void,
|
||||||
|
y.data_ptr() as *mut c_void,
|
||||||
|
e as i32, n as i32, k as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
y
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dequantize MXFP4 weights [E, N, K] → BF16 [E, K, N] for the prefill GEMM path
|
||||||
|
/// (the BF16 batched GEMM expects weights as [E, K, N]).
|
||||||
|
pub fn dequant_mxfp4_to_bf16_t(w_packed: &Tensor, w_scales: &Tensor, e: usize, n: usize, k: usize) -> Tensor {
|
||||||
|
let out = Tensor::empty(&[e, k, n], DType::BF16, w_packed.device());
|
||||||
|
unsafe {
|
||||||
|
launch_dequant_mxfp4_to_bf16_t(
|
||||||
|
w_packed.data_ptr() as *const c_void,
|
||||||
|
w_scales.data_ptr() as *const c_void,
|
||||||
|
out.data_ptr() as *mut c_void,
|
||||||
|
e as i32, n as i32, k as i32,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ unsafe extern "C" {
|
|||||||
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void);
|
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void);
|
||||||
fn launch_rmsnorm_bf16(x: *const c_void, gamma: *const c_void, out: *mut c_void,
|
fn launch_rmsnorm_bf16(x: *const c_void, gamma: *const c_void, out: *mut c_void,
|
||||||
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void);
|
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void);
|
||||||
|
fn launch_add_rmsnorm_bf16(x: *const c_void, residual: *const c_void, gamma: *const c_void,
|
||||||
|
normed_out: *mut c_void, sum_out: *mut c_void,
|
||||||
|
rows: i32, hidden_size: i32, eps: f32, stream: *mut c_void);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rmsnorm(x: &Tensor, gamma: &Tensor, eps: f32) -> Tensor {
|
pub fn rmsnorm(x: &Tensor, gamma: &Tensor, eps: f32) -> Tensor {
|
||||||
@@ -17,7 +20,9 @@ pub fn rmsnorm(x: &Tensor, gamma: &Tensor, eps: f32) -> Tensor {
|
|||||||
assert_eq!(x.dtype(), gamma.dtype());
|
assert_eq!(x.dtype(), gamma.dtype());
|
||||||
|
|
||||||
let rows = x.numel() / hidden_size;
|
let rows = x.numel() / hidden_size;
|
||||||
let out = Tensor::zeros(x.shape(), x.dtype(), x.device());
|
assert!(rows <= i32::MAX as usize, "too many rows for i32 kernel param");
|
||||||
|
assert!(hidden_size <= i32::MAX as usize, "hidden_size too large for i32 kernel param");
|
||||||
|
let out = Tensor::empty(x.shape(), x.dtype(), x.device());
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
match x.dtype() {
|
match x.dtype() {
|
||||||
@@ -32,6 +37,43 @@ pub fn rmsnorm(x: &Tensor, gamma: &Tensor, eps: f32) -> Tensor {
|
|||||||
_ => panic!("unsupported dtype for rmsnorm"),
|
_ => panic!("unsupported dtype for rmsnorm"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fused Add + RMSNorm: computes sum = x + residual, then normed = rmsnorm(sum, gamma, eps).
|
||||||
|
/// Returns (normed, sum). BF16 only.
|
||||||
|
/// Saves one kernel launch and one full HBM round-trip per layer.
|
||||||
|
pub fn add_rmsnorm(x: &Tensor, residual: &Tensor, gamma: &Tensor, eps: f32) -> (Tensor, Tensor) {
|
||||||
|
assert!(x.ndim() >= 1);
|
||||||
|
assert_eq!(x.shape(), residual.shape());
|
||||||
|
assert!(x.is_contiguous() && residual.is_contiguous() && gamma.is_contiguous());
|
||||||
|
assert!(matches!(x.device(), Device::Cuda(_)));
|
||||||
|
assert_eq!(x.dtype(), DType::BF16, "add_rmsnorm requires BF16");
|
||||||
|
assert_eq!(residual.dtype(), DType::BF16);
|
||||||
|
assert_eq!(gamma.dtype(), DType::BF16);
|
||||||
|
|
||||||
|
let hidden_size = *x.shape().last().unwrap();
|
||||||
|
assert_eq!(gamma.shape(), &[hidden_size]);
|
||||||
|
|
||||||
|
let rows = x.numel() / hidden_size;
|
||||||
|
assert!(rows <= i32::MAX as usize, "too many rows for i32 kernel param");
|
||||||
|
assert!(hidden_size <= i32::MAX as usize, "hidden_size too large for i32 kernel param");
|
||||||
|
let normed_out = Tensor::empty(x.shape(), DType::BF16, x.device());
|
||||||
|
let sum_out = Tensor::empty(x.shape(), DType::BF16, x.device());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
launch_add_rmsnorm_bf16(
|
||||||
|
x.data_ptr() as *const c_void,
|
||||||
|
residual.data_ptr() as *const c_void,
|
||||||
|
gamma.data_ptr() as *const c_void,
|
||||||
|
normed_out.data_ptr() as *mut c_void,
|
||||||
|
sum_out.data_ptr() as *mut c_void,
|
||||||
|
rows as i32,
|
||||||
|
hidden_size as i32,
|
||||||
|
eps,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(normed_out, sum_out)
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,81 @@ impl RopeCache {
|
|||||||
max_seq_len as i32, half_dim as i32, theta, std::ptr::null_mut(),
|
max_seq_len as i32, half_dim as i32, theta, std::ptr::null_mut(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
|
Self { cos, sin, max_seq_len, half_dim }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// YaRN (Yet another RoPE extensioN) RoPE cache. Applies frequency-dependent
|
||||||
|
/// interpolation so the model can extrapolate beyond its training context.
|
||||||
|
pub fn new_yarn(
|
||||||
|
max_seq_len: usize,
|
||||||
|
head_dim: usize,
|
||||||
|
theta: f64,
|
||||||
|
factor: f64,
|
||||||
|
original_max_pos: usize,
|
||||||
|
beta_fast: f64,
|
||||||
|
beta_slow: f64,
|
||||||
|
) -> Self {
|
||||||
|
let half_dim = head_dim / 2;
|
||||||
|
let dim = head_dim as f64;
|
||||||
|
|
||||||
|
// find_correction_dim: inverse formula to find dimension from number of rotations
|
||||||
|
let find_correction_dim = |num_rotations: f64| -> f64 {
|
||||||
|
dim * (original_max_pos as f64 / (num_rotations * 2.0 * std::f64::consts::PI)).ln()
|
||||||
|
/ (2.0 * theta.ln())
|
||||||
|
};
|
||||||
|
|
||||||
|
let low_raw = find_correction_dim(beta_fast);
|
||||||
|
let high_raw = find_correction_dim(beta_slow);
|
||||||
|
// config has truncate=false, so use raw values (no floor/ceil)
|
||||||
|
let low = low_raw.max(0.0);
|
||||||
|
let high = high_raw.min((half_dim - 1) as f64);
|
||||||
|
|
||||||
|
// Compute inv_freq with YaRN interpolation
|
||||||
|
let mut inv_freq = vec![0.0f64; half_dim];
|
||||||
|
for i in 0..half_dim {
|
||||||
|
let pos_freq = theta.powf((2 * i) as f64 / dim);
|
||||||
|
let inv_freq_extrapolation = 1.0 / pos_freq; // original
|
||||||
|
let inv_freq_interpolation = 1.0 / (factor * pos_freq); // scaled
|
||||||
|
|
||||||
|
// Linear ramp: 0 where we keep original, 1 where we interpolate
|
||||||
|
let ramp = if (high - low).abs() < 0.001 {
|
||||||
|
0.5
|
||||||
|
} else {
|
||||||
|
((i as f64 - low) / (high - low)).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let extrapolation_factor = 1.0 - ramp;
|
||||||
|
|
||||||
|
inv_freq[i] = inv_freq_interpolation * (1.0 - extrapolation_factor)
|
||||||
|
+ inv_freq_extrapolation * extrapolation_factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attention scaling factor for YaRN: 0.1 * ln(factor) + 1.0
|
||||||
|
let attn_factor = 0.1 * factor.ln() + 1.0;
|
||||||
|
|
||||||
|
// Build cos/sin cache on CPU then upload
|
||||||
|
let total = max_seq_len * half_dim;
|
||||||
|
let mut cos_host = vec![0.0f32; total];
|
||||||
|
let mut sin_host = vec![0.0f32; total];
|
||||||
|
for pos in 0..max_seq_len {
|
||||||
|
for i in 0..half_dim {
|
||||||
|
let angle = pos as f64 * inv_freq[i];
|
||||||
|
cos_host[pos * half_dim + i] = (angle.cos() * attn_factor) as f32;
|
||||||
|
sin_host[pos * half_dim + i] = (angle.sin() * attn_factor) as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nbytes = total * std::mem::size_of::<f32>();
|
||||||
|
let mut cos = GpuBuffer::alloc(nbytes).expect("alloc yarn cos_cache");
|
||||||
|
let mut sin = GpuBuffer::alloc(nbytes).expect("alloc yarn sin_cache");
|
||||||
|
let cos_bytes = unsafe {
|
||||||
|
std::slice::from_raw_parts(cos_host.as_ptr() as *const u8, nbytes)
|
||||||
|
};
|
||||||
|
let sin_bytes = unsafe {
|
||||||
|
std::slice::from_raw_parts(sin_host.as_ptr() as *const u8, nbytes)
|
||||||
|
};
|
||||||
|
cos.copy_from_host(cos_bytes).unwrap();
|
||||||
|
sin.copy_from_host(sin_bytes).unwrap();
|
||||||
|
|
||||||
Self { cos, sin, max_seq_len, half_dim }
|
Self { cos, sin, max_seq_len, half_dim }
|
||||||
}
|
}
|
||||||
@@ -59,7 +133,7 @@ pub fn rope_inplace(x: &Tensor, cache: &RopeCache, positions: &[u32]) {
|
|||||||
num_tokens * std::mem::size_of::<u32>(),
|
num_tokens * std::mem::size_of::<u32>(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let mut pos_gpu = GpuBuffer::alloc(pos_bytes.len()).expect("alloc positions");
|
let mut pos_gpu = xserv_cuda::allocator::cached_alloc(pos_bytes.len()).expect("alloc positions");
|
||||||
pos_gpu.copy_from_host(pos_bytes).unwrap();
|
pos_gpu.copy_from_host(pos_bytes).unwrap();
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -81,5 +155,4 @@ pub fn rope_inplace(x: &Tensor, cache: &RopeCache, positions: &[u32]) {
|
|||||||
_ => panic!("unsupported dtype for rope"),
|
_ => panic!("unsupported dtype for rope"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ pub fn softmax(x: &Tensor) -> Tensor {
|
|||||||
|
|
||||||
let cols = *x.shape().last().unwrap();
|
let cols = *x.shape().last().unwrap();
|
||||||
let rows = x.numel() / cols;
|
let rows = x.numel() / cols;
|
||||||
let out = Tensor::zeros(x.shape(), x.dtype(), x.device());
|
assert!(rows <= i32::MAX as usize, "too many rows for i32 kernel param");
|
||||||
|
assert!(cols <= i32::MAX as usize, "cols too large for i32 kernel param");
|
||||||
|
let out = Tensor::empty(x.shape(), x.dtype(), x.device());
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
match x.dtype() {
|
match x.dtype() {
|
||||||
@@ -29,6 +31,5 @@ pub fn softmax(x: &Tensor) -> Tensor {
|
|||||||
_ => panic!("unsupported dtype for softmax"),
|
_ => panic!("unsupported dtype for softmax"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
xserv_cuda::device::synchronize().unwrap();
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
142
crates/xserv-kernels/src/transpose.rs
Normal file
142
crates/xserv-kernels/src/transpose.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use std::ffi::c_void;
|
||||||
|
use xserv_tensor::{DType, Device, Tensor};
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn launch_reshape_heads_bf16(inp: *const c_void, out: *mut c_void, seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_merge_heads_bf16(inp: *const c_void, out: *mut c_void, seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_transpose_hsd_to_shd_bf16(inp: *const c_void, out: *mut c_void, seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_transpose_shd_to_hsd_bf16(inp: *const c_void, out: *mut c_void, seq_len: i32, num_heads: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_repeat_kv_bf16(inp: *const c_void, out: *mut c_void, kv_heads: i32, n_rep: i32, seq_len: i32, head_dim: i32, stream: *mut c_void);
|
||||||
|
fn launch_strided_copy_bf16(inp: *const c_void, out: *mut c_void, numel: i32, ndim: i32,
|
||||||
|
shape0: i32, shape1: i32, shape2: i32, shape3: i32,
|
||||||
|
in_stride0: i32, in_stride1: i32, in_stride2: i32, in_stride3: i32,
|
||||||
|
in_offset: i32, stream: *mut c_void);
|
||||||
|
fn launch_strided_copy_f32(inp: *const c_void, out: *mut c_void, numel: i32, ndim: i32,
|
||||||
|
shape0: i32, shape1: i32, shape2: i32, shape3: i32,
|
||||||
|
in_stride0: i32, in_stride1: i32, in_stride2: i32, in_stride3: i32,
|
||||||
|
in_offset: i32, stream: *mut c_void);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [S, H*D] → [1, H, S, D] on GPU (BF16)
|
||||||
|
pub fn reshape_heads_gpu(x: &Tensor, seq_len: usize, num_heads: usize, head_dim: usize) -> Tensor {
|
||||||
|
assert_eq!(x.dtype(), DType::BF16);
|
||||||
|
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
||||||
|
let out = Tensor::empty(&[1, num_heads, seq_len, head_dim], DType::BF16, x.device());
|
||||||
|
unsafe {
|
||||||
|
launch_reshape_heads_bf16(
|
||||||
|
x.data_ptr() as _, out.data_ptr() as *mut c_void,
|
||||||
|
seq_len as i32, num_heads as i32, head_dim as i32, std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [1, H, S, D] → [S, H*D] on GPU (BF16)
|
||||||
|
pub fn merge_heads_gpu(x: &Tensor, seq_len: usize, num_heads: usize, head_dim: usize) -> Tensor {
|
||||||
|
assert_eq!(x.dtype(), DType::BF16);
|
||||||
|
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
||||||
|
let hidden = num_heads * head_dim;
|
||||||
|
let out = Tensor::empty(&[seq_len, hidden], DType::BF16, x.device());
|
||||||
|
unsafe {
|
||||||
|
launch_merge_heads_bf16(
|
||||||
|
x.data_ptr() as _, out.data_ptr() as *mut c_void,
|
||||||
|
seq_len as i32, num_heads as i32, head_dim as i32, std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [1, H, S, D] → [S, H, D] for RoPE on GPU (BF16)
|
||||||
|
pub fn transpose_for_rope_gpu(x: &Tensor, seq_len: usize, num_heads: usize, head_dim: usize) -> Tensor {
|
||||||
|
assert_eq!(x.dtype(), DType::BF16);
|
||||||
|
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
||||||
|
let out = Tensor::empty(&[seq_len, num_heads, head_dim], DType::BF16, x.device());
|
||||||
|
unsafe {
|
||||||
|
launch_transpose_hsd_to_shd_bf16(
|
||||||
|
x.data_ptr() as _, out.data_ptr() as *mut c_void,
|
||||||
|
seq_len as i32, num_heads as i32, head_dim as i32, std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [S, H, D] → [1, H, S, D] after RoPE on GPU (BF16)
|
||||||
|
pub fn transpose_from_rope_gpu(x: &Tensor, seq_len: usize, num_heads: usize, head_dim: usize) -> Tensor {
|
||||||
|
assert_eq!(x.dtype(), DType::BF16);
|
||||||
|
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
||||||
|
let out = Tensor::empty(&[1, num_heads, seq_len, head_dim], DType::BF16, x.device());
|
||||||
|
unsafe {
|
||||||
|
launch_transpose_shd_to_hsd_bf16(
|
||||||
|
x.data_ptr() as _, out.data_ptr() as *mut c_void,
|
||||||
|
seq_len as i32, num_heads as i32, head_dim as i32, std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [1, KV_H, S, D] → [1, KV_H*n_rep, S, D] on GPU (BF16)
|
||||||
|
pub fn repeat_kv_gpu(x: &Tensor, n_rep: usize) -> Tensor {
|
||||||
|
if n_rep == 1 { return x.clone(); }
|
||||||
|
assert_eq!(x.dtype(), DType::BF16);
|
||||||
|
assert!(x.is_contiguous() && matches!(x.device(), Device::Cuda(_)));
|
||||||
|
let kv_heads = x.shape()[1];
|
||||||
|
let seq_len = x.shape()[2];
|
||||||
|
let head_dim = x.shape()[3];
|
||||||
|
let new_heads = kv_heads * n_rep;
|
||||||
|
let out = Tensor::empty(&[1, new_heads, seq_len, head_dim], DType::BF16, x.device());
|
||||||
|
unsafe {
|
||||||
|
launch_repeat_kv_bf16(
|
||||||
|
x.data_ptr() as _, out.data_ptr() as *mut c_void,
|
||||||
|
kv_heads as i32, n_rep as i32, seq_len as i32, head_dim as i32, std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a non-contiguous GPU tensor contiguous via a strided copy kernel.
|
||||||
|
/// Supports BF16 and F32, up to 4D tensors (padded to 4D internally).
|
||||||
|
pub fn strided_to_contiguous_gpu(x: &Tensor) -> Tensor {
|
||||||
|
assert!(matches!(x.device(), Device::Cuda(_)), "expected GPU tensor");
|
||||||
|
assert!(!x.is_contiguous(), "tensor is already contiguous");
|
||||||
|
assert!(x.ndim() <= 4, "strided_to_contiguous_gpu supports up to 4D");
|
||||||
|
|
||||||
|
let ndim = x.ndim();
|
||||||
|
let numel = x.numel();
|
||||||
|
|
||||||
|
// Pad shape and strides to 4D (prepend 1s for shape, 0s for strides)
|
||||||
|
let mut shape4 = [1i32; 4];
|
||||||
|
let mut strides4 = [0i32; 4];
|
||||||
|
let pad = 4 - ndim;
|
||||||
|
for i in 0..ndim {
|
||||||
|
shape4[pad + i] = x.shape()[i] as i32;
|
||||||
|
strides4[pad + i] = x.strides()[i] as i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = Tensor::empty(x.shape(), x.dtype(), x.device());
|
||||||
|
|
||||||
|
// Use storage base pointer + element offset, because strides are relative to
|
||||||
|
// element 0 of the storage, not the data_ptr() (which already adds byte offset).
|
||||||
|
let storage_ptr = x.storage().gpu_buffer().as_ptr();
|
||||||
|
let in_offset = x.offset() as i32;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match x.dtype() {
|
||||||
|
DType::BF16 => launch_strided_copy_bf16(
|
||||||
|
storage_ptr as _, out.data_ptr() as *mut c_void,
|
||||||
|
numel as i32, ndim as i32,
|
||||||
|
shape4[0], shape4[1], shape4[2], shape4[3],
|
||||||
|
strides4[0], strides4[1], strides4[2], strides4[3],
|
||||||
|
in_offset, std::ptr::null_mut(),
|
||||||
|
),
|
||||||
|
DType::F32 => launch_strided_copy_f32(
|
||||||
|
storage_ptr as _, out.data_ptr() as *mut c_void,
|
||||||
|
numel as i32, ndim as i32,
|
||||||
|
shape4[0], shape4[1], shape4[2], shape4[3],
|
||||||
|
strides4[0], strides4[1], strides4[2], strides4[3],
|
||||||
|
in_offset, std::ptr::null_mut(),
|
||||||
|
),
|
||||||
|
_ => panic!("strided_to_contiguous_gpu: unsupported dtype {:?}", x.dtype()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -121,6 +121,20 @@ fn test_gemm_cublas_bf16_small() { run_gemm_test_bf16(GemmBackend::CuBlas, 4, 4,
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_gemm_cublas_bf16_medium() { run_gemm_test_bf16(GemmBackend::CuBlas, 256, 256, 256); }
|
fn test_gemm_cublas_bf16_medium() { run_gemm_test_bf16(GemmBackend::CuBlas, 256, 256, 256); }
|
||||||
|
|
||||||
|
// --- Custom GEMV tests (M=1, BF16 fast path) ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gemv_bf16_small() { run_gemm_test_bf16(GemmBackend::CuBlas, 1, 64, 64); }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gemv_bf16_medium() { run_gemm_test_bf16(GemmBackend::CuBlas, 1, 256, 256); }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gemv_bf16_4096() { run_gemm_test_bf16(GemmBackend::CuBlas, 1, 4096, 4096); }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gemv_bf16_rect() { run_gemm_test_bf16(GemmBackend::CuBlas, 1, 512, 4096); }
|
||||||
|
|
||||||
// --- Larger benchmark-style tests ---
|
// --- Larger benchmark-style tests ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ fn cpu_rope(x: &mut [f32], positions: &[u32], num_heads: usize, head_dim: usize,
|
|||||||
let cos_val = angle.cos();
|
let cos_val = angle.cos();
|
||||||
let sin_val = angle.sin();
|
let sin_val = angle.sin();
|
||||||
let base = (t * num_heads + h) * head_dim;
|
let base = (t * num_heads + h) * head_dim;
|
||||||
let x0 = x[base + 2 * i];
|
let x0 = x[base + i];
|
||||||
let x1 = x[base + 2 * i + 1];
|
let x1 = x[base + i + half_dim];
|
||||||
x[base + 2 * i] = x0 * cos_val - x1 * sin_val;
|
x[base + i] = x0 * cos_val - x1 * sin_val;
|
||||||
x[base + 2 * i + 1] = x0 * sin_val + x1 * cos_val;
|
x[base + i + half_dim] = x1 * cos_val + x0 * sin_val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ xserv-cuda = { path = "../xserv-cuda" }
|
|||||||
xserv-tensor = { path = "../xserv-tensor" }
|
xserv-tensor = { path = "../xserv-tensor" }
|
||||||
xserv-kernels = { path = "../xserv-kernels" }
|
xserv-kernels = { path = "../xserv-kernels" }
|
||||||
xserv-tokenizer = { path = "../xserv-tokenizer" }
|
xserv-tokenizer = { path = "../xserv-tokenizer" }
|
||||||
|
xserv-distributed = { path = "../xserv-distributed" }
|
||||||
half.workspace = true
|
half.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
safetensors.workspace = true
|
safetensors.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
|||||||
310
crates/xserv-model/src/bin/bench-gpt-oss.rs
Normal file
310
crates/xserv-model/src/bin/bench-gpt-oss.rs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use xserv_distributed::{TpContext, UniqueId, get_unique_id};
|
||||||
|
use xserv_model::{loader, GptOss, ModelConfig, PagedKVCache, BLOCK_SIZE};
|
||||||
|
use xserv_tensor::{DType, Device};
|
||||||
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: bench-gpt-oss <model-dir> [--max-tokens N] [--tp N]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_dir = PathBuf::from(&args[1]);
|
||||||
|
let max_tokens: usize = get_arg(&args, "--max-tokens").unwrap_or(32);
|
||||||
|
let world: usize = get_arg(&args, "--tp").unwrap_or(2);
|
||||||
|
|
||||||
|
let config = ModelConfig::from_file(&model_dir.join("config.json"));
|
||||||
|
let tokenizer = Tokenizer::from_file(&model_dir.join("tokenizer.json"));
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"gpt-oss-20b: layers={}, hidden={}, heads={}/{} kv, experts={}, top_k={}, vocab={}",
|
||||||
|
config.num_layers(), config.hidden(), config.num_heads(),
|
||||||
|
config.num_kv_heads(), config.num_experts(), config.experts_per_token(),
|
||||||
|
config.vocab_size
|
||||||
|
);
|
||||||
|
eprintln!("TP world={world}, max_tokens={max_tokens}");
|
||||||
|
|
||||||
|
let max_seq_len: usize = 2048;
|
||||||
|
let max_blocks_per_seq = (max_seq_len + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
|
||||||
|
// TP setup
|
||||||
|
let uid = get_unique_id();
|
||||||
|
let local_kv = config.num_kv_heads() / world;
|
||||||
|
|
||||||
|
// Spawn worker threads for ranks 1..world
|
||||||
|
let mut worker_handles = Vec::new();
|
||||||
|
let mut worker_txs = Vec::new();
|
||||||
|
for rank in 1..world {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<WorkerCmd>();
|
||||||
|
let (ack_tx, ack_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
let cfg = config.clone();
|
||||||
|
let md = model_dir.clone();
|
||||||
|
let uid_copy = uid;
|
||||||
|
worker_handles.push((
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
worker_loop(rank, world, uid_copy, md, cfg, max_seq_len, rx, ack_tx);
|
||||||
|
}),
|
||||||
|
ack_rx,
|
||||||
|
));
|
||||||
|
worker_txs.push(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank 0 setup
|
||||||
|
xserv_cuda::device::set_device(0).unwrap();
|
||||||
|
let tp0 = Arc::new(TpContext::init(0, world, uid, 0));
|
||||||
|
eprintln!("[rank 0] Loading weights...");
|
||||||
|
let weights = loader::load_model_dir(&model_dir, Device::Cpu);
|
||||||
|
eprintln!("[rank 0] Loaded {} tensors, building model...", weights.len());
|
||||||
|
let model = GptOss::from_weights_tp(config.clone(), weights, 0, world, 0, Some(tp0));
|
||||||
|
let total_blocks = max_blocks_per_seq + 64;
|
||||||
|
let mut cache = PagedKVCache::new_tp(
|
||||||
|
&config, local_kv, total_blocks, 0, 4, max_blocks_per_seq, DType::BF16, 0,
|
||||||
|
);
|
||||||
|
eprintln!("[rank 0] Ready.");
|
||||||
|
|
||||||
|
// Prompt
|
||||||
|
let prompt_arg = get_arg::<String>(&args, "--prompt");
|
||||||
|
let prompt = prompt_arg.as_deref().unwrap_or("What is the meaning of life?");
|
||||||
|
let token_ids = tokenizer.encode(prompt);
|
||||||
|
eprintln!("Prompt ({} tokens): {prompt}", token_ids.len());
|
||||||
|
|
||||||
|
// Register sequence
|
||||||
|
let slot = 0;
|
||||||
|
cache.register_sequence(slot).unwrap();
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Register(slot));
|
||||||
|
|
||||||
|
// Teacher-forced diagnostic: prefill (prompt + forced ids) in one shot and
|
||||||
|
// report, for each forced position, whether xserv's argmax == the forced
|
||||||
|
// (oracle) next token. Removes free-running compounding so it isolates
|
||||||
|
// whether per-position logits agree with the llama.cpp trajectory.
|
||||||
|
if let Some(forced) = get_arg::<String>(&args, "--forced") {
|
||||||
|
let forced_ids: Vec<u32> = forced.split(',').filter_map(|s| s.trim().parse().ok()).collect();
|
||||||
|
let mut seq = token_ids.clone();
|
||||||
|
seq.extend_from_slice(&forced_ids);
|
||||||
|
// Workers must run the same prefill in lockstep (TP AllReduces match up).
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Prefill { tokens: seq.clone(), slot });
|
||||||
|
let logits = model.forward_prefill_paged(&seq, slot, &mut cache);
|
||||||
|
wait_workers(&worker_handles);
|
||||||
|
let logits_cpu = logits.to_device(Device::Cpu);
|
||||||
|
let vocab = logits.shape()[1];
|
||||||
|
let data = logits_cpu.as_slice::<half::bf16>();
|
||||||
|
let plen = token_ids.len();
|
||||||
|
let mut matches = 0usize;
|
||||||
|
let mut total = 0usize;
|
||||||
|
// position i predicts seq[i+1]; we check the forced region
|
||||||
|
for i in (plen - 1)..(seq.len() - 1) {
|
||||||
|
let row = &data[i * vocab..(i + 1) * vocab];
|
||||||
|
let argmax = row.iter().enumerate()
|
||||||
|
.max_by(|a, b| a.1.to_f32().partial_cmp(&b.1.to_f32()).unwrap())
|
||||||
|
.map(|(j, _)| j as u32).unwrap();
|
||||||
|
let expected = seq[i + 1];
|
||||||
|
let ok = argmax == expected;
|
||||||
|
if ok { matches += 1; }
|
||||||
|
total += 1;
|
||||||
|
eprintln!("pos {i}: xserv_argmax={argmax} oracle={expected} {}", if ok {"OK"} else {"DIFF"});
|
||||||
|
}
|
||||||
|
eprintln!("\nTeacher-forced top-1 agreement: {matches}/{total} = {:.1}%",
|
||||||
|
100.0 * matches as f64 / total as f64);
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Shutdown);
|
||||||
|
for (h, _) in worker_handles { h.join().unwrap(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teacher-forced DECODE diagnostic: prefill the prompt, then walk the oracle
|
||||||
|
// trajectory through the autoregressive decode path (NOT prefill), recording
|
||||||
|
// per-position top-1 agreement bucketed by position. Localizes long-context
|
||||||
|
// decode degradation (which prefill teacher-forcing cannot see).
|
||||||
|
if let Some(forced) = get_arg::<String>(&args, "--forced-decode") {
|
||||||
|
let forced_ids: Vec<u32> = forced.split(',').filter_map(|s| s.trim().parse().ok()).collect();
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Prefill { tokens: token_ids.clone(), slot });
|
||||||
|
let logits = model.forward_prefill_paged(&token_ids, slot, &mut cache);
|
||||||
|
wait_workers(&worker_handles);
|
||||||
|
let mut pred = sample_greedy_last(&logits); // prediction for forced[0]
|
||||||
|
let bucket = 50usize;
|
||||||
|
let mut buckets: Vec<(usize, usize)> = Vec::new();
|
||||||
|
let (mut matches, mut total) = (0usize, 0usize);
|
||||||
|
for (i, &f) in forced_ids.iter().enumerate() {
|
||||||
|
let ok = pred == f;
|
||||||
|
matches += ok as usize;
|
||||||
|
total += 1;
|
||||||
|
let b = i / bucket;
|
||||||
|
if buckets.len() <= b { buckets.push((0, 0)); }
|
||||||
|
buckets[b].0 += ok as usize;
|
||||||
|
buckets[b].1 += 1;
|
||||||
|
// Teacher-force: feed the oracle token through the decode path.
|
||||||
|
let pos = cache.seq_len(slot);
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Decode {
|
||||||
|
tokens: vec![f], positions: vec![pos], slots: vec![slot],
|
||||||
|
});
|
||||||
|
let logits = model.forward_decode_paged(&[f], &[pos], &[slot], &mut cache);
|
||||||
|
wait_workers(&worker_handles);
|
||||||
|
pred = sample_greedy_last(&logits);
|
||||||
|
}
|
||||||
|
eprintln!("Teacher-forced DECODE agreement: {matches}/{total} = {:.1}%",
|
||||||
|
100.0 * matches as f64 / total as f64);
|
||||||
|
for (b, (m, t)) in buckets.iter().enumerate() {
|
||||||
|
eprintln!(" pos[{:>4}..{:<4}]: {m:>3}/{t:<3} = {:.0}%",
|
||||||
|
b * bucket, b * bucket + t, 100.0 * (*m as f64) / (*t as f64));
|
||||||
|
}
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Shutdown);
|
||||||
|
for (h, _) in worker_handles { h.join().unwrap(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefill
|
||||||
|
let t0 = Instant::now();
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Prefill {
|
||||||
|
tokens: token_ids.clone(), slot,
|
||||||
|
});
|
||||||
|
let logits = model.forward_prefill_paged(&token_ids, slot, &mut cache);
|
||||||
|
wait_workers(&worker_handles);
|
||||||
|
let ttft = t0.elapsed();
|
||||||
|
|
||||||
|
let mut next = sample_greedy_last(&logits);
|
||||||
|
let mut output_tokens = vec![next];
|
||||||
|
|
||||||
|
eprintln!("TTFT: {:.1}ms", ttft.as_secs_f64() * 1000.0);
|
||||||
|
print!("{prompt}");
|
||||||
|
|
||||||
|
// Decode
|
||||||
|
let decode_start = Instant::now();
|
||||||
|
for _ in 1..max_tokens {
|
||||||
|
let text = tokenizer.decode(&[next]);
|
||||||
|
print!("{text}");
|
||||||
|
|
||||||
|
if tokenizer.eos_token_id() == Some(next) { break; }
|
||||||
|
|
||||||
|
let pos = cache.seq_len(slot);
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Decode {
|
||||||
|
tokens: vec![next], positions: vec![pos], slots: vec![slot],
|
||||||
|
});
|
||||||
|
let logits = model.forward_decode_paged(&[next], &[pos], &[slot], &mut cache);
|
||||||
|
wait_workers(&worker_handles);
|
||||||
|
|
||||||
|
next = sample_greedy_last(&logits);
|
||||||
|
output_tokens.push(next);
|
||||||
|
}
|
||||||
|
let decode_elapsed = decode_start.elapsed();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let gen_tokens = output_tokens.len();
|
||||||
|
let full_text = tokenizer.decode(&output_tokens);
|
||||||
|
eprintln!("\nGenerated text: {full_text}");
|
||||||
|
eprintln!("Token IDs: {:?}", &output_tokens[..output_tokens.len().min(20)]);
|
||||||
|
let tpot = if gen_tokens > 1 {
|
||||||
|
decode_elapsed.as_secs_f64() * 1000.0 / (gen_tokens - 1) as f64
|
||||||
|
} else { 0.0 };
|
||||||
|
let tok_s = if gen_tokens > 1 {
|
||||||
|
(gen_tokens - 1) as f64 / decode_elapsed.as_secs_f64()
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
eprintln!("\n--- Performance ---");
|
||||||
|
eprintln!("Generated: {} tokens", gen_tokens);
|
||||||
|
eprintln!("TTFT: {:.1}ms", ttft.as_secs_f64() * 1000.0);
|
||||||
|
eprintln!("TPOT: {:.1}ms", tpot);
|
||||||
|
eprintln!("Throughput: {:.1} tok/s", tok_s);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
broadcast_cmd(&worker_txs, &worker_handles, WorkerCmd::Shutdown);
|
||||||
|
for (h, _) in worker_handles {
|
||||||
|
h.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Worker infrastructure ---
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum WorkerCmd {
|
||||||
|
Register(usize),
|
||||||
|
Prefill { tokens: Vec<u32>, slot: usize },
|
||||||
|
Decode { tokens: Vec<u32>, positions: Vec<usize>, slots: Vec<usize> },
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_loop(
|
||||||
|
rank: usize,
|
||||||
|
world: usize,
|
||||||
|
uid: UniqueId,
|
||||||
|
model_dir: PathBuf,
|
||||||
|
config: ModelConfig,
|
||||||
|
max_seq_len: usize,
|
||||||
|
rx: std::sync::mpsc::Receiver<WorkerCmd>,
|
||||||
|
ack_tx: std::sync::mpsc::Sender<()>,
|
||||||
|
) {
|
||||||
|
xserv_cuda::device::set_device(rank as u32).unwrap();
|
||||||
|
let tp = Arc::new(TpContext::init(rank, world, uid, rank as u32));
|
||||||
|
eprintln!("[rank {rank}] Loading weights...");
|
||||||
|
let weights = loader::load_model_dir(&model_dir, Device::Cpu);
|
||||||
|
let model = GptOss::from_weights_tp(config.clone(), weights, rank, world, rank as u32, Some(tp));
|
||||||
|
let local_kv = config.num_kv_heads() / world;
|
||||||
|
let max_blocks_per_seq = (max_seq_len + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
let total_blocks = max_blocks_per_seq + 64;
|
||||||
|
let mut cache = PagedKVCache::new_tp(
|
||||||
|
&config, local_kv, total_blocks, 0, 4, max_blocks_per_seq, DType::BF16, rank as u32,
|
||||||
|
);
|
||||||
|
eprintln!("[rank {rank}] Ready.");
|
||||||
|
ack_tx.send(()).unwrap();
|
||||||
|
|
||||||
|
while let Ok(cmd) = rx.recv() {
|
||||||
|
match cmd {
|
||||||
|
WorkerCmd::Register(slot) => {
|
||||||
|
let _ = cache.register_sequence(slot);
|
||||||
|
}
|
||||||
|
WorkerCmd::Prefill { tokens, slot } => {
|
||||||
|
let _ = model.forward_prefill_paged(&tokens, slot, &mut cache);
|
||||||
|
}
|
||||||
|
WorkerCmd::Decode { tokens, positions, slots } => {
|
||||||
|
let _ = model.forward_decode_paged(&tokens, &positions, &slots, &mut cache);
|
||||||
|
}
|
||||||
|
WorkerCmd::Shutdown => break,
|
||||||
|
}
|
||||||
|
ack_tx.send(()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast_cmd(
|
||||||
|
txs: &[std::sync::mpsc::Sender<WorkerCmd>],
|
||||||
|
_handles: &[(std::thread::JoinHandle<()>, std::sync::mpsc::Receiver<()>)],
|
||||||
|
cmd: WorkerCmd,
|
||||||
|
) {
|
||||||
|
for tx in txs {
|
||||||
|
tx.send(cmd.clone()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_workers(handles: &[(std::thread::JoinHandle<()>, std::sync::mpsc::Receiver<()>)]) {
|
||||||
|
for (_, rx) in handles {
|
||||||
|
rx.recv().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_greedy_last(logits: &xserv_tensor::Tensor) -> u32 {
|
||||||
|
use half::bf16;
|
||||||
|
assert_eq!(logits.ndim(), 2);
|
||||||
|
let logits_cpu = logits.to_device(Device::Cpu);
|
||||||
|
let vocab_size = logits.shape()[1];
|
||||||
|
let seq_len = logits.shape()[0];
|
||||||
|
let data = logits_cpu.as_slice::<bf16>();
|
||||||
|
let last = &data[(seq_len - 1) * vocab_size..seq_len * vocab_size];
|
||||||
|
|
||||||
|
|
||||||
|
last.iter().enumerate()
|
||||||
|
.max_by(|a, b| {
|
||||||
|
let af = a.1.to_f32();
|
||||||
|
let bf = b.1.to_f32();
|
||||||
|
af.partial_cmp(&bf).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.map(|(i, _)| i as u32).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_arg<T: std::str::FromStr>(args: &[String], flag: &str) -> Option<T> {
|
||||||
|
args.iter()
|
||||||
|
.position(|a| a == flag)
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use xserv_model::qwen3::sample_greedy;
|
use xserv_model::qwen3::sample_greedy;
|
||||||
use xserv_model::{loader, KVCache, ModelConfig, Qwen3};
|
use xserv_model::{loader, DecodeGraphState, GpuKVCache, ModelConfig, Qwen3};
|
||||||
use xserv_tensor::{DType, Device};
|
use xserv_tensor::{DType, Device};
|
||||||
use xserv_tokenizer::Tokenizer;
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
if args.len() < 2 {
|
if args.len() < 2 {
|
||||||
eprintln!("Usage: bench-qwen3 <model-dir> [--gen-tokens N]");
|
eprintln!("Usage: bench-qwen3 <model-dir> [--gen-tokens N] [--cuda-graph]");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
let model_dir = PathBuf::from(&args[1]);
|
let model_dir = PathBuf::from(&args[1]);
|
||||||
@@ -18,6 +18,7 @@ fn main() {
|
|||||||
.and_then(|i| args.get(i + 1))
|
.and_then(|i| args.get(i + 1))
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(20);
|
.unwrap_or(20);
|
||||||
|
let use_cuda_graph = args.iter().any(|a| a == "--cuda-graph");
|
||||||
|
|
||||||
xserv_cuda::device::set_device(0).unwrap();
|
xserv_cuda::device::set_device(0).unwrap();
|
||||||
|
|
||||||
@@ -31,12 +32,21 @@ fn main() {
|
|||||||
// Warmup
|
// Warmup
|
||||||
{
|
{
|
||||||
let ids = tokenizer.encode("warmup");
|
let ids = tokenizer.encode("warmup");
|
||||||
let mut cache = KVCache::new(
|
let mut cache = GpuKVCache::new(&config, 256, DType::BF16, 0);
|
||||||
config.num_layers(), config.num_kv_heads(), config.head_dim(),
|
let _ = model.forward_gpu_cache(&ids, &mut cache);
|
||||||
DType::BF16, Device::Cuda(0),
|
|
||||||
);
|
|
||||||
let _ = model.forward_with_cache(&ids, &mut cache);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CUDA Graph setup
|
||||||
|
let layer_ptrs = model.layer_weight_ptrs();
|
||||||
|
let (norm_w, lm_head, embed, cos, sin) = model.graph_capture_ptrs();
|
||||||
|
let mut decode_graph = if use_cuda_graph {
|
||||||
|
eprintln!("CUDA Graph mode enabled");
|
||||||
|
Some(DecodeGraphState::new(&config))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut graph_captured = false;
|
||||||
|
|
||||||
eprintln!("Warmup done. Running benchmark...");
|
eprintln!("Warmup done. Running benchmark...");
|
||||||
|
|
||||||
let prompts: Vec<&str> = vec![
|
let prompts: Vec<&str> = vec![
|
||||||
@@ -97,14 +107,17 @@ fn main() {
|
|||||||
let input_ids = tokenizer.encode(prompt);
|
let input_ids = tokenizer.encode(prompt);
|
||||||
let input_len = input_ids.len();
|
let input_len = input_ids.len();
|
||||||
|
|
||||||
let mut cache = KVCache::new(
|
let mut cache = GpuKVCache::new(&config, 256, DType::BF16, 0);
|
||||||
config.num_layers(), config.num_kv_heads(), config.head_dim(),
|
|
||||||
DType::BF16, Device::Cuda(0),
|
// Reset graph state for new prompt
|
||||||
);
|
graph_captured = false;
|
||||||
|
if let Some(ref mut g) = decode_graph {
|
||||||
|
g.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
// Prefill
|
// Prefill
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
let logits = model.forward_with_cache(&input_ids, &mut cache);
|
let logits = model.forward_gpu_cache(&input_ids, &mut cache);
|
||||||
let first_token = sample_greedy(&logits);
|
let first_token = sample_greedy(&logits);
|
||||||
let ttft_us = t0.elapsed().as_micros();
|
let ttft_us = t0.elapsed().as_micros();
|
||||||
|
|
||||||
@@ -115,8 +128,35 @@ fn main() {
|
|||||||
for _ in 1..gen_tokens {
|
for _ in 1..gen_tokens {
|
||||||
let last = *generated.last().unwrap();
|
let last = *generated.last().unwrap();
|
||||||
let t_start = Instant::now();
|
let t_start = Instant::now();
|
||||||
let logits = model.forward_with_cache(&[last], &mut cache);
|
|
||||||
let next = sample_greedy(&logits);
|
let next = if let Some(ref mut graph) = decode_graph {
|
||||||
|
if !graph_captured {
|
||||||
|
// First decode token: run ungraphed, then capture
|
||||||
|
let logits = model.forward_gpu_cache(&[last], &mut cache);
|
||||||
|
graph_captured = true;
|
||||||
|
graph.capture(&layer_ptrs, norm_w, lm_head, embed, cos, sin);
|
||||||
|
sample_greedy(&logits)
|
||||||
|
} else {
|
||||||
|
// Replay captured graphs
|
||||||
|
let pos = cache.seq_len() as u32;
|
||||||
|
graph.execute(last, pos, &mut cache, &layer_ptrs, embed, config.vocab_size as i32, config.hidden() as i32);
|
||||||
|
cache.advance_seq_len(1);
|
||||||
|
// Read logits from graph buffer
|
||||||
|
let vocab_size = config.vocab_size;
|
||||||
|
let mut logits_bytes = vec![0u8; vocab_size * 2];
|
||||||
|
graph.logits_buffer().copy_to_host(&mut logits_bytes).unwrap();
|
||||||
|
let logits_data: &[half::bf16] = unsafe {
|
||||||
|
std::slice::from_raw_parts(logits_bytes.as_ptr() as *const half::bf16, vocab_size)
|
||||||
|
};
|
||||||
|
logits_data.iter().enumerate()
|
||||||
|
.max_by(|a, b| a.1.to_f32().partial_cmp(&b.1.to_f32()).unwrap())
|
||||||
|
.map(|(idx, _)| idx as u32).unwrap()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let logits = model.forward_gpu_cache(&[last], &mut cache);
|
||||||
|
sample_greedy(&logits)
|
||||||
|
};
|
||||||
|
|
||||||
token_times.push(t_start.elapsed().as_micros());
|
token_times.push(t_start.elapsed().as_micros());
|
||||||
generated.push(next);
|
generated.push(next);
|
||||||
if tokenizer.eos_token_id() == Some(next) { break; }
|
if tokenizer.eos_token_id() == Some(next) { break; }
|
||||||
@@ -148,12 +188,14 @@ fn main() {
|
|||||||
print!("\"tpot_us\": {tpot_us}}}");
|
print!("\"tpot_us\": {tpot_us}}}");
|
||||||
if i < prompts.len() - 1 { println!(","); } else { println!(); }
|
if i < prompts.len() - 1 { println!(","); } else { println!(); }
|
||||||
|
|
||||||
|
let display_text = generated_text.replace('\n', " ");
|
||||||
|
let truncated: String = display_text.chars().take(60).collect();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[{}/{}] input={input_len}tok gen={num_generated}tok ttft={:.1}ms tbt={:.1}ms | {}",
|
"[{}/{}] input={input_len}tok gen={num_generated}tok ttft={:.1}ms tbt={:.1}ms | {}",
|
||||||
i + 1, prompts.len(),
|
i + 1, prompts.len(),
|
||||||
ttft_us as f64 / 1000.0,
|
ttft_us as f64 / 1000.0,
|
||||||
tbt_us as f64 / 1000.0,
|
tbt_us as f64 / 1000.0,
|
||||||
&generated_text.replace('\n', " ")[..generated_text.len().min(60)]
|
truncated
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!("]");
|
println!("]");
|
||||||
|
|||||||
194
crates/xserv-model/src/bin/bench-tp.rs
Normal file
194
crates/xserv-model/src/bin/bench-tp.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
//! Tensor-parallel E2E benchmark for Qwen3.
|
||||||
|
//!
|
||||||
|
//! Spawns one thread per TP rank (each bound to one GPU), loads the sharded
|
||||||
|
//! model, and runs greedy autoregressive generation. Because lm_head is
|
||||||
|
//! replicated and the post-AllReduce hidden state is identical on every rank,
|
||||||
|
//! all ranks compute identical logits and pick the same greedy token — so the
|
||||||
|
//! rank threads stay in lockstep via the per-layer AllReduces without any
|
||||||
|
//! token broadcast. Rank 0 records output + timings.
|
||||||
|
//!
|
||||||
|
//! Usage: bench-tp <model-dir> [--tp N] [--gen-tokens N] [--devices 0,1,2,3]
|
||||||
|
//!
|
||||||
|
//! Run with --tp 1 / 2 / 4 and compare the printed text (correctness) and
|
||||||
|
//! tok/s (performance).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use xserv_model::qwen3::sample_greedy;
|
||||||
|
use xserv_model::{loader, ModelConfig, PagedKVCache, Qwen3, BLOCK_SIZE};
|
||||||
|
use xserv_tensor::{DType, Device};
|
||||||
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
|
struct PromptResult {
|
||||||
|
gen_ids: Vec<u32>,
|
||||||
|
ttft_ms: f64,
|
||||||
|
decode_tok_s: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: bench-tp <model-dir> [--tp N] [--gen-tokens N] [--devices 0,1,2,3]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let model_dir = PathBuf::from(&args[1]);
|
||||||
|
let world: usize = arg(&args, "--tp").and_then(|s| s.parse().ok()).unwrap_or(1).max(1);
|
||||||
|
let gen_tokens: usize = arg(&args, "--gen-tokens").and_then(|s| s.parse().ok()).unwrap_or(64);
|
||||||
|
let devices: Vec<u32> = match arg(&args, "--devices") {
|
||||||
|
Some(s) => s.split(',').filter_map(|d| d.trim().parse().ok()).collect(),
|
||||||
|
None => (0..world as u32).collect(),
|
||||||
|
};
|
||||||
|
assert_eq!(devices.len(), world, "--devices count must equal --tp");
|
||||||
|
|
||||||
|
let config = ModelConfig::from_file(&model_dir.join("config.json"));
|
||||||
|
assert!(
|
||||||
|
config.num_kv_heads() % world == 0,
|
||||||
|
"num_kv_heads {} not divisible by tp {world}",
|
||||||
|
config.num_kv_heads()
|
||||||
|
);
|
||||||
|
let tokenizer = Tokenizer::from_file(&model_dir.join("tokenizer.json"));
|
||||||
|
let eos = tokenizer.eos_token_id();
|
||||||
|
|
||||||
|
let prompts: Vec<&str> = vec![
|
||||||
|
"The capital of France is",
|
||||||
|
"Explain photosynthesis in one sentence.",
|
||||||
|
"Write a haiku about the ocean.",
|
||||||
|
"List three uses of a hammer.",
|
||||||
|
"What is the speed of light?",
|
||||||
|
"Describe the water cycle briefly.",
|
||||||
|
"Who wrote Romeo and Juliet?",
|
||||||
|
"Translate 'good morning' into Spanish.",
|
||||||
|
];
|
||||||
|
let prompt_ids: Vec<Vec<u32>> = prompts.iter().map(|p| tokenizer.encode(p)).collect();
|
||||||
|
|
||||||
|
// Tensors are not Send (their Storage holds a raw GPU pointer), so each rank
|
||||||
|
// thread loads its own CPU copy of the weights and shards in-thread. Loading
|
||||||
|
// is not part of the timed region.
|
||||||
|
let id = if world > 1 { Some(xserv_distributed::get_unique_id()) } else { None };
|
||||||
|
|
||||||
|
let handles: Vec<_> = (0..world)
|
||||||
|
.map(|rank| {
|
||||||
|
let model_dir = model_dir.clone();
|
||||||
|
let config = config.clone();
|
||||||
|
let prompt_ids = prompt_ids.clone();
|
||||||
|
let device = devices[rank];
|
||||||
|
thread::spawn(move || {
|
||||||
|
run_rank(rank, world, device, id, config, model_dir, prompt_ids, gen_tokens, eos)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut rank0: Option<Vec<PromptResult>> = None;
|
||||||
|
for (rank, h) in handles.into_iter().enumerate() {
|
||||||
|
let r = h.join().expect("rank thread panicked");
|
||||||
|
if rank == 0 {
|
||||||
|
rank0 = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = rank0.expect("rank 0 produced no results");
|
||||||
|
println!("\n=== TP={world} (devices {devices:?}) — Qwen3 E2E benchmark ===");
|
||||||
|
println!("{:<45} {:>10} {:>12} {:>8}", "prompt", "TTFT(ms)", "decode tok/s", "gen");
|
||||||
|
let mut tps_sum = 0.0;
|
||||||
|
for (i, r) in results.iter().enumerate() {
|
||||||
|
let text = tokenizer.decode(&r.gen_ids).replace('\n', " ");
|
||||||
|
let short: String = text.chars().take(50).collect();
|
||||||
|
let p: String = prompts[i].chars().take(43).collect();
|
||||||
|
println!(
|
||||||
|
"{:<45} {:>10.1} {:>12.1} {:>8} | {}",
|
||||||
|
p, r.ttft_ms, r.decode_tok_s, r.gen_ids.len(), short
|
||||||
|
);
|
||||||
|
tps_sum += r.decode_tok_s;
|
||||||
|
}
|
||||||
|
println!("--- mean decode throughput: {:.1} tok/s ---", tps_sum / results.len() as f64);
|
||||||
|
|
||||||
|
// Machine-readable line for cross-TP correctness diffing (rank 0 token ids).
|
||||||
|
let all_ids: Vec<String> = results
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.gen_ids.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(","))
|
||||||
|
.collect();
|
||||||
|
println!("CORRECTNESS_IDS tp={world} {}", all_ids.join(" | "));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_rank(
|
||||||
|
rank: usize,
|
||||||
|
world: usize,
|
||||||
|
device: u32,
|
||||||
|
id: Option<xserv_distributed::UniqueId>,
|
||||||
|
config: ModelConfig,
|
||||||
|
model_dir: PathBuf,
|
||||||
|
prompt_ids: Vec<Vec<u32>>,
|
||||||
|
gen_tokens: usize,
|
||||||
|
eos: Option<u32>,
|
||||||
|
) -> Option<Vec<PromptResult>> {
|
||||||
|
// Bind this thread to its GPU and set up the TP communicator.
|
||||||
|
let tp = if world > 1 {
|
||||||
|
Some(Arc::new(xserv_distributed::TpContext::init(rank, world, id.unwrap(), device)))
|
||||||
|
} else {
|
||||||
|
xserv_cuda::device::set_device(device).unwrap();
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load this rank's own CPU copy of the weights and shard in-thread.
|
||||||
|
let weights = loader::load_model_dir(&model_dir, Device::Cpu);
|
||||||
|
let model = Qwen3::from_weights_tp(config.clone(), weights, rank, world, device, tp.clone());
|
||||||
|
|
||||||
|
// Per-rank paged KV cache holds only this rank's local KV heads.
|
||||||
|
let local_kv = config.num_kv_heads() / world;
|
||||||
|
let max_seq = 2048usize;
|
||||||
|
let max_blocks_per_seq = max_seq.div_ceil(BLOCK_SIZE);
|
||||||
|
let total_blocks = max_blocks_per_seq + 8;
|
||||||
|
let mut cache = PagedKVCache::new_tp(
|
||||||
|
&config, local_kv, total_blocks, 0, 1, max_blocks_per_seq, DType::BF16, device,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Warmup (init kernels / allocator / NCCL channels) — not timed.
|
||||||
|
cache.register_sequence(0).unwrap();
|
||||||
|
let _ = model.forward_prefill_paged(&[1u32, 2, 3], 0, &mut cache);
|
||||||
|
cache.free_sequence(0);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for ids in &prompt_ids {
|
||||||
|
cache.register_sequence(0).unwrap();
|
||||||
|
|
||||||
|
// Prefill (TTFT).
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let logits = model.forward_prefill_paged(ids, 0, &mut cache);
|
||||||
|
let first = sample_greedy(&logits);
|
||||||
|
let ttft_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
|
||||||
|
let mut generated = vec![first];
|
||||||
|
|
||||||
|
// Decode.
|
||||||
|
let t1 = Instant::now();
|
||||||
|
let mut steps = 0usize;
|
||||||
|
for _ in 1..gen_tokens {
|
||||||
|
let last = *generated.last().unwrap();
|
||||||
|
if eos == Some(last) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let pos = cache.seq_len(0);
|
||||||
|
let logits = model.forward_decode_paged(&[last], &[pos], &[0], &mut cache);
|
||||||
|
let next = sample_greedy(&logits);
|
||||||
|
generated.push(next);
|
||||||
|
steps += 1;
|
||||||
|
}
|
||||||
|
let decode_s = t1.elapsed().as_secs_f64();
|
||||||
|
let decode_tok_s = if steps > 0 && decode_s > 0.0 { steps as f64 / decode_s } else { 0.0 };
|
||||||
|
|
||||||
|
cache.free_sequence(0);
|
||||||
|
|
||||||
|
if rank == 0 {
|
||||||
|
out.push(PromptResult { gen_ids: generated, ttft_ms, decode_tok_s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rank == 0 { Some(out) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arg<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
|
||||||
|
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).map(|s| s.as_str())
|
||||||
|
}
|
||||||
937
crates/xserv-model/src/bin/xserv-chat.rs
Normal file
937
crates/xserv-model/src/bin/xserv-chat.rs
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use xserv_model::{loader, sample, sample_greedy_penalized, GptOss, ModelConfig, PagedKVCache, Qwen3, SamplingParams, BLOCK_SIZE};
|
||||||
|
use xserv_tensor::{DType, Device};
|
||||||
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
|
enum ChatModel {
|
||||||
|
Qwen3(Qwen3),
|
||||||
|
GptOss(GptOss),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatModel {
|
||||||
|
fn forward_prefill_paged(&self, tokens: &[u32], slot: usize, cache: &mut PagedKVCache) -> xserv_tensor::Tensor {
|
||||||
|
match self {
|
||||||
|
ChatModel::Qwen3(m) => m.forward_prefill_paged(tokens, slot, cache),
|
||||||
|
ChatModel::GptOss(m) => m.forward_prefill_paged(tokens, slot, cache),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn forward_decode_paged(&self, tokens: &[u32], positions: &[usize], slots: &[usize], cache: &mut PagedKVCache) -> xserv_tensor::Tensor {
|
||||||
|
match self {
|
||||||
|
ChatModel::Qwen3(m) => m.forward_decode_paged(tokens, positions, slots, cache),
|
||||||
|
ChatModel::GptOss(m) => m.forward_decode_paged(tokens, positions, slots, cache),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TP worker infrastructure (reused from tp_engine pattern)
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum TpCommand {
|
||||||
|
Register(usize),
|
||||||
|
Free(usize),
|
||||||
|
Prefill { tokens: Vec<u32>, slot: usize },
|
||||||
|
Decode { tokens: Vec<u32>, positions: Vec<usize>, slots: Vec<usize> },
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TpHandle {
|
||||||
|
cmd_txs: Vec<mpsc::Sender<TpCommand>>,
|
||||||
|
ack_rx: mpsc::Receiver<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TpHandle {
|
||||||
|
fn send(&self, cmd: TpCommand) {
|
||||||
|
for tx in &self.cmd_txs {
|
||||||
|
tx.send(cmd.clone()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn wait(&self) {
|
||||||
|
for _ in 0..self.cmd_txs.len() {
|
||||||
|
self.ack_rx.recv().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tp_worker_loop(
|
||||||
|
rank: usize, world: usize,
|
||||||
|
id: xserv_distributed::UniqueId,
|
||||||
|
model_dir: std::path::PathBuf,
|
||||||
|
config: ModelConfig,
|
||||||
|
max_seq_len: usize,
|
||||||
|
cmd_rx: mpsc::Receiver<TpCommand>,
|
||||||
|
ack_tx: mpsc::Sender<()>,
|
||||||
|
) {
|
||||||
|
let tp = Arc::new(xserv_distributed::TpContext::init(rank, world, id, rank as u32));
|
||||||
|
let weights = loader::load_model_dir(&model_dir, Device::Cpu);
|
||||||
|
let model = if config.is_moe() {
|
||||||
|
ChatModel::GptOss(GptOss::from_weights_tp(config.clone(), weights, rank, world, rank as u32, Some(tp)))
|
||||||
|
} else {
|
||||||
|
ChatModel::Qwen3(Qwen3::from_weights_tp(config.clone(), weights, rank, world, rank as u32, Some(tp)))
|
||||||
|
};
|
||||||
|
let local_kv = config.num_kv_heads() / world;
|
||||||
|
let max_blocks_per_seq = (max_seq_len + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
let total_blocks = max_blocks_per_seq + 8;
|
||||||
|
let mut cache = PagedKVCache::new_tp(
|
||||||
|
&config, local_kv, total_blocks, 0, 1, max_blocks_per_seq, DType::BF16, rank as u32,
|
||||||
|
);
|
||||||
|
while let Ok(cmd) = cmd_rx.recv() {
|
||||||
|
match cmd {
|
||||||
|
TpCommand::Register(slot) => { let _ = cache.register_sequence(slot); }
|
||||||
|
TpCommand::Free(slot) => cache.free_sequence(slot),
|
||||||
|
TpCommand::Prefill { tokens, slot } => {
|
||||||
|
let _ = model.forward_prefill_paged(&tokens, slot, &mut cache);
|
||||||
|
}
|
||||||
|
TpCommand::Decode { tokens, positions, slots } => {
|
||||||
|
let _ = model.forward_decode_paged(&tokens, &positions, &slots, &mut cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = ack_tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT: usize = 0;
|
||||||
|
|
||||||
|
struct CliOptions {
|
||||||
|
model_dir: PathBuf,
|
||||||
|
max_tokens: usize,
|
||||||
|
max_seq_len: usize,
|
||||||
|
tp: usize,
|
||||||
|
sampling: SamplingParams,
|
||||||
|
system_prompt: Option<String>,
|
||||||
|
enable_thinking: bool,
|
||||||
|
color: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Finish {
|
||||||
|
Stop { token_id: u32 },
|
||||||
|
Length,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Line {
|
||||||
|
Text(String),
|
||||||
|
Eof,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII terminal raw-mode guard. Disables canonical mode + echo (keeps output
|
||||||
|
/// post-processing and signals), so we read keystrokes ourselves and edit the
|
||||||
|
/// line UTF-8-aware. Restores the original termios on drop.
|
||||||
|
struct RawMode {
|
||||||
|
orig: libc::termios,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawMode {
|
||||||
|
fn enable() -> Option<Self> {
|
||||||
|
unsafe {
|
||||||
|
let mut orig: libc::termios = std::mem::zeroed();
|
||||||
|
if libc::tcgetattr(libc::STDIN_FILENO, &mut orig) != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut raw = orig;
|
||||||
|
raw.c_lflag &= !(libc::ICANON | libc::ECHO);
|
||||||
|
raw.c_cc[libc::VMIN as usize] = 1;
|
||||||
|
raw.c_cc[libc::VTIME as usize] = 0;
|
||||||
|
if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(RawMode { orig })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RawMode {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.orig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one line with UTF-8/CJK-aware editing. In a TTY this enters raw mode and
|
||||||
|
/// handles keystrokes so Backspace deletes a whole character (not a byte), and
|
||||||
|
/// multi-byte input (汉字/日本語/한글) renders correctly. Non-TTY (piped) input
|
||||||
|
/// falls back to a plain cooked read.
|
||||||
|
fn read_line_edited(prompt: &str) -> Line {
|
||||||
|
let cooked = || -> Line {
|
||||||
|
print!("{prompt}");
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
let mut s = String::new();
|
||||||
|
match io::stdin().read_line(&mut s) {
|
||||||
|
Ok(0) | Err(_) => Line::Eof,
|
||||||
|
Ok(_) => Line::Text(s),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !io::stdin().is_terminal() {
|
||||||
|
return cooked();
|
||||||
|
}
|
||||||
|
let Some(_raw) = RawMode::enable() else {
|
||||||
|
return cooked();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single-line editor: on every edit, rewrite the whole line so the terminal
|
||||||
|
// renders correct (incl. double-width CJK) glyphs; \x1b[K clears leftovers.
|
||||||
|
let redraw = |buf: &str| {
|
||||||
|
print!("\r{prompt}{buf}\x1b[K");
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf = String::new();
|
||||||
|
redraw(&buf);
|
||||||
|
let mut stdin = io::stdin().lock();
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if stdin.read(&mut byte).unwrap_or(0) == 0 {
|
||||||
|
// EOF on the stream.
|
||||||
|
if buf.is_empty() {
|
||||||
|
return Line::Eof;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match byte[0] {
|
||||||
|
b'\r' | b'\n' => {
|
||||||
|
println!();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
0x7f | 0x08 => {
|
||||||
|
// Backspace: drop one whole char (String::pop is char-aware).
|
||||||
|
buf.pop();
|
||||||
|
redraw(&buf);
|
||||||
|
}
|
||||||
|
0x04 => {
|
||||||
|
// Ctrl-D: EOF only when the line is empty.
|
||||||
|
if buf.is_empty() {
|
||||||
|
return Line::Eof;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0x1b => {
|
||||||
|
// Escape sequence (arrows, etc.): consume and ignore the 2 bytes
|
||||||
|
// of a typical CSI sequence so they don't land in the buffer.
|
||||||
|
let mut seq = [0u8; 2];
|
||||||
|
let _ = stdin.read(&mut seq);
|
||||||
|
}
|
||||||
|
b if b < 0x20 => { /* other control bytes: ignore */ }
|
||||||
|
b if b < 0x80 => {
|
||||||
|
buf.push(b as char);
|
||||||
|
redraw(&buf);
|
||||||
|
}
|
||||||
|
b => {
|
||||||
|
// UTF-8 multi-byte: read the continuation bytes for this char.
|
||||||
|
let extra = if b >= 0xF0 { 3 } else if b >= 0xE0 { 2 } else { 1 };
|
||||||
|
let mut bytes = vec![b];
|
||||||
|
let mut cont = [0u8; 1];
|
||||||
|
let mut ok = true;
|
||||||
|
for _ in 0..extra {
|
||||||
|
if stdin.read(&mut cont).unwrap_or(0) == 0 {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bytes.push(cont[0]);
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if let Ok(s) = std::str::from_utf8(&bytes) {
|
||||||
|
buf.push_str(s);
|
||||||
|
redraw(&buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Line::Text(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let opts = parse_args();
|
||||||
|
|
||||||
|
xserv_cuda::device::set_device(0).unwrap();
|
||||||
|
let info = xserv_cuda::device::device_info(0).unwrap();
|
||||||
|
eprintln!(
|
||||||
|
"GPU: {} ({} MB free)",
|
||||||
|
info.name,
|
||||||
|
info.free_memory / 1024 / 1024
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = ModelConfig::from_file(&opts.model_dir.join("config.json"));
|
||||||
|
let model_type = config.model_type.as_deref().unwrap_or("unknown");
|
||||||
|
let is_moe = config.is_moe();
|
||||||
|
|
||||||
|
let max_seq_len = opts.max_seq_len.min(config.max_seq_len()).max(1);
|
||||||
|
eprintln!(
|
||||||
|
"Model: {model_type}{}, layers={}, hidden={}, heads={}/{} kv, vocab={}, max_seq_len={}",
|
||||||
|
if is_moe { " (MoE)" } else { "" },
|
||||||
|
config.num_layers(),
|
||||||
|
config.hidden(),
|
||||||
|
config.num_heads(),
|
||||||
|
config.num_kv_heads(),
|
||||||
|
config.vocab_size,
|
||||||
|
max_seq_len
|
||||||
|
);
|
||||||
|
|
||||||
|
let world = opts.tp;
|
||||||
|
if world > 1 {
|
||||||
|
assert!(
|
||||||
|
config.num_kv_heads() % world == 0,
|
||||||
|
"num_kv_heads {} not divisible by tp {world}", config.num_kv_heads()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (model, mut cache, tp_handle) = if world > 1 {
|
||||||
|
let id = xserv_distributed::get_unique_id();
|
||||||
|
let (ack_tx, ack_rx) = mpsc::channel::<()>();
|
||||||
|
let mut cmd_txs = Vec::new();
|
||||||
|
for rank in 1..world {
|
||||||
|
let (ctx_tx, ctx_rx) = mpsc::channel::<TpCommand>();
|
||||||
|
cmd_txs.push(ctx_tx);
|
||||||
|
let ack_tx = ack_tx.clone();
|
||||||
|
let model_dir = opts.model_dir.clone();
|
||||||
|
let config = config.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
tp_worker_loop(rank, world, id, model_dir, config, max_seq_len, ctx_rx, ack_tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
eprintln!("Loading weights (tp={world})...");
|
||||||
|
let tp = Arc::new(xserv_distributed::TpContext::init(0, world, id, 0));
|
||||||
|
let weights = loader::load_model_dir(&opts.model_dir, Device::Cpu);
|
||||||
|
eprintln!("Loaded {} tensors", weights.len());
|
||||||
|
let m = if is_moe {
|
||||||
|
ChatModel::GptOss(GptOss::from_weights_tp(config.clone(), weights, 0, world, 0, Some(tp)))
|
||||||
|
} else {
|
||||||
|
ChatModel::Qwen3(Qwen3::from_weights_tp(config.clone(), weights, 0, world, 0, Some(tp)))
|
||||||
|
};
|
||||||
|
let local_kv = config.num_kv_heads() / world;
|
||||||
|
let max_blocks_per_seq = (max_seq_len + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
let total_blocks = max_blocks_per_seq + 8;
|
||||||
|
let c = PagedKVCache::new_tp(&config, local_kv, total_blocks, 0, 1, max_blocks_per_seq, DType::BF16, 0);
|
||||||
|
let h = TpHandle { cmd_txs, ack_rx };
|
||||||
|
(m, c, Some(h))
|
||||||
|
} else {
|
||||||
|
eprintln!("Loading weights...");
|
||||||
|
let weights = loader::load_model_dir(&opts.model_dir, Device::Cuda(0));
|
||||||
|
eprintln!("Loaded {} tensors", weights.len());
|
||||||
|
let m = if is_moe {
|
||||||
|
ChatModel::GptOss(GptOss::from_weights(config.clone(), weights))
|
||||||
|
} else {
|
||||||
|
ChatModel::Qwen3(Qwen3::from_weights(config.clone(), weights))
|
||||||
|
};
|
||||||
|
let c = new_paged_cache(&config, max_seq_len);
|
||||||
|
(m, c, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let tokenizer = Tokenizer::from_file(&opts.model_dir.join("tokenizer.json"));
|
||||||
|
if let Some(h) = &tp_handle { h.send(TpCommand::Register(SLOT)); h.wait(); }
|
||||||
|
cache.register_sequence(SLOT).expect("register chat slot");
|
||||||
|
let use_color = opts.color && io::stdout().is_terminal();
|
||||||
|
|
||||||
|
eprintln!("Ready (paged KV cache, tp={world}).");
|
||||||
|
eprintln!("Commands: /exit, /quit, /clear\n");
|
||||||
|
|
||||||
|
// gpt-oss multi-turn history of (user, assistant-final) text. Harmony
|
||||||
|
// requires re-rendering the conversation each turn with prior analysis
|
||||||
|
// dropped, so the moe path re-prefills from this rather than reusing an
|
||||||
|
// incremental KV cache (which would accumulate CoT + <|return|> and collapse
|
||||||
|
// at longer context). Qwen3 ignores this and keeps the incremental cache.
|
||||||
|
let mut moe_history: Vec<(String, String)> = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let line = match read_line_edited("user> ") {
|
||||||
|
Line::Eof => break,
|
||||||
|
Line::Text(s) => s,
|
||||||
|
};
|
||||||
|
let input = line.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match input {
|
||||||
|
"/exit" | "/quit" | "exit" | "quit" => break,
|
||||||
|
"/clear" => {
|
||||||
|
reset_slot(&mut cache, &tp_handle);
|
||||||
|
moe_history.clear();
|
||||||
|
eprintln!("history and KV cache cleared");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
"/help" => {
|
||||||
|
print_help();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_moe {
|
||||||
|
// Harmony multi-turn: re-render the whole conversation (prior
|
||||||
|
// analysis dropped) and re-prefill into a freshly cleared slot.
|
||||||
|
let prompt = build_conversation_gpt_oss(
|
||||||
|
opts.system_prompt.as_deref(),
|
||||||
|
&moe_history,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
let prompt_tokens = tokenizer.encode(&prompt);
|
||||||
|
if prompt_tokens.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if prompt_tokens.len() >= max_seq_len {
|
||||||
|
eprintln!(
|
||||||
|
"context full: conversation needs {} tokens >= max_seq_len {max_seq_len}; use /clear",
|
||||||
|
prompt_tokens.len()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let max_new_tokens = opts.max_tokens.min(max_seq_len - prompt_tokens.len());
|
||||||
|
reset_slot(&mut cache, &tp_handle);
|
||||||
|
print!("assistant> ");
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
let (_finish, answer) = generate_with_paged_cache(
|
||||||
|
&model, &mut cache, &tokenizer, &prompt_tokens, &opts.sampling,
|
||||||
|
max_new_tokens, use_color, &tp_handle, is_moe, opts.enable_thinking,
|
||||||
|
);
|
||||||
|
moe_history.push((input.to_string(), answer));
|
||||||
|
println!();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qwen3: incremental KV cache — only the new turn is prefilled and the
|
||||||
|
// assistant's tokens stay cached for the next turn.
|
||||||
|
let include_system = cache.seq_len(SLOT) == 0;
|
||||||
|
let prompt = build_turn_prompt(
|
||||||
|
opts.system_prompt.as_deref(),
|
||||||
|
include_system,
|
||||||
|
input,
|
||||||
|
opts.enable_thinking,
|
||||||
|
);
|
||||||
|
let prompt_tokens = tokenizer.encode(&prompt);
|
||||||
|
if prompt_tokens.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let used = cache.seq_len(SLOT);
|
||||||
|
let remaining = max_seq_len.saturating_sub(used);
|
||||||
|
if prompt_tokens.len() >= remaining {
|
||||||
|
eprintln!(
|
||||||
|
"context full: {used}/{max_seq_len} tokens used, new turn needs {} tokens; use /clear",
|
||||||
|
prompt_tokens.len()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let max_new_tokens = opts.max_tokens.min(remaining - prompt_tokens.len());
|
||||||
|
|
||||||
|
print!("assistant> ");
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
let (finish, _answer) = generate_with_paged_cache(
|
||||||
|
&model,
|
||||||
|
&mut cache,
|
||||||
|
&tokenizer,
|
||||||
|
&prompt_tokens,
|
||||||
|
&opts.sampling,
|
||||||
|
max_new_tokens,
|
||||||
|
use_color,
|
||||||
|
&tp_handle,
|
||||||
|
is_moe,
|
||||||
|
opts.enable_thinking,
|
||||||
|
);
|
||||||
|
match finish {
|
||||||
|
Finish::Stop { token_id } => {
|
||||||
|
append_after_stop(&model, &mut cache, &tokenizer, max_seq_len, token_id, &tp_handle);
|
||||||
|
}
|
||||||
|
Finish::Length => {
|
||||||
|
append_text_to_cache(&model, &mut cache, &tokenizer, max_seq_len, "<|im_end|>\n", &tp_handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free and re-register the single chat KV slot (clears all cached context).
|
||||||
|
fn reset_slot(cache: &mut PagedKVCache, tp: &Option<TpHandle>) {
|
||||||
|
if let Some(h) = tp { h.send(TpCommand::Free(SLOT)); h.wait(); }
|
||||||
|
cache.free_sequence(SLOT);
|
||||||
|
if let Some(h) = tp { h.send(TpCommand::Register(SLOT)); h.wait(); }
|
||||||
|
cache.register_sequence(SLOT).expect("register chat slot");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> CliOptions {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
if args.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") {
|
||||||
|
print_usage_and_exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut model_dir = None;
|
||||||
|
let mut max_tokens = 256usize;
|
||||||
|
let mut max_seq_len = 2048usize;
|
||||||
|
let mut tp = 1usize;
|
||||||
|
let mut temperature = 0.0f32;
|
||||||
|
let mut top_k = 0usize;
|
||||||
|
let mut top_p = 1.0f32;
|
||||||
|
let mut system_prompt = None;
|
||||||
|
let mut enable_thinking = false;
|
||||||
|
let mut color = true;
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"-m" | "--model" => {
|
||||||
|
i += 1;
|
||||||
|
model_dir = args.get(i).map(PathBuf::from);
|
||||||
|
}
|
||||||
|
"--max-tokens" => {
|
||||||
|
i += 1;
|
||||||
|
max_tokens = parse_value(&args, i, "--max-tokens");
|
||||||
|
}
|
||||||
|
"--max-seq-len" => {
|
||||||
|
i += 1;
|
||||||
|
max_seq_len = parse_value(&args, i, "--max-seq-len");
|
||||||
|
}
|
||||||
|
"--tp" => {
|
||||||
|
i += 1;
|
||||||
|
tp = parse_value(&args, i, "--tp");
|
||||||
|
}
|
||||||
|
"--temperature" => {
|
||||||
|
i += 1;
|
||||||
|
temperature = parse_value(&args, i, "--temperature");
|
||||||
|
}
|
||||||
|
"--top-k" => {
|
||||||
|
i += 1;
|
||||||
|
top_k = parse_value(&args, i, "--top-k");
|
||||||
|
}
|
||||||
|
"--top-p" => {
|
||||||
|
i += 1;
|
||||||
|
top_p = parse_value(&args, i, "--top-p");
|
||||||
|
}
|
||||||
|
"--system" => {
|
||||||
|
i += 1;
|
||||||
|
system_prompt = args.get(i).cloned();
|
||||||
|
if system_prompt.is_none() {
|
||||||
|
eprintln!("missing value for --system");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--think" => {
|
||||||
|
enable_thinking = true;
|
||||||
|
}
|
||||||
|
"--no-color" => {
|
||||||
|
color = false;
|
||||||
|
}
|
||||||
|
arg if arg.starts_with('-') => {
|
||||||
|
eprintln!("unknown option: {arg}");
|
||||||
|
print_usage_and_exit(2);
|
||||||
|
}
|
||||||
|
arg => {
|
||||||
|
if model_dir.is_some() {
|
||||||
|
eprintln!("unexpected extra argument: {arg}");
|
||||||
|
print_usage_and_exit(2);
|
||||||
|
}
|
||||||
|
model_dir = Some(PathBuf::from(arg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
CliOptions {
|
||||||
|
model_dir: model_dir.unwrap_or_else(|| {
|
||||||
|
eprintln!("missing model directory");
|
||||||
|
print_usage_and_exit(2);
|
||||||
|
}),
|
||||||
|
max_tokens: max_tokens.max(1),
|
||||||
|
max_seq_len: max_seq_len.max(1),
|
||||||
|
tp: tp.max(1),
|
||||||
|
sampling: SamplingParams {
|
||||||
|
temperature,
|
||||||
|
top_k,
|
||||||
|
top_p,
|
||||||
|
},
|
||||||
|
system_prompt,
|
||||||
|
enable_thinking,
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_value<T: std::str::FromStr>(args: &[String], i: usize, name: &str) -> T {
|
||||||
|
args.get(i).and_then(|s| s.parse().ok()).unwrap_or_else(|| {
|
||||||
|
eprintln!("invalid or missing value for {name}");
|
||||||
|
std::process::exit(2);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_usage_and_exit(code: i32) -> ! {
|
||||||
|
eprintln!(
|
||||||
|
"Usage: xserv-chat <model-dir> [options]\n\
|
||||||
|
\n\
|
||||||
|
Options:\n\
|
||||||
|
\t-m, --model DIR Model directory\n\
|
||||||
|
\t--max-tokens N Max generated tokens per turn (default: 256)\n\
|
||||||
|
\t--max-seq-len N Persistent KV context length (default: 2048)\n\
|
||||||
|
\t--tp N Tensor parallelism degree (default: 1)\n\
|
||||||
|
\t--temperature F Sampling temperature, 0 = greedy (default: 0)\n\
|
||||||
|
\t--top-k N Top-k sampling, 0 = disabled (default: 0)\n\
|
||||||
|
\t--top-p F Top-p sampling (default: 1.0)\n\
|
||||||
|
\t--system TEXT System prompt for the first turn after start or /clear\n\
|
||||||
|
\t--think Let Qwen3 emit thinking; rendered gray on terminals\n\
|
||||||
|
\t--no-color Disable ANSI color for thinking output\n\
|
||||||
|
\t-h, --help Show this help"
|
||||||
|
);
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
eprintln!("Commands:");
|
||||||
|
eprintln!(" /clear clear chat history and free/recreate the paged KV slot");
|
||||||
|
eprintln!(" /exit quit");
|
||||||
|
eprintln!(" /quit quit");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_paged_cache(config: &ModelConfig, max_seq_len: usize) -> PagedKVCache {
|
||||||
|
let max_blocks_per_seq = (max_seq_len + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
let total_blocks = (max_blocks_per_seq + 1).max(2);
|
||||||
|
// Single-slot interactive CLI: no swap pool (cpu_total_blocks = 0).
|
||||||
|
PagedKVCache::new(config, total_blocks, 0, 1, max_blocks_per_seq, DType::BF16, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_turn_prompt(
|
||||||
|
system: Option<&str>,
|
||||||
|
include_system: bool,
|
||||||
|
user_input: &str,
|
||||||
|
enable_thinking: bool,
|
||||||
|
) -> String {
|
||||||
|
let mut prompt = String::new();
|
||||||
|
if include_system {
|
||||||
|
if let Some(system) = system {
|
||||||
|
if !system.trim().is_empty() {
|
||||||
|
prompt.push_str("<|im_start|>system\n");
|
||||||
|
prompt.push_str(system.trim());
|
||||||
|
prompt.push_str("<|im_end|>\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompt.push_str("<|im_start|>user\n");
|
||||||
|
prompt.push_str(user_input);
|
||||||
|
prompt.push_str("<|im_end|>\n");
|
||||||
|
prompt.push_str("<|im_start|>assistant\n");
|
||||||
|
if !enable_thinking {
|
||||||
|
prompt.push_str("<think>\n\n</think>\n\n");
|
||||||
|
}
|
||||||
|
prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the full gpt-oss harmony conversation for re-prefill. gpt-oss was
|
||||||
|
/// trained on this exact system-message structure (identity / knowledge cutoff
|
||||||
|
/// / current date / Reasoning level / channels — see the model's
|
||||||
|
/// chat_template.jinja `build_system_message`). A hand-rolled substitute puts
|
||||||
|
/// the model out of distribution and destabilizes channel selection.
|
||||||
|
///
|
||||||
|
/// Harmony multi-turn drops prior chain-of-thought: past assistant messages are
|
||||||
|
/// rendered as completed `final` channels ending in `<|end|>` (not the
|
||||||
|
/// `<|return|>` stop token). Keeping the analysis + `<|return|>` of every turn
|
||||||
|
/// in context — as an incremental KV cache does — is out of distribution and
|
||||||
|
/// makes the model collapse at longer context. "Reasoning: low" keeps the
|
||||||
|
/// analysis channel short for an interactive chat.
|
||||||
|
fn build_conversation_gpt_oss(
|
||||||
|
system: Option<&str>,
|
||||||
|
history: &[(String, String)],
|
||||||
|
current_user: &str,
|
||||||
|
) -> String {
|
||||||
|
let mut prompt = String::new();
|
||||||
|
prompt.push_str("<|start|>system<|message|>");
|
||||||
|
prompt.push_str("You are ChatGPT, a large language model trained by OpenAI.\n");
|
||||||
|
prompt.push_str("Knowledge cutoff: 2024-06\n");
|
||||||
|
prompt.push_str(&format!("Current date: {}\n\n", today_ymd()));
|
||||||
|
prompt.push_str("Reasoning: low\n\n");
|
||||||
|
prompt.push_str("# Valid channels: analysis, commentary, final. Channel must be included for every message.");
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
if let Some(sys) = system {
|
||||||
|
if !sys.trim().is_empty() {
|
||||||
|
prompt.push_str("<|start|>developer<|message|># Instructions\n\n");
|
||||||
|
prompt.push_str(sys.trim());
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (user, assistant) in history {
|
||||||
|
prompt.push_str("<|start|>user<|message|>");
|
||||||
|
prompt.push_str(user);
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
prompt.push_str("<|start|>assistant<|channel|>final<|message|>");
|
||||||
|
prompt.push_str(assistant.trim());
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
}
|
||||||
|
prompt.push_str("<|start|>user<|message|>");
|
||||||
|
prompt.push_str(current_user);
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
prompt.push_str("<|start|>assistant");
|
||||||
|
prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current UTC date as "YYYY-MM-DD" for the harmony system message. Rata Die
|
||||||
|
/// civil-calendar conversion (same algorithm the server uses for strftime_now).
|
||||||
|
fn today_ymd() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||||
|
let z = (secs / 86400) as i64 + 719468;
|
||||||
|
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
|
||||||
|
let doe = z - era * 146097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let y = if m <= 2 { y + 1 } else { y };
|
||||||
|
format!("{y:04}-{m:02}-{d:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_with_paged_cache(
|
||||||
|
model: &ChatModel,
|
||||||
|
cache: &mut PagedKVCache,
|
||||||
|
tokenizer: &Tokenizer,
|
||||||
|
prompt_tokens: &[u32],
|
||||||
|
sampling: &SamplingParams,
|
||||||
|
max_tokens: usize,
|
||||||
|
use_color: bool,
|
||||||
|
tp: &Option<TpHandle>,
|
||||||
|
is_moe: bool,
|
||||||
|
enable_thinking: bool,
|
||||||
|
) -> (Finish, String) {
|
||||||
|
let harmony_end_id = if is_moe { tokenizer.special_token_id("<|end|>") } else { None };
|
||||||
|
let harmony_channel_id = if is_moe { tokenizer.special_token_id("<|channel|>") } else { None };
|
||||||
|
let harmony_message_id = if is_moe { tokenizer.special_token_id("<|message|>") } else { None };
|
||||||
|
let harmony_special: Vec<u32> = if is_moe {
|
||||||
|
["<|channel|>", "<|start|>", "<|end|>", "<|message|>", "<|return|>"]
|
||||||
|
.iter().filter_map(|s| tokenizer.special_token_id(s)).collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
// Harmony channel state: "final" channel text is printed normally,
|
||||||
|
// "analysis" channel is rendered as thinking (gray). After <|channel|>
|
||||||
|
// we read the channel name tokens until <|message|>.
|
||||||
|
#[derive(PartialEq, Clone, Copy)]
|
||||||
|
enum HarmonyState { Normal, ReadingChannel, InAnalysis, InFinal }
|
||||||
|
let mut hstate = if is_moe { HarmonyState::InFinal } else { HarmonyState::Normal };
|
||||||
|
|
||||||
|
// Off by default. A repetition penalty over a harmony stream penalizes the
|
||||||
|
// control tokens (<|channel|>, <|message|>, <|start|>) that MUST repeat to
|
||||||
|
// open the final channel — so a non-1.0 default makes gpt-oss stop right
|
||||||
|
// after the analysis block, before emitting any answer. Opt in via the env
|
||||||
|
// var if you want it for plain (non-harmony) generation.
|
||||||
|
let rep_penalty: f32 = std::env::var("XSERV_REP_PENALTY").ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
let rep_window: usize = std::env::var("XSERV_REP_WINDOW").ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(512);
|
||||||
|
let mut history: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
|
let pick = |logits: &xserv_tensor::Tensor, sp: &SamplingParams, hist: &[u32]| -> u32 {
|
||||||
|
if rep_penalty > 1.0 && sp.temperature == 0.0 {
|
||||||
|
let start = hist.len().saturating_sub(rep_window);
|
||||||
|
sample_greedy_penalized(logits, &hist[start..], rep_penalty)
|
||||||
|
} else {
|
||||||
|
sample(logits, sp)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(h) = tp { h.send(TpCommand::Prefill { tokens: prompt_tokens.to_vec(), slot: SLOT }); }
|
||||||
|
let logits = model.forward_prefill_paged(prompt_tokens, SLOT, cache);
|
||||||
|
if let Some(h) = tp { h.wait(); }
|
||||||
|
let mut next = pick(&logits, sampling, &history);
|
||||||
|
let mut decode_buffer = Vec::new();
|
||||||
|
let mut in_thinking = false;
|
||||||
|
let show_thinking = is_moe && enable_thinking;
|
||||||
|
// Visible answer tokens, returned for multi-turn history. For moe this is
|
||||||
|
// the final-channel content only (analysis is suppressed/gray); for Qwen3
|
||||||
|
// it is everything printed. The caller decodes these into the assistant
|
||||||
|
// message it re-renders into the next prompt.
|
||||||
|
let mut answer_ids: Vec<u32> = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..max_tokens {
|
||||||
|
let position = cache.seq_len(SLOT);
|
||||||
|
if let Some(h) = tp { h.send(TpCommand::Decode { tokens: vec![next], positions: vec![position], slots: vec![SLOT] }); }
|
||||||
|
let logits = model.forward_decode_paged(&[next], &[position], &[SLOT], cache);
|
||||||
|
if let Some(h) = tp { h.wait(); }
|
||||||
|
if tokenizer.is_eos(next) {
|
||||||
|
print_stream_text(
|
||||||
|
&tokenizer.flush_decode_stream(&mut decode_buffer),
|
||||||
|
in_thinking,
|
||||||
|
use_color,
|
||||||
|
);
|
||||||
|
if show_thinking && in_thinking {
|
||||||
|
print_stream_text("\n</think>\n\n", true, use_color);
|
||||||
|
}
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
return (Finish::Stop { token_id: next }, tokenizer.decode(&answer_ids));
|
||||||
|
}
|
||||||
|
if harmony_end_id == Some(next) {
|
||||||
|
// <|end|> closes current segment; if in final channel, we're done
|
||||||
|
print_stream_text(
|
||||||
|
&tokenizer.flush_decode_stream(&mut decode_buffer),
|
||||||
|
in_thinking,
|
||||||
|
use_color,
|
||||||
|
);
|
||||||
|
if hstate == HarmonyState::InFinal {
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
return (Finish::Stop { token_id: next }, tokenizer.decode(&answer_ids));
|
||||||
|
}
|
||||||
|
// Closing a thinking (analysis/commentary) channel: emit the </think>
|
||||||
|
// marker so it renders like Qwen3's thinking block.
|
||||||
|
if show_thinking && hstate == HarmonyState::InAnalysis {
|
||||||
|
print_stream_text("\n</think>\n\n", true, use_color);
|
||||||
|
in_thinking = false;
|
||||||
|
}
|
||||||
|
hstate = HarmonyState::Normal;
|
||||||
|
next = pick(&logits, sampling, &history);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push(next);
|
||||||
|
|
||||||
|
// Harmony channel routing state machine
|
||||||
|
if harmony_channel_id == Some(next) {
|
||||||
|
decode_buffer.clear();
|
||||||
|
hstate = HarmonyState::ReadingChannel;
|
||||||
|
next = pick(&logits, sampling, &history);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if harmony_message_id == Some(next) {
|
||||||
|
if hstate == HarmonyState::ReadingChannel {
|
||||||
|
// Channel name was accumulated but we don't need to parse it —
|
||||||
|
// we just check via the channel_name buffer below
|
||||||
|
}
|
||||||
|
decode_buffer.clear();
|
||||||
|
next = pick(&logits, sampling, &history);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hstate == HarmonyState::ReadingChannel {
|
||||||
|
// Reading channel name tokens (e.g. "final", "analysis")
|
||||||
|
let tok_text = tokenizer.decode(&[next]);
|
||||||
|
if tok_text.contains("final") {
|
||||||
|
hstate = HarmonyState::InFinal;
|
||||||
|
in_thinking = false;
|
||||||
|
} else {
|
||||||
|
hstate = HarmonyState::InAnalysis;
|
||||||
|
// Open a Qwen3-style thinking block for the analysis channel.
|
||||||
|
if show_thinking {
|
||||||
|
print_stream_text("<think>\n", true, use_color);
|
||||||
|
in_thinking = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next = pick(&logits, sampling, &history);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if harmony_special.contains(&next) {
|
||||||
|
next = pick(&logits, sampling, &history);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if hstate == HarmonyState::InAnalysis {
|
||||||
|
// Analysis channel = the model's reasoning. With --think, show it as a
|
||||||
|
// thinking block (gray if color); otherwise suppress it (answer only).
|
||||||
|
if show_thinking {
|
||||||
|
print_generated_token(tokenizer, next, &mut decode_buffer, &mut in_thinking, use_color);
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
}
|
||||||
|
next = pick(&logits, sampling, &history);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_moe && hstate != HarmonyState::InFinal {
|
||||||
|
// Between harmony messages (after a channel's <|end|>, before the
|
||||||
|
// next <|channel|>): the model emits a role header like "assistant".
|
||||||
|
// That's structural, not user-visible content — suppress it. Only
|
||||||
|
// for moe/harmony; non-moe (Qwen3) stays in Normal and prints here.
|
||||||
|
next = pick(&logits, sampling, &history);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
answer_ids.push(next);
|
||||||
|
print_generated_token(
|
||||||
|
tokenizer,
|
||||||
|
next,
|
||||||
|
&mut decode_buffer,
|
||||||
|
&mut in_thinking,
|
||||||
|
use_color,
|
||||||
|
);
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
next = pick(&logits, sampling, &history);
|
||||||
|
}
|
||||||
|
|
||||||
|
print_stream_text(
|
||||||
|
&tokenizer.flush_decode_stream(&mut decode_buffer),
|
||||||
|
in_thinking,
|
||||||
|
use_color,
|
||||||
|
);
|
||||||
|
if show_thinking && in_thinking {
|
||||||
|
print_stream_text("\n</think>\n\n", true, use_color);
|
||||||
|
}
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
(Finish::Length, tokenizer.decode(&answer_ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_after_stop(
|
||||||
|
model: &ChatModel,
|
||||||
|
cache: &mut PagedKVCache,
|
||||||
|
tokenizer: &Tokenizer,
|
||||||
|
max_seq_len: usize,
|
||||||
|
_stop_token_id: u32,
|
||||||
|
tp: &Option<TpHandle>,
|
||||||
|
) {
|
||||||
|
append_text_to_cache(model, cache, tokenizer, max_seq_len, "\n", tp);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_text_to_cache(
|
||||||
|
model: &ChatModel,
|
||||||
|
cache: &mut PagedKVCache,
|
||||||
|
tokenizer: &Tokenizer,
|
||||||
|
max_seq_len: usize,
|
||||||
|
text: &str,
|
||||||
|
tp: &Option<TpHandle>,
|
||||||
|
) {
|
||||||
|
let tokens = tokenizer.encode(text);
|
||||||
|
if tokens.is_empty() || cache.seq_len(SLOT) + tokens.len() > max_seq_len {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(h) = tp { h.send(TpCommand::Prefill { tokens: tokens.clone(), slot: SLOT }); }
|
||||||
|
let _ = model.forward_prefill_paged(&tokens, SLOT, cache);
|
||||||
|
if let Some(h) = tp { h.wait(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_generated_token(
|
||||||
|
tokenizer: &Tokenizer,
|
||||||
|
token_id: u32,
|
||||||
|
decode_buffer: &mut Vec<u8>,
|
||||||
|
in_thinking: &mut bool,
|
||||||
|
use_color: bool,
|
||||||
|
) {
|
||||||
|
if tokenizer.special_token_id("<think>") == Some(token_id) {
|
||||||
|
print_stream_text(
|
||||||
|
&tokenizer.flush_decode_stream(decode_buffer),
|
||||||
|
*in_thinking,
|
||||||
|
use_color,
|
||||||
|
);
|
||||||
|
*in_thinking = true;
|
||||||
|
print_stream_text("<think>", true, use_color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenizer.special_token_id("</think>") == Some(token_id) {
|
||||||
|
print_stream_text(
|
||||||
|
&tokenizer.flush_decode_stream(decode_buffer),
|
||||||
|
*in_thinking,
|
||||||
|
use_color,
|
||||||
|
);
|
||||||
|
print_stream_text("</think>", true, use_color);
|
||||||
|
*in_thinking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = tokenizer.decode_token_stream(token_id, decode_buffer);
|
||||||
|
print_stream_text(&text, *in_thinking, use_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_stream_text(text: &str, in_thinking: bool, use_color: bool) {
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if in_thinking && use_color {
|
||||||
|
print!("\x1b[90m{text}\x1b[0m");
|
||||||
|
} else {
|
||||||
|
print!("{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use xserv_model::{loader, KVCache, ModelConfig};
|
use xserv_model::{loader, KVCache, ModelConfig, PagedKVCache, BLOCK_SIZE};
|
||||||
use xserv_tensor::{DType, Device};
|
use xserv_tensor::{DType, Device};
|
||||||
use xserv_tokenizer::Tokenizer;
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
@@ -36,14 +36,18 @@ fn main() {
|
|||||||
eprintln!("Loaded {} tensors", weights.len());
|
eprintln!("Loaded {} tensors", weights.len());
|
||||||
|
|
||||||
let is_qwen3 = model_type.contains("qwen");
|
let is_qwen3 = model_type.contains("qwen");
|
||||||
let dtype = if is_qwen3 { DType::BF16 } else { DType::F32 };
|
let is_gpt_oss = model_type.contains("gpt_oss");
|
||||||
|
let dtype = if is_qwen3 || is_gpt_oss { DType::BF16 } else { DType::F32 };
|
||||||
|
|
||||||
// Build model
|
// Build model
|
||||||
enum Model {
|
enum Model {
|
||||||
GPT2(xserv_model::GPT2),
|
GPT2(xserv_model::GPT2),
|
||||||
Qwen3(xserv_model::Qwen3),
|
Qwen3(xserv_model::Qwen3),
|
||||||
|
GptOss(xserv_model::GptOss),
|
||||||
}
|
}
|
||||||
let model = if is_qwen3 {
|
let model = if is_gpt_oss {
|
||||||
|
Model::GptOss(xserv_model::GptOss::from_weights(config.clone(), weights))
|
||||||
|
} else if is_qwen3 {
|
||||||
Model::Qwen3(xserv_model::Qwen3::from_weights(config.clone(), weights))
|
Model::Qwen3(xserv_model::Qwen3::from_weights(config.clone(), weights))
|
||||||
} else {
|
} else {
|
||||||
Model::GPT2(xserv_model::GPT2::from_weights(config.clone(), weights))
|
Model::GPT2(xserv_model::GPT2::from_weights(config.clone(), weights))
|
||||||
@@ -62,40 +66,92 @@ fn main() {
|
|||||||
if input == "quit" || input == "exit" { break; }
|
if input == "quit" || input == "exit" { break; }
|
||||||
|
|
||||||
let token_ids = tokenizer.encode(input);
|
let token_ids = tokenizer.encode(input);
|
||||||
let kv_heads = if is_qwen3 { config.num_kv_heads() } else { config.num_heads() };
|
|
||||||
let mut cache = KVCache::new(
|
|
||||||
config.num_layers(), kv_heads, config.head_dim(), dtype, Device::Cuda(0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prefill + decode
|
if is_gpt_oss {
|
||||||
let logits = match &model {
|
// GptOss uses paged KV cache
|
||||||
Model::GPT2(m) => m.forward_with_cache(&token_ids, &mut cache),
|
let max_seq = 2048;
|
||||||
Model::Qwen3(m) => m.forward_with_cache(&token_ids, &mut cache),
|
let max_blocks_per_seq = (max_seq + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
};
|
let total_blocks = max_blocks_per_seq + 64;
|
||||||
let mut next = match &model {
|
let mut paged_cache = PagedKVCache::new(
|
||||||
Model::GPT2(_) => xserv_model::gpt2::sample_greedy(&logits),
|
&config, total_blocks, 0, 4, max_blocks_per_seq, DType::BF16, 0,
|
||||||
Model::Qwen3(_) => xserv_model::qwen3::sample_greedy(&logits),
|
);
|
||||||
};
|
let slot = 0;
|
||||||
|
paged_cache.register_sequence(slot).expect("register slot");
|
||||||
|
|
||||||
print!("{input}");
|
let model = match &model { Model::GptOss(m) => m, _ => unreachable!() };
|
||||||
io::stdout().flush().unwrap();
|
let logits = model.forward_prefill_paged(&token_ids, slot, &mut paged_cache);
|
||||||
|
let mut next = sample_greedy_last(&logits);
|
||||||
|
|
||||||
for _ in 0..max_tokens {
|
print!("{input}");
|
||||||
let text = tokenizer.decode(&[next]);
|
|
||||||
print!("{text}");
|
|
||||||
io::stdout().flush().unwrap();
|
io::stdout().flush().unwrap();
|
||||||
|
|
||||||
if tokenizer.eos_token_id() == Some(next) { break; }
|
for _ in 0..max_tokens {
|
||||||
|
let text = tokenizer.decode(&[next]);
|
||||||
|
print!("{text}");
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
|
||||||
|
if tokenizer.eos_token_id() == Some(next) { break; }
|
||||||
|
|
||||||
|
let pos = paged_cache.seq_len(slot);
|
||||||
|
let logits = model.forward_decode_paged(
|
||||||
|
&[next], &[pos], &[slot], &mut paged_cache,
|
||||||
|
);
|
||||||
|
next = sample_greedy_last(&logits);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
paged_cache.free_sequence(slot);
|
||||||
|
} else {
|
||||||
|
let kv_heads = if is_qwen3 { config.num_kv_heads() } else { config.num_heads() };
|
||||||
|
let mut cache = KVCache::new(
|
||||||
|
config.num_layers(), kv_heads, config.head_dim(), dtype, Device::Cuda(0),
|
||||||
|
);
|
||||||
|
|
||||||
let logits = match &model {
|
let logits = match &model {
|
||||||
Model::GPT2(m) => m.forward_with_cache(&[next], &mut cache),
|
Model::GPT2(m) => m.forward_with_cache(&token_ids, &mut cache),
|
||||||
Model::Qwen3(m) => m.forward_with_cache(&[next], &mut cache),
|
Model::Qwen3(m) => m.forward_with_cache(&token_ids, &mut cache),
|
||||||
|
Model::GptOss(_) => unreachable!(),
|
||||||
};
|
};
|
||||||
next = match &model {
|
let mut next = match &model {
|
||||||
Model::GPT2(_) => xserv_model::gpt2::sample_greedy(&logits),
|
Model::GPT2(_) => xserv_model::gpt2::sample_greedy(&logits),
|
||||||
Model::Qwen3(_) => xserv_model::qwen3::sample_greedy(&logits),
|
Model::Qwen3(_) => xserv_model::qwen3::sample_greedy(&logits),
|
||||||
|
Model::GptOss(_) => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
print!("{input}");
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
|
||||||
|
for _ in 0..max_tokens {
|
||||||
|
let text = tokenizer.decode(&[next]);
|
||||||
|
print!("{text}");
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
|
||||||
|
if tokenizer.eos_token_id() == Some(next) { break; }
|
||||||
|
|
||||||
|
let logits = match &model {
|
||||||
|
Model::GPT2(m) => m.forward_with_cache(&[next], &mut cache),
|
||||||
|
Model::Qwen3(m) => m.forward_with_cache(&[next], &mut cache),
|
||||||
|
Model::GptOss(_) => unreachable!(),
|
||||||
|
};
|
||||||
|
next = match &model {
|
||||||
|
Model::GPT2(_) => xserv_model::gpt2::sample_greedy(&logits),
|
||||||
|
Model::Qwen3(_) => xserv_model::qwen3::sample_greedy(&logits),
|
||||||
|
Model::GptOss(_) => unreachable!(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
println!();
|
||||||
}
|
}
|
||||||
println!();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_greedy_last(logits: &xserv_tensor::Tensor) -> u32 {
|
||||||
|
use half::bf16;
|
||||||
|
assert_eq!(logits.ndim(), 2);
|
||||||
|
let logits_cpu = logits.to_device(Device::Cpu);
|
||||||
|
let vocab_size = logits.shape()[1];
|
||||||
|
let seq_len = logits.shape()[0];
|
||||||
|
let data = logits_cpu.as_slice::<bf16>();
|
||||||
|
let last = &data[(seq_len - 1) * vocab_size..seq_len * vocab_size];
|
||||||
|
last.iter().enumerate()
|
||||||
|
.max_by(|a, b| a.1.to_f32().partial_cmp(&b.1.to_f32()).unwrap())
|
||||||
|
.map(|(i, _)| i as u32).unwrap()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct RopeScaling {
|
||||||
|
pub rope_type: Option<String>,
|
||||||
|
pub factor: Option<f64>,
|
||||||
|
pub original_max_position_embeddings: Option<usize>,
|
||||||
|
pub beta_fast: Option<f64>,
|
||||||
|
pub beta_slow: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct ModelConfig {
|
pub struct ModelConfig {
|
||||||
pub architectures: Option<Vec<String>>,
|
pub architectures: Option<Vec<String>>,
|
||||||
@@ -46,6 +55,28 @@ pub struct ModelConfig {
|
|||||||
pub rope_theta: Option<f64>,
|
pub rope_theta: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tie_word_embeddings: Option<bool>,
|
pub tie_word_embeddings: Option<bool>,
|
||||||
|
|
||||||
|
// MoE (gpt-oss)
|
||||||
|
#[serde(default)]
|
||||||
|
pub num_local_experts: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub num_experts_per_tok: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layer_types: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sliding_window: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attention_bias: Option<bool>,
|
||||||
|
#[serde(default, rename = "head_dim")]
|
||||||
|
pub explicit_head_dim: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rope_scaling: Option<RopeScaling>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub swiglu_limit: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub geglu_alpha: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hidden_act: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelConfig {
|
impl ModelConfig {
|
||||||
@@ -81,7 +112,7 @@ impl ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn head_dim(&self) -> usize {
|
pub fn head_dim(&self) -> usize {
|
||||||
self.hidden() / self.num_heads()
|
self.explicit_head_dim.unwrap_or_else(|| self.hidden() / self.num_heads())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ln_eps(&self) -> f32 {
|
pub fn ln_eps(&self) -> f32 {
|
||||||
@@ -93,4 +124,32 @@ impl ModelConfig {
|
|||||||
pub fn tied_embeddings(&self) -> bool {
|
pub fn tied_embeddings(&self) -> bool {
|
||||||
self.tie_word_embeddings.unwrap_or(true)
|
self.tie_word_embeddings.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn num_experts(&self) -> usize {
|
||||||
|
self.num_local_experts.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn experts_per_token(&self) -> usize {
|
||||||
|
self.num_experts_per_tok.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_moe(&self) -> bool {
|
||||||
|
self.num_local_experts.unwrap_or(0) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_sliding_layer(&self, layer_idx: usize) -> bool {
|
||||||
|
self.layer_types
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|lt| lt.get(layer_idx))
|
||||||
|
.map(|t| t == "sliding_attention")
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn window_size(&self) -> usize {
|
||||||
|
self.sliding_window.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn geglu_alpha(&self) -> f32 {
|
||||||
|
self.geglu_alpha.unwrap_or(1.702) as f32
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
458
crates/xserv-model/src/decode_graph.rs
Normal file
458
crates/xserv-model/src/decode_graph.rs
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
//! CUDA Graph integration for batch=1 single-sequence decode.
|
||||||
|
//!
|
||||||
|
//! Uses a per-layer split graph approach:
|
||||||
|
//! - Pre-attention graph: RMSNorm + QKV projections + reshape + QK-norm + RoPE
|
||||||
|
//! - Ungraphed: KV cache append + decode attention (variable kv_len)
|
||||||
|
//! - Post-attention graph: merge_heads + O-proj + add_rmsnorm + FFN + residual
|
||||||
|
//! - Final graph: last RMSNorm + lm_head GEMV
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use xserv_cuda::{CudaGraph, CudaStream, GpuBuffer};
|
||||||
|
use xserv_kernels::dispatch;
|
||||||
|
use xserv_kernels::gemm::cublas_handle;
|
||||||
|
|
||||||
|
use crate::config::ModelConfig;
|
||||||
|
use crate::kv_cache::GpuKVCache;
|
||||||
|
|
||||||
|
/// Pre-allocated intermediate buffers for decode (batch=1).
|
||||||
|
/// All buffers have stable GPU addresses for CUDA Graph replay.
|
||||||
|
struct DecodeBuffers {
|
||||||
|
// Hidden-size buffers: [1, hidden]
|
||||||
|
x: GpuBuffer, // running hidden state
|
||||||
|
normed: GpuBuffer, // rmsnorm output
|
||||||
|
attn_out: GpuBuffer, // attention output [1, num_heads, 1, head_dim]
|
||||||
|
attn_merged: GpuBuffer, // merge_heads output [1, hidden]
|
||||||
|
o_proj: GpuBuffer, // O projection output [1, hidden]
|
||||||
|
normed2: GpuBuffer, // post-attn norm output [1, hidden]
|
||||||
|
sum_out: GpuBuffer, // add_rmsnorm sum output [1, hidden]
|
||||||
|
down: GpuBuffer, // down projection output [1, hidden]
|
||||||
|
|
||||||
|
// QKV projection outputs
|
||||||
|
q_proj: GpuBuffer, // [1, num_heads * head_dim]
|
||||||
|
k_proj: GpuBuffer, // [1, num_kv_heads * head_dim]
|
||||||
|
v_proj: GpuBuffer, // [1, num_kv_heads * head_dim]
|
||||||
|
|
||||||
|
// Reshaped: [1, H, 1, D]
|
||||||
|
q_reshaped: GpuBuffer,
|
||||||
|
k_reshaped: GpuBuffer,
|
||||||
|
v_reshaped: GpuBuffer,
|
||||||
|
|
||||||
|
// After QK-norm (same shape as reshaped)
|
||||||
|
q_normed: GpuBuffer,
|
||||||
|
k_normed: GpuBuffer,
|
||||||
|
|
||||||
|
// RoPE transposed: [1, H, D]
|
||||||
|
q_rope: GpuBuffer,
|
||||||
|
k_rope: GpuBuffer,
|
||||||
|
|
||||||
|
// After RoPE transpose back: [1, H, 1, D]
|
||||||
|
q_final: GpuBuffer,
|
||||||
|
k_final: GpuBuffer,
|
||||||
|
|
||||||
|
// FFN intermediates
|
||||||
|
gate: GpuBuffer, // [1, intermediate]
|
||||||
|
up: GpuBuffer, // [1, intermediate]
|
||||||
|
silu_out: GpuBuffer, // [1, intermediate]
|
||||||
|
|
||||||
|
// GEMV fp32 accumulators (separate per output dimension)
|
||||||
|
fp32_hidden: GpuBuffer, // for hidden-sized GEMV outputs
|
||||||
|
fp32_q: GpuBuffer, // for Q projection
|
||||||
|
fp32_kv: GpuBuffer, // for K/V projection
|
||||||
|
fp32_intermediate: GpuBuffer,// for gate/up projections
|
||||||
|
fp32_vocab: GpuBuffer, // for lm_head
|
||||||
|
|
||||||
|
// Token ID and position (GPU-resident, updated before replay)
|
||||||
|
token_id_gpu: GpuBuffer, // 4 bytes (u32)
|
||||||
|
position_gpu: GpuBuffer, // 4 bytes (u32)
|
||||||
|
|
||||||
|
// Final output
|
||||||
|
logits: GpuBuffer, // [1, vocab_size]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DecodeGraphState {
|
||||||
|
stream: CudaStream,
|
||||||
|
buffers: DecodeBuffers,
|
||||||
|
|
||||||
|
// Per-layer graph pairs
|
||||||
|
pre_attn_graphs: Vec<CudaGraph>,
|
||||||
|
post_attn_graphs: Vec<CudaGraph>,
|
||||||
|
final_graph: CudaGraph,
|
||||||
|
|
||||||
|
captured: bool,
|
||||||
|
|
||||||
|
// Model dimensions
|
||||||
|
hidden: usize,
|
||||||
|
num_heads: usize,
|
||||||
|
num_kv_heads: usize,
|
||||||
|
head_dim: usize,
|
||||||
|
intermediate: usize,
|
||||||
|
vocab_size: usize,
|
||||||
|
num_layers: usize,
|
||||||
|
eps: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodeGraphState {
|
||||||
|
pub fn new(config: &ModelConfig) -> Self {
|
||||||
|
let hidden = config.hidden();
|
||||||
|
let num_heads = config.num_heads();
|
||||||
|
let num_kv_heads = config.num_kv_heads();
|
||||||
|
let head_dim = config.head_dim();
|
||||||
|
let intermediate = config.ffn_hidden();
|
||||||
|
let vocab_size = config.vocab_size;
|
||||||
|
let num_layers = config.num_layers();
|
||||||
|
let eps = config.rms_norm_eps.unwrap_or(1e-6) as f32;
|
||||||
|
let es = 2usize; // BF16 = 2 bytes
|
||||||
|
|
||||||
|
let stream = CudaStream::new().expect("create CUDA stream for graph");
|
||||||
|
|
||||||
|
let alloc = |size: usize| -> GpuBuffer {
|
||||||
|
GpuBuffer::alloc(size).expect("alloc decode graph buffer")
|
||||||
|
};
|
||||||
|
|
||||||
|
let buffers = DecodeBuffers {
|
||||||
|
x: alloc(hidden * es),
|
||||||
|
normed: alloc(hidden * es),
|
||||||
|
attn_out: alloc(num_heads * head_dim * es),
|
||||||
|
attn_merged: alloc(hidden * es),
|
||||||
|
o_proj: alloc(hidden * es),
|
||||||
|
normed2: alloc(hidden * es),
|
||||||
|
sum_out: alloc(hidden * es),
|
||||||
|
down: alloc(hidden * es),
|
||||||
|
|
||||||
|
q_proj: alloc(num_heads * head_dim * es),
|
||||||
|
k_proj: alloc(num_kv_heads * head_dim * es),
|
||||||
|
v_proj: alloc(num_kv_heads * head_dim * es),
|
||||||
|
|
||||||
|
q_reshaped: alloc(num_heads * head_dim * es),
|
||||||
|
k_reshaped: alloc(num_kv_heads * head_dim * es),
|
||||||
|
v_reshaped: alloc(num_kv_heads * head_dim * es),
|
||||||
|
|
||||||
|
q_normed: alloc(num_heads * head_dim * es),
|
||||||
|
k_normed: alloc(num_kv_heads * head_dim * es),
|
||||||
|
|
||||||
|
q_rope: alloc(num_heads * head_dim * es),
|
||||||
|
k_rope: alloc(num_kv_heads * head_dim * es),
|
||||||
|
|
||||||
|
q_final: alloc(num_heads * head_dim * es),
|
||||||
|
k_final: alloc(num_kv_heads * head_dim * es),
|
||||||
|
|
||||||
|
gate: alloc(intermediate * es),
|
||||||
|
up: alloc(intermediate * es),
|
||||||
|
silu_out: alloc(intermediate * es),
|
||||||
|
|
||||||
|
fp32_hidden: alloc(hidden * 4),
|
||||||
|
fp32_q: alloc(num_heads * head_dim * 4),
|
||||||
|
fp32_kv: alloc(num_kv_heads * head_dim * 4),
|
||||||
|
fp32_intermediate: alloc(intermediate * 4),
|
||||||
|
fp32_vocab: alloc(vocab_size * 4),
|
||||||
|
|
||||||
|
token_id_gpu: alloc(4),
|
||||||
|
position_gpu: alloc(4),
|
||||||
|
|
||||||
|
logits: alloc(vocab_size * es),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pre_attn_graphs = (0..num_layers).map(|_| CudaGraph::new()).collect();
|
||||||
|
let post_attn_graphs = (0..num_layers).map(|_| CudaGraph::new()).collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
stream,
|
||||||
|
buffers,
|
||||||
|
pre_attn_graphs,
|
||||||
|
post_attn_graphs,
|
||||||
|
final_graph: CudaGraph::new(),
|
||||||
|
captured: false,
|
||||||
|
hidden,
|
||||||
|
num_heads,
|
||||||
|
num_kv_heads,
|
||||||
|
head_dim,
|
||||||
|
intermediate,
|
||||||
|
vocab_size,
|
||||||
|
num_layers,
|
||||||
|
eps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_captured(&self) -> bool {
|
||||||
|
self.captured
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture all per-layer graphs. Called once after the first decode step.
|
||||||
|
pub fn capture(
|
||||||
|
&mut self,
|
||||||
|
layers: &[LayerWeightPtrs],
|
||||||
|
norm_weight: *const c_void,
|
||||||
|
lm_head_wt: *const c_void,
|
||||||
|
_embed_table: *const c_void,
|
||||||
|
rope_cos: *const c_void,
|
||||||
|
rope_sin: *const c_void,
|
||||||
|
) {
|
||||||
|
let s = self.stream.as_raw();
|
||||||
|
let h = self.hidden as i32;
|
||||||
|
let nh = self.num_heads as i32;
|
||||||
|
let nkv = self.num_kv_heads as i32;
|
||||||
|
let hd = self.head_dim as i32;
|
||||||
|
let inter = self.intermediate as i32;
|
||||||
|
let vocab = self.vocab_size as i32;
|
||||||
|
let eps = self.eps;
|
||||||
|
|
||||||
|
let cublas = cublas_handle();
|
||||||
|
|
||||||
|
// Set cuBLAS to use our stream
|
||||||
|
unsafe { dispatch::set_cublas_stream(cublas, s); }
|
||||||
|
|
||||||
|
for (l, lw) in layers.iter().enumerate() {
|
||||||
|
// === Pre-attention graph ===
|
||||||
|
self.pre_attn_graphs[l].begin_capture(&self.stream).expect("begin pre-attn capture");
|
||||||
|
unsafe {
|
||||||
|
// RMSNorm
|
||||||
|
dispatch::rmsnorm_bf16(
|
||||||
|
self.buffers.x.as_ptr() as _, lw.input_norm, self.buffers.normed.as_mut_ptr() as _,
|
||||||
|
1, h, eps, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Q projection (GEMV)
|
||||||
|
dispatch::gemv_bf16(
|
||||||
|
self.buffers.normed.as_ptr() as _, lw.q_proj_wt, self.buffers.q_proj.as_mut_ptr() as _,
|
||||||
|
self.buffers.fp32_q.as_mut_ptr() as _,
|
||||||
|
h, nh * hd, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// K projection (GEMV)
|
||||||
|
dispatch::gemv_bf16(
|
||||||
|
self.buffers.normed.as_ptr() as _, lw.k_proj_wt, self.buffers.k_proj.as_mut_ptr() as _,
|
||||||
|
self.buffers.fp32_kv.as_mut_ptr() as _,
|
||||||
|
h, nkv * hd, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// V projection (GEMV)
|
||||||
|
dispatch::gemv_bf16(
|
||||||
|
self.buffers.normed.as_ptr() as _, lw.v_proj_wt, self.buffers.v_proj.as_mut_ptr() as _,
|
||||||
|
self.buffers.fp32_kv.as_mut_ptr() as _,
|
||||||
|
h, nkv * hd, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reshape heads: [1, H*D] -> [1, H, 1, D]
|
||||||
|
dispatch::reshape_heads_bf16(self.buffers.q_proj.as_ptr() as _, self.buffers.q_reshaped.as_mut_ptr() as _, 1, nh, hd, s);
|
||||||
|
dispatch::reshape_heads_bf16(self.buffers.k_proj.as_ptr() as _, self.buffers.k_reshaped.as_mut_ptr() as _, 1, nkv, hd, s);
|
||||||
|
dispatch::reshape_heads_bf16(self.buffers.v_proj.as_ptr() as _, self.buffers.v_reshaped.as_mut_ptr() as _, 1, nkv, hd, s);
|
||||||
|
|
||||||
|
// QK norm (head-level rmsnorm: treat [1,H,1,D] as [H, D])
|
||||||
|
dispatch::rmsnorm_bf16(self.buffers.q_reshaped.as_ptr() as _, lw.q_norm, self.buffers.q_normed.as_mut_ptr() as _, nh, hd, eps, s);
|
||||||
|
dispatch::rmsnorm_bf16(self.buffers.k_reshaped.as_ptr() as _, lw.k_norm, self.buffers.k_normed.as_mut_ptr() as _, nkv, hd, eps, s);
|
||||||
|
|
||||||
|
// Transpose for RoPE: [1,H,1,D] -> [1,H,D]
|
||||||
|
dispatch::transpose_hsd_to_shd_bf16(self.buffers.q_normed.as_ptr() as _, self.buffers.q_rope.as_mut_ptr() as _, 1, nh, hd, s);
|
||||||
|
dispatch::transpose_hsd_to_shd_bf16(self.buffers.k_normed.as_ptr() as _, self.buffers.k_rope.as_mut_ptr() as _, 1, nkv, hd, s);
|
||||||
|
|
||||||
|
// RoPE (in-place, reads position_gpu)
|
||||||
|
dispatch::rope_bf16(self.buffers.q_rope.as_mut_ptr() as _, rope_cos, rope_sin, self.buffers.position_gpu.as_ptr() as _, 1, nh, hd, s);
|
||||||
|
dispatch::rope_bf16(self.buffers.k_rope.as_mut_ptr() as _, rope_cos, rope_sin, self.buffers.position_gpu.as_ptr() as _, 1, nkv, hd, s);
|
||||||
|
|
||||||
|
// Transpose back: [1,H,D] -> [1,H,1,D]
|
||||||
|
dispatch::transpose_shd_to_hsd_bf16(self.buffers.q_rope.as_ptr() as _, self.buffers.q_final.as_mut_ptr() as _, 1, nh, hd, s);
|
||||||
|
dispatch::transpose_shd_to_hsd_bf16(self.buffers.k_rope.as_ptr() as _, self.buffers.k_final.as_mut_ptr() as _, 1, nkv, hd, s);
|
||||||
|
}
|
||||||
|
self.pre_attn_graphs[l].end_capture(&self.stream).expect("end pre-attn capture");
|
||||||
|
|
||||||
|
// === Post-attention graph ===
|
||||||
|
self.post_attn_graphs[l].begin_capture(&self.stream).expect("begin post-attn capture");
|
||||||
|
unsafe {
|
||||||
|
// Merge heads: [1,H,1,D] -> [1, hidden]
|
||||||
|
// attn_out is written by ungraphed attention
|
||||||
|
dispatch::merge_heads_bf16(self.buffers.attn_out.as_ptr() as _, self.buffers.attn_merged.as_mut_ptr() as _, 1, nh, hd, s);
|
||||||
|
|
||||||
|
// O projection
|
||||||
|
dispatch::gemv_bf16(
|
||||||
|
self.buffers.attn_merged.as_ptr() as _, lw.o_proj_wt, self.buffers.o_proj.as_mut_ptr() as _,
|
||||||
|
self.buffers.fp32_hidden.as_mut_ptr() as _,
|
||||||
|
nh * hd, h, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fused Add+RMSNorm: normed2 = rmsnorm(o_proj + x), sum_out = o_proj + x
|
||||||
|
dispatch::add_rmsnorm_bf16(
|
||||||
|
self.buffers.o_proj.as_ptr() as _, self.buffers.x.as_ptr() as _, lw.post_norm,
|
||||||
|
self.buffers.normed2.as_mut_ptr() as _, self.buffers.sum_out.as_mut_ptr() as _,
|
||||||
|
1, h, eps, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gate projection
|
||||||
|
dispatch::gemv_bf16(
|
||||||
|
self.buffers.normed2.as_ptr() as _, lw.gate_proj_wt, self.buffers.gate.as_mut_ptr() as _,
|
||||||
|
self.buffers.fp32_intermediate.as_mut_ptr() as _,
|
||||||
|
h, inter, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Up projection
|
||||||
|
dispatch::gemv_bf16(
|
||||||
|
self.buffers.normed2.as_ptr() as _, lw.up_proj_wt, self.buffers.up.as_mut_ptr() as _,
|
||||||
|
self.buffers.fp32_intermediate.as_mut_ptr() as _,
|
||||||
|
h, inter, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fused SiLU x Mul
|
||||||
|
dispatch::silu_mul_bf16(self.buffers.gate.as_ptr() as _, self.buffers.up.as_ptr() as _, self.buffers.silu_out.as_mut_ptr() as _, inter, s);
|
||||||
|
|
||||||
|
// Down projection
|
||||||
|
dispatch::gemv_bf16(
|
||||||
|
self.buffers.silu_out.as_ptr() as _, lw.down_proj_wt, self.buffers.down.as_mut_ptr() as _,
|
||||||
|
self.buffers.fp32_hidden.as_mut_ptr() as _,
|
||||||
|
inter, h, s,
|
||||||
|
);
|
||||||
|
|
||||||
|
// x = sum_out + down (residual connection for next layer)
|
||||||
|
dispatch::add_bf16(self.buffers.sum_out.as_ptr() as _, self.buffers.down.as_ptr() as _, self.buffers.x.as_mut_ptr() as _, h, s);
|
||||||
|
}
|
||||||
|
self.post_attn_graphs[l].end_capture(&self.stream).expect("end post-attn capture");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Final graph: norm + lm_head ===
|
||||||
|
self.final_graph.begin_capture(&self.stream).expect("begin final capture");
|
||||||
|
unsafe {
|
||||||
|
dispatch::rmsnorm_bf16(self.buffers.x.as_ptr() as _, norm_weight, self.buffers.normed.as_mut_ptr() as _, 1, h, eps, s);
|
||||||
|
dispatch::gemv_bf16(
|
||||||
|
self.buffers.normed.as_ptr() as _, lm_head_wt, self.buffers.logits.as_mut_ptr() as _,
|
||||||
|
self.buffers.fp32_vocab.as_mut_ptr() as _,
|
||||||
|
h, vocab, s,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.final_graph.end_capture(&self.stream).expect("end final capture");
|
||||||
|
|
||||||
|
// Reset cuBLAS back to null stream
|
||||||
|
unsafe { dispatch::set_cublas_stream(cublas, std::ptr::null_mut()); }
|
||||||
|
|
||||||
|
self.captured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a single decode step using captured graphs.
|
||||||
|
pub fn execute(
|
||||||
|
&mut self,
|
||||||
|
token_id: u32,
|
||||||
|
position: u32,
|
||||||
|
cache: &mut GpuKVCache,
|
||||||
|
_layers: &[LayerWeightPtrs],
|
||||||
|
embed_table: *const c_void,
|
||||||
|
vocab_size: i32,
|
||||||
|
hidden_size: i32,
|
||||||
|
) {
|
||||||
|
assert!(self.captured, "must call capture() before execute()");
|
||||||
|
let s = self.stream.as_raw();
|
||||||
|
let nkv = self.num_kv_heads;
|
||||||
|
let nh = self.num_heads;
|
||||||
|
let hd = self.head_dim;
|
||||||
|
let es = 2usize; // BF16
|
||||||
|
|
||||||
|
// Upload token ID and position to fixed GPU buffers
|
||||||
|
self.buffers.token_id_gpu.copy_from_host(&token_id.to_le_bytes()).unwrap();
|
||||||
|
self.buffers.position_gpu.copy_from_host(&position.to_le_bytes()).unwrap();
|
||||||
|
|
||||||
|
// Embedding (outside graph since token_id changes each step)
|
||||||
|
unsafe {
|
||||||
|
dispatch::embedding_bf16(
|
||||||
|
embed_table,
|
||||||
|
self.buffers.token_id_gpu.as_ptr() as _,
|
||||||
|
self.buffers.x.as_mut_ptr() as _,
|
||||||
|
1, hidden_size, vocab_size, s,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for l in 0..self.num_layers {
|
||||||
|
// Pre-attention graph (norm + QKV + reshape + QK-norm + RoPE)
|
||||||
|
self.pre_attn_graphs[l].launch(&self.stream).expect("launch pre-attn graph");
|
||||||
|
|
||||||
|
// Ungraphed: KV cache append
|
||||||
|
// k_final shape: [1, num_kv_heads, 1, head_dim] (after RoPE pipeline)
|
||||||
|
// v_reshaped shape: [1, num_kv_heads, 1, head_dim] (V skips RoPE)
|
||||||
|
let pos = position as usize;
|
||||||
|
|
||||||
|
let k_buf_size = nkv * hd * es;
|
||||||
|
let v_buf_size = nkv * hd * es;
|
||||||
|
let shape = [1usize, nkv, 1, hd];
|
||||||
|
|
||||||
|
// Synchronize before accessing buffers for KV cache append
|
||||||
|
self.stream.synchronize().expect("sync before kv cache");
|
||||||
|
|
||||||
|
let k_view = unsafe {
|
||||||
|
crate::kv_cache::tensor_from_gpu_buffer_pub(
|
||||||
|
GpuBuffer::borrow_raw(self.buffers.k_final.as_mut_ptr(), k_buf_size),
|
||||||
|
&shape,
|
||||||
|
xserv_tensor::DType::BF16,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let v_view = unsafe {
|
||||||
|
crate::kv_cache::tensor_from_gpu_buffer_pub(
|
||||||
|
GpuBuffer::borrow_raw(self.buffers.v_reshaped.as_mut_ptr(), v_buf_size),
|
||||||
|
&shape,
|
||||||
|
xserv_tensor::DType::BF16,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
cache.append(l, &k_view, &v_view, 1, pos);
|
||||||
|
|
||||||
|
// Ungraphed: get full KV cache and run decode attention
|
||||||
|
let (k_full, v_full) = cache.get_kv_len(l, pos + 1);
|
||||||
|
let kv_len = (pos + 1) as i32;
|
||||||
|
let scale = 1.0 / (hd as f32).sqrt();
|
||||||
|
|
||||||
|
// Attention output written to attn_out (separate from q_final)
|
||||||
|
unsafe {
|
||||||
|
dispatch::decode_attention_bf16(
|
||||||
|
self.buffers.q_final.as_ptr() as _,
|
||||||
|
k_full.data_ptr() as _,
|
||||||
|
v_full.data_ptr() as _,
|
||||||
|
self.buffers.attn_out.as_mut_ptr() as _,
|
||||||
|
1, nh as i32, nkv as i32,
|
||||||
|
kv_len, hd as i32,
|
||||||
|
scale, s,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronize before post-attention graph reads attn_out
|
||||||
|
self.stream.synchronize().expect("sync before post-attn");
|
||||||
|
|
||||||
|
// Post-attention graph (merge + O-proj + add_rmsnorm + FFN + residual)
|
||||||
|
self.post_attn_graphs[l].launch(&self.stream).expect("launch post-attn graph");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final graph (norm + lm_head)
|
||||||
|
self.final_graph.launch(&self.stream).expect("launch final graph");
|
||||||
|
|
||||||
|
// Sync to ensure logits are ready
|
||||||
|
self.stream.synchronize().expect("sync after decode");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the logits buffer (for reading results after execute).
|
||||||
|
pub fn logits_buffer(&self) -> &GpuBuffer {
|
||||||
|
&self.buffers.logits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate captured graphs (e.g. when switching sequences).
|
||||||
|
pub fn invalidate(&mut self) {
|
||||||
|
self.captured = false;
|
||||||
|
self.pre_attn_graphs = (0..self.num_layers).map(|_| CudaGraph::new()).collect();
|
||||||
|
self.post_attn_graphs = (0..self.num_layers).map(|_| CudaGraph::new()).collect();
|
||||||
|
self.final_graph = CudaGraph::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for DecodeGraphState {}
|
||||||
|
|
||||||
|
/// Lightweight struct holding raw pointers to a layer's weight tensors.
|
||||||
|
/// Used to avoid passing the full model struct into the graph capture code.
|
||||||
|
pub struct LayerWeightPtrs {
|
||||||
|
pub input_norm: *const c_void,
|
||||||
|
pub q_proj_wt: *const c_void,
|
||||||
|
pub k_proj_wt: *const c_void,
|
||||||
|
pub v_proj_wt: *const c_void,
|
||||||
|
pub o_proj_wt: *const c_void,
|
||||||
|
pub q_norm: *const c_void,
|
||||||
|
pub k_norm: *const c_void,
|
||||||
|
pub post_norm: *const c_void,
|
||||||
|
pub gate_proj_wt: *const c_void,
|
||||||
|
pub up_proj_wt: *const c_void,
|
||||||
|
pub down_proj_wt: *const c_void,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for LayerWeightPtrs {}
|
||||||
|
unsafe impl Sync for LayerWeightPtrs {}
|
||||||
@@ -116,6 +116,7 @@ fn tensor_from_raw_bytes(bytes: &[u8], shape: &[usize], dtype: DType) -> Tensor
|
|||||||
|
|
||||||
impl GPT2 {
|
impl GPT2 {
|
||||||
pub fn from_weights(config: ModelConfig, mut w: HashMap<String, Tensor>) -> Self {
|
pub fn from_weights(config: ModelConfig, mut w: HashMap<String, Tensor>) -> Self {
|
||||||
|
crate::init_kernels();
|
||||||
let take = |w: &mut HashMap<String, Tensor>, name: &str| -> Tensor {
|
let take = |w: &mut HashMap<String, Tensor>, name: &str| -> Tensor {
|
||||||
w.remove(name).unwrap_or_else(|| panic!("missing weight: {name}"))
|
w.remove(name).unwrap_or_else(|| panic!("missing weight: {name}"))
|
||||||
};
|
};
|
||||||
@@ -279,45 +280,88 @@ fn add_bias(x: &Tensor, bias: &Tensor) -> Tensor {
|
|||||||
fn split_qkv(qkv: &Tensor, num_heads: usize, head_dim: usize, seq_len: usize) -> (Tensor, Tensor, Tensor) {
|
fn split_qkv(qkv: &Tensor, num_heads: usize, head_dim: usize, seq_len: usize) -> (Tensor, Tensor, Tensor) {
|
||||||
let hidden = num_heads * head_dim;
|
let hidden = num_heads * head_dim;
|
||||||
let qkv_cpu = qkv.to_device(Device::Cpu);
|
let qkv_cpu = qkv.to_device(Device::Cpu);
|
||||||
let data = qkv_cpu.as_slice::<f32>();
|
|
||||||
|
|
||||||
let mut q_data = vec![0.0f32; num_heads * seq_len * head_dim];
|
|
||||||
let mut k_data = vec![0.0f32; num_heads * seq_len * head_dim];
|
|
||||||
let mut v_data = vec![0.0f32; num_heads * seq_len * head_dim];
|
|
||||||
|
|
||||||
for s in 0..seq_len {
|
|
||||||
let row = &data[s * 3 * hidden..(s + 1) * 3 * hidden];
|
|
||||||
for h in 0..num_heads {
|
|
||||||
let src_off = h * head_dim;
|
|
||||||
let dst_off = (h * seq_len + s) * head_dim;
|
|
||||||
q_data[dst_off..dst_off + head_dim].copy_from_slice(&row[src_off..src_off + head_dim]);
|
|
||||||
k_data[dst_off..dst_off + head_dim].copy_from_slice(&row[hidden + src_off..hidden + src_off + head_dim]);
|
|
||||||
v_data[dst_off..dst_off + head_dim].copy_from_slice(&row[2 * hidden + src_off..2 * hidden + src_off + head_dim]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let device = qkv.device();
|
let device = qkv.device();
|
||||||
let q = Tensor::from_slice(&q_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
let dtype = qkv.dtype();
|
||||||
let k = Tensor::from_slice(&k_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
|
||||||
let v = Tensor::from_slice(&v_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
match dtype {
|
||||||
(q, k, v)
|
DType::F32 => {
|
||||||
|
let data = qkv_cpu.as_slice::<f32>();
|
||||||
|
let mut q_data = vec![0.0f32; num_heads * seq_len * head_dim];
|
||||||
|
let mut k_data = vec![0.0f32; num_heads * seq_len * head_dim];
|
||||||
|
let mut v_data = vec![0.0f32; num_heads * seq_len * head_dim];
|
||||||
|
for s in 0..seq_len {
|
||||||
|
let row = &data[s * 3 * hidden..(s + 1) * 3 * hidden];
|
||||||
|
for h in 0..num_heads {
|
||||||
|
let src_off = h * head_dim;
|
||||||
|
let dst_off = (h * seq_len + s) * head_dim;
|
||||||
|
q_data[dst_off..dst_off + head_dim].copy_from_slice(&row[src_off..src_off + head_dim]);
|
||||||
|
k_data[dst_off..dst_off + head_dim].copy_from_slice(&row[hidden + src_off..hidden + src_off + head_dim]);
|
||||||
|
v_data[dst_off..dst_off + head_dim].copy_from_slice(&row[2 * hidden + src_off..2 * hidden + src_off + head_dim]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let q = Tensor::from_slice(&q_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
||||||
|
let k = Tensor::from_slice(&k_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
||||||
|
let v = Tensor::from_slice(&v_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
||||||
|
(q, k, v)
|
||||||
|
}
|
||||||
|
DType::BF16 => {
|
||||||
|
let data = qkv_cpu.as_slice::<half::bf16>();
|
||||||
|
let mut q_data = vec![half::bf16::ZERO; num_heads * seq_len * head_dim];
|
||||||
|
let mut k_data = vec![half::bf16::ZERO; num_heads * seq_len * head_dim];
|
||||||
|
let mut v_data = vec![half::bf16::ZERO; num_heads * seq_len * head_dim];
|
||||||
|
for s in 0..seq_len {
|
||||||
|
let row = &data[s * 3 * hidden..(s + 1) * 3 * hidden];
|
||||||
|
for h in 0..num_heads {
|
||||||
|
let src_off = h * head_dim;
|
||||||
|
let dst_off = (h * seq_len + s) * head_dim;
|
||||||
|
q_data[dst_off..dst_off + head_dim].copy_from_slice(&row[src_off..src_off + head_dim]);
|
||||||
|
k_data[dst_off..dst_off + head_dim].copy_from_slice(&row[hidden + src_off..hidden + src_off + head_dim]);
|
||||||
|
v_data[dst_off..dst_off + head_dim].copy_from_slice(&row[2 * hidden + src_off..2 * hidden + src_off + head_dim]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let q = Tensor::from_slice(&q_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
||||||
|
let k = Tensor::from_slice(&k_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
||||||
|
let v = Tensor::from_slice(&v_data, &[1, num_heads, seq_len, head_dim]).to_device(device);
|
||||||
|
(q, k, v)
|
||||||
|
}
|
||||||
|
_ => panic!("unsupported dtype {:?} in split_qkv", dtype),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_heads(x: &Tensor, seq_len: usize, hidden: usize) -> Tensor {
|
fn merge_heads(x: &Tensor, seq_len: usize, hidden: usize) -> Tensor {
|
||||||
let num_heads = x.shape()[1];
|
let num_heads = x.shape()[1];
|
||||||
let head_dim = x.shape()[3];
|
let head_dim = x.shape()[3];
|
||||||
let x_cpu = x.to_device(Device::Cpu);
|
let x_cpu = x.to_device(Device::Cpu);
|
||||||
let src = x_cpu.as_slice::<f32>();
|
let device = x.device();
|
||||||
|
let dtype = x.dtype();
|
||||||
|
|
||||||
let mut out = vec![0.0f32; seq_len * hidden];
|
match dtype {
|
||||||
for s in 0..seq_len {
|
DType::F32 => {
|
||||||
for h in 0..num_heads {
|
let src = x_cpu.as_slice::<f32>();
|
||||||
let src_off = (h * seq_len + s) * head_dim;
|
let mut out = vec![0.0f32; seq_len * hidden];
|
||||||
let dst_off = s * hidden + h * head_dim;
|
for s in 0..seq_len {
|
||||||
out[dst_off..dst_off + head_dim].copy_from_slice(&src[src_off..src_off + head_dim]);
|
for h in 0..num_heads {
|
||||||
|
let src_off = (h * seq_len + s) * head_dim;
|
||||||
|
let dst_off = s * hidden + h * head_dim;
|
||||||
|
out[dst_off..dst_off + head_dim].copy_from_slice(&src[src_off..src_off + head_dim]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tensor::from_slice(&out, &[seq_len, hidden]).to_device(device)
|
||||||
}
|
}
|
||||||
|
DType::BF16 => {
|
||||||
|
let src = x_cpu.as_slice::<half::bf16>();
|
||||||
|
let mut out = vec![half::bf16::ZERO; seq_len * hidden];
|
||||||
|
for s in 0..seq_len {
|
||||||
|
for h in 0..num_heads {
|
||||||
|
let src_off = (h * seq_len + s) * head_dim;
|
||||||
|
let dst_off = s * hidden + h * head_dim;
|
||||||
|
out[dst_off..dst_off + head_dim].copy_from_slice(&src[src_off..src_off + head_dim]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tensor::from_slice(&out, &[seq_len, hidden]).to_device(device)
|
||||||
|
}
|
||||||
|
_ => panic!("unsupported dtype {:?} in merge_heads", dtype),
|
||||||
}
|
}
|
||||||
Tensor::from_slice(&out, &[seq_len, hidden]).to_device(x.device())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Greedy sampling: return the argmax token ID from the last position's logits.
|
/// Greedy sampling: return the argmax token ID from the last position's logits.
|
||||||
|
|||||||
769
crates/xserv-model/src/gpt_oss.rs
Normal file
769
crates/xserv-model/src/gpt_oss.rs
Normal file
@@ -0,0 +1,769 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use half::bf16;
|
||||||
|
use xserv_kernels::*;
|
||||||
|
use xserv_tensor::{Device, Tensor};
|
||||||
|
|
||||||
|
use crate::config::ModelConfig;
|
||||||
|
use crate::paged_kv_cache::PagedKVCache;
|
||||||
|
|
||||||
|
pub struct GptOss {
|
||||||
|
pub config: ModelConfig,
|
||||||
|
embed_tokens: Tensor,
|
||||||
|
layers: Vec<GptOssBlock>,
|
||||||
|
norm: Tensor,
|
||||||
|
norm_bias: Option<Tensor>,
|
||||||
|
lm_head_t: Tensor,
|
||||||
|
rope_cache: RopeCache,
|
||||||
|
tp: Option<std::sync::Arc<xserv_distributed::TpContext>>,
|
||||||
|
local_num_heads: usize,
|
||||||
|
local_num_kv_heads: usize,
|
||||||
|
has_norm_bias: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GptOssBlock {
|
||||||
|
input_norm: Tensor,
|
||||||
|
input_norm_bias: Option<Tensor>,
|
||||||
|
// Attention (with bias)
|
||||||
|
q_proj_wt: Tensor,
|
||||||
|
q_proj_bias: Tensor,
|
||||||
|
k_proj_wt: Tensor,
|
||||||
|
k_proj_bias: Tensor,
|
||||||
|
v_proj_wt: Tensor,
|
||||||
|
v_proj_bias: Tensor,
|
||||||
|
o_proj_wt: Tensor,
|
||||||
|
o_proj_bias: Tensor,
|
||||||
|
sinks: Tensor,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
is_sliding: bool,
|
||||||
|
window_size: usize,
|
||||||
|
// MoE MLP
|
||||||
|
post_norm: Tensor,
|
||||||
|
post_norm_bias: Option<Tensor>,
|
||||||
|
router_wt: Tensor,
|
||||||
|
router_bias: Tensor,
|
||||||
|
// 3D expert weights for batched GEMM (contiguous on GPU)
|
||||||
|
expert_gate_up_wt: Tensor, // [local_experts, hidden, 2*inter] BF16
|
||||||
|
expert_gate_up_bias: Tensor, // [local_experts, 2*inter]
|
||||||
|
expert_down_wt: Tensor, // [local_experts, inter, hidden] BF16
|
||||||
|
expert_down_bias: Tensor, // [local_experts, hidden]
|
||||||
|
// FP8 quantized expert weights (Some when running FP8 W8A8)
|
||||||
|
// Transposed layout [E, N, K] for cuBLASLt FP8 (Blackwell requires transA=T)
|
||||||
|
expert_gate_up_fp8: Option<Tensor>, // [local_experts, 2*inter, hidden] FP8E4M3
|
||||||
|
expert_gate_up_scale: Option<Tensor>,// [local_experts] F32
|
||||||
|
expert_down_fp8: Option<Tensor>, // [local_experts, hidden, inter] FP8E4M3
|
||||||
|
expert_down_scale: Option<Tensor>, // [local_experts] F32
|
||||||
|
// MXFP4 W4A16 expert weights (Some when running 4-bit weight-only).
|
||||||
|
// (packed [E, N, K/2] u8, scales [E, N, K/32] u8) in [E, N, K] layout.
|
||||||
|
expert_gate_up_mxfp4: Option<(Tensor, Tensor)>,
|
||||||
|
expert_down_mxfp4: Option<(Tensor, Tensor)>,
|
||||||
|
local_experts: usize,
|
||||||
|
// Activation params
|
||||||
|
glu_alpha: f32,
|
||||||
|
glu_limit: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GptOss {
|
||||||
|
pub fn from_weights(config: ModelConfig, w: HashMap<String, Tensor>) -> Self {
|
||||||
|
Self::from_weights_tp(config, w, 0, 1, 0, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_weights_tp(
|
||||||
|
config: ModelConfig,
|
||||||
|
mut w: HashMap<String, Tensor>,
|
||||||
|
rank: usize,
|
||||||
|
world: usize,
|
||||||
|
device: u32,
|
||||||
|
tp: Option<std::sync::Arc<xserv_distributed::TpContext>>,
|
||||||
|
) -> Self {
|
||||||
|
crate::init_kernels();
|
||||||
|
let dev = Device::Cuda(device);
|
||||||
|
let take = |w: &mut HashMap<String, Tensor>, name: &str| -> Tensor {
|
||||||
|
w.remove(name).unwrap_or_else(|| panic!("missing weight: {name}"))
|
||||||
|
};
|
||||||
|
let repl = |t: Tensor| -> Tensor { t.to_device(dev) };
|
||||||
|
// column-parallel: shard rows of [out, in], transpose → [in, out/world]
|
||||||
|
let col = |t: Tensor| -> Tensor {
|
||||||
|
shard_rows(&t, rank, world).to_device(dev).transpose(0, 1).contiguous()
|
||||||
|
};
|
||||||
|
// row-parallel: shard cols of [out, in], transpose → [in/world, out]
|
||||||
|
let row = |t: Tensor| -> Tensor {
|
||||||
|
shard_cols(&t, rank, world).to_device(dev).transpose(0, 1).contiguous()
|
||||||
|
};
|
||||||
|
// Bias sharding helpers
|
||||||
|
let col_bias = |t: Tensor| -> Tensor { shard_1d(&t, rank, world).to_device(dev) };
|
||||||
|
let repl_bias = |t: Tensor| -> Tensor { t.to_device(dev) };
|
||||||
|
|
||||||
|
let embed_tokens = repl(take(&mut w, "model.embed_tokens.weight"));
|
||||||
|
let norm = repl(take(&mut w, "model.norm.weight"));
|
||||||
|
let norm_bias = w.remove("model.norm.bias").map(|t| repl(t));
|
||||||
|
let lm_head_t = repl(take(&mut w, "lm_head.weight")).transpose(0, 1).contiguous();
|
||||||
|
|
||||||
|
let head_dim = config.head_dim();
|
||||||
|
let rope_theta = config.rope_theta.unwrap_or(150000.0);
|
||||||
|
let max_seq_len = config.max_seq_len().min(8192); // cap for memory
|
||||||
|
|
||||||
|
let rope_cache = if let Some(ref rs) = config.rope_scaling {
|
||||||
|
if rs.rope_type.as_deref() == Some("yarn") {
|
||||||
|
RopeCache::new_yarn(
|
||||||
|
max_seq_len,
|
||||||
|
head_dim,
|
||||||
|
rope_theta,
|
||||||
|
rs.factor.unwrap_or(1.0),
|
||||||
|
rs.original_max_position_embeddings.unwrap_or(4096),
|
||||||
|
rs.beta_fast.unwrap_or(32.0),
|
||||||
|
rs.beta_slow.unwrap_or(1.0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
RopeCache::new(max_seq_len, head_dim, rope_theta as f32)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RopeCache::new(max_seq_len, head_dim, rope_theta as f32)
|
||||||
|
};
|
||||||
|
|
||||||
|
let num_layers = config.num_layers();
|
||||||
|
let num_experts = config.num_experts();
|
||||||
|
let glu_alpha = config.geglu_alpha();
|
||||||
|
let glu_limit = config.swiglu_limit.unwrap_or(7.0) as f32;
|
||||||
|
|
||||||
|
let mut layers = Vec::with_capacity(num_layers);
|
||||||
|
if rank == 0 {
|
||||||
|
eprintln!(
|
||||||
|
"Loading gpt-oss weights: {} layers, {} experts, world={world}, glu_alpha={glu_alpha}...",
|
||||||
|
num_layers, num_experts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..num_layers {
|
||||||
|
let p = format!("model.layers.{i}");
|
||||||
|
|
||||||
|
// Attention weights — column-parallel for Q/K/V, row-parallel for O
|
||||||
|
let q_proj_wt = col(take(&mut w, &format!("{p}.self_attn.q_proj.weight")));
|
||||||
|
let q_proj_bias = col_bias(take(&mut w, &format!("{p}.self_attn.q_proj.bias")));
|
||||||
|
let k_proj_wt = col(take(&mut w, &format!("{p}.self_attn.k_proj.weight")));
|
||||||
|
let k_proj_bias = col_bias(take(&mut w, &format!("{p}.self_attn.k_proj.bias")));
|
||||||
|
let v_proj_wt = col(take(&mut w, &format!("{p}.self_attn.v_proj.weight")));
|
||||||
|
let v_proj_bias = col_bias(take(&mut w, &format!("{p}.self_attn.v_proj.bias")));
|
||||||
|
let o_proj_wt = row(take(&mut w, &format!("{p}.self_attn.o_proj.weight")));
|
||||||
|
let o_proj_bias = repl_bias(take(&mut w, &format!("{p}.self_attn.o_proj.bias")));
|
||||||
|
|
||||||
|
// Sinks: shard per-head across TP ranks
|
||||||
|
let sinks_full = take(&mut w, &format!("{p}.self_attn.sinks"));
|
||||||
|
let sinks = shard_1d(&sinks_full, rank, world).to_device(dev);
|
||||||
|
|
||||||
|
let is_sliding = config.is_sliding_layer(i);
|
||||||
|
let window_size = if is_sliding { config.window_size() } else { 0 };
|
||||||
|
|
||||||
|
// MoE weights — router replicated, experts split across TP ranks
|
||||||
|
let router_wt_raw = take(&mut w, &format!("{p}.mlp.router.weight"));
|
||||||
|
let router_wt = router_wt_raw.to_device(dev).transpose(0, 1).contiguous();
|
||||||
|
let router_bias = repl_bias(take(&mut w, &format!("{p}.mlp.router.bias")));
|
||||||
|
|
||||||
|
// Expert weights: [num_experts, hidden, 2*inter] — stored as 3D tensors
|
||||||
|
// Expert parallelism: rank owns experts [rank*E/world .. (rank+1)*E/world)
|
||||||
|
let gate_up_3d = take(&mut w, &format!("{p}.mlp.experts.gate_up_proj"));
|
||||||
|
let gate_up_bias_2d = take(&mut w, &format!("{p}.mlp.experts.gate_up_proj_bias"));
|
||||||
|
let down_3d = take(&mut w, &format!("{p}.mlp.experts.down_proj"));
|
||||||
|
let down_bias_2d = take(&mut w, &format!("{p}.mlp.experts.down_proj_bias"));
|
||||||
|
|
||||||
|
// FP8 scale tensors (present only in FP8-quantized models)
|
||||||
|
let gate_up_scale = w.remove(&format!("{p}.mlp.experts.gate_up_proj_scale"));
|
||||||
|
let down_scale = w.remove(&format!("{p}.mlp.experts.down_proj_scale"));
|
||||||
|
|
||||||
|
let local_experts = num_experts / world;
|
||||||
|
let expert_start = rank * local_experts;
|
||||||
|
|
||||||
|
// MXFP4 stores 4-bit weights in an FP8E4M3 byte container (same dtype
|
||||||
|
// as FP8), so distinguish by the scale rank: FP8 scale is 1-D [E],
|
||||||
|
// MXFP4 scale is 3-D [E, N, K/32].
|
||||||
|
let is_mxfp4 = gate_up_scale.as_ref().map(|s| s.ndim() == 3).unwrap_or(false);
|
||||||
|
let is_fp8 = !is_mxfp4 && gate_up_3d.dtype() == xserv_tensor::DType::FP8E4M3;
|
||||||
|
|
||||||
|
let mut expert_gate_up_mxfp4: Option<(Tensor, Tensor)> = None;
|
||||||
|
let mut expert_down_mxfp4: Option<(Tensor, Tensor)> = None;
|
||||||
|
|
||||||
|
let inter2 = if is_mxfp4 { gate_up_3d.shape()[1] } else { gate_up_3d.shape()[2] }; // 2*inter (N)
|
||||||
|
let hidden = if is_mxfp4 { gate_up_3d.shape()[2] * 2 } else { gate_up_3d.shape()[1] };
|
||||||
|
let inter = if is_mxfp4 { down_3d.shape()[2] * 2 } else { down_3d.shape()[1] };
|
||||||
|
|
||||||
|
// Slice the rank's range of experts as contiguous 3D tensors on GPU
|
||||||
|
let expert_gate_up_wt;
|
||||||
|
let expert_down_wt;
|
||||||
|
let expert_gate_up_fp8;
|
||||||
|
let expert_gate_up_scale_gpu;
|
||||||
|
let expert_down_fp8;
|
||||||
|
let expert_down_scale_gpu;
|
||||||
|
|
||||||
|
if is_mxfp4 {
|
||||||
|
// MXFP4 W4A16: weights already [E, N, K] packed ([E, N, K/2] bytes)
|
||||||
|
// + scales [E, N, K/32]. Slice this rank's experts (raw bytes).
|
||||||
|
let gu_s = gate_up_scale.expect("MXFP4 model missing gate_up_proj_scale");
|
||||||
|
let d_s = down_scale.expect("MXFP4 model missing down_proj_scale");
|
||||||
|
let gu_packed = slice_expert_range_3d_raw(&gate_up_3d, expert_start, local_experts, inter2, hidden / 2).to_device(dev);
|
||||||
|
let gu_scl = slice_expert_range_3d_raw(&gu_s, expert_start, local_experts, inter2, hidden / 32).to_device(dev);
|
||||||
|
let dn_packed = slice_expert_range_3d_raw(&down_3d, expert_start, local_experts, hidden, inter / 2).to_device(dev);
|
||||||
|
let dn_scl = slice_expert_range_3d_raw(&d_s, expert_start, local_experts, hidden, inter / 32).to_device(dev);
|
||||||
|
expert_gate_up_mxfp4 = Some((gu_packed, gu_scl));
|
||||||
|
expert_down_mxfp4 = Some((dn_packed, dn_scl));
|
||||||
|
expert_gate_up_fp8 = None;
|
||||||
|
expert_gate_up_scale_gpu = None;
|
||||||
|
expert_down_fp8 = None;
|
||||||
|
expert_down_scale_gpu = None;
|
||||||
|
expert_gate_up_wt = Tensor::empty(&[1, 1, 1], xserv_tensor::DType::BF16, dev);
|
||||||
|
expert_down_wt = Tensor::empty(&[1, 1, 1], xserv_tensor::DType::BF16, dev);
|
||||||
|
} else if is_fp8 {
|
||||||
|
// FP8 W8A8 path: load and TRANSPOSE weights for cuBLASLt (requires transA=T on Blackwell).
|
||||||
|
// Original: [E, K, N] → Transposed: [E, N, K]
|
||||||
|
let gu_sliced = slice_expert_range_3d_raw(&gate_up_3d, expert_start, local_experts, hidden, inter2);
|
||||||
|
let dn_sliced = slice_expert_range_3d_raw(&down_3d, expert_start, local_experts, inter, hidden);
|
||||||
|
expert_gate_up_fp8 = Some(transpose_3d_inner_raw(&gu_sliced, local_experts, hidden, inter2).to_device(dev));
|
||||||
|
expert_down_fp8 = Some(transpose_3d_inner_raw(&dn_sliced, local_experts, inter, hidden).to_device(dev));
|
||||||
|
// Scales: [num_experts] F32 → slice to [local_experts]
|
||||||
|
let gu_s = gate_up_scale.expect("FP8 model missing gate_up_proj_scale");
|
||||||
|
let d_s = down_scale.expect("FP8 model missing down_proj_scale");
|
||||||
|
expert_gate_up_scale_gpu = Some(slice_scale_range(&gu_s, expert_start, local_experts).to_device(dev));
|
||||||
|
expert_down_scale_gpu = Some(slice_scale_range(&d_s, expert_start, local_experts).to_device(dev));
|
||||||
|
// Dummy BF16 tensors (never read in FP8 path)
|
||||||
|
expert_gate_up_wt = Tensor::empty(&[1, 1, 1], xserv_tensor::DType::BF16, dev);
|
||||||
|
expert_down_wt = Tensor::empty(&[1, 1, 1], xserv_tensor::DType::BF16, dev);
|
||||||
|
} else {
|
||||||
|
// BF16 path: existing behavior
|
||||||
|
expert_gate_up_wt = slice_expert_range_3d(&gate_up_3d, expert_start, local_experts, hidden, inter2).to_device(dev);
|
||||||
|
expert_down_wt = slice_expert_range_3d(&down_3d, expert_start, local_experts, inter, hidden).to_device(dev);
|
||||||
|
expert_gate_up_fp8 = None;
|
||||||
|
expert_gate_up_scale_gpu = None;
|
||||||
|
expert_down_fp8 = None;
|
||||||
|
expert_down_scale_gpu = None;
|
||||||
|
}
|
||||||
|
let expert_gate_up_bias = slice_expert_range_2d(&gate_up_bias_2d, expert_start, local_experts, inter2).to_device(dev);
|
||||||
|
let expert_down_bias = slice_expert_range_2d(&down_bias_2d, expert_start, local_experts, hidden).to_device(dev);
|
||||||
|
|
||||||
|
xserv_cuda::allocator::cached_trim();
|
||||||
|
|
||||||
|
let input_norm = repl(take(&mut w, &format!("{p}.input_layernorm.weight")));
|
||||||
|
let input_norm_bias = w.remove(&format!("{p}.input_layernorm.bias")).map(|t| repl(t));
|
||||||
|
let post_norm = repl(take(&mut w, &format!("{p}.post_attention_layernorm.weight")));
|
||||||
|
let post_norm_bias = w.remove(&format!("{p}.post_attention_layernorm.bias")).map(|t| repl(t));
|
||||||
|
|
||||||
|
layers.push(GptOssBlock {
|
||||||
|
input_norm,
|
||||||
|
input_norm_bias,
|
||||||
|
q_proj_wt,
|
||||||
|
q_proj_bias,
|
||||||
|
k_proj_wt,
|
||||||
|
k_proj_bias,
|
||||||
|
v_proj_wt,
|
||||||
|
v_proj_bias,
|
||||||
|
o_proj_wt,
|
||||||
|
o_proj_bias,
|
||||||
|
sinks,
|
||||||
|
is_sliding,
|
||||||
|
window_size,
|
||||||
|
post_norm,
|
||||||
|
post_norm_bias,
|
||||||
|
router_wt,
|
||||||
|
router_bias,
|
||||||
|
expert_gate_up_wt,
|
||||||
|
expert_gate_up_bias,
|
||||||
|
expert_down_wt,
|
||||||
|
expert_down_bias,
|
||||||
|
expert_gate_up_fp8,
|
||||||
|
expert_gate_up_scale: expert_gate_up_scale_gpu,
|
||||||
|
expert_down_fp8,
|
||||||
|
expert_down_scale: expert_down_scale_gpu,
|
||||||
|
expert_gate_up_mxfp4,
|
||||||
|
expert_down_mxfp4,
|
||||||
|
local_experts,
|
||||||
|
glu_alpha,
|
||||||
|
glu_limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_num_heads = config.num_heads() / world;
|
||||||
|
let local_num_kv_heads = config.num_kv_heads() / world;
|
||||||
|
|
||||||
|
let has_norm_bias = norm_bias.is_some();
|
||||||
|
let is_fp8 = layers.first().map(|l| l.expert_gate_up_fp8.is_some()).unwrap_or(false);
|
||||||
|
let is_mxfp4 = layers.first().map(|l| l.expert_gate_up_mxfp4.is_some()).unwrap_or(false);
|
||||||
|
if rank == 0 {
|
||||||
|
if has_norm_bias {
|
||||||
|
eprintln!("gpt-oss: detected LayerNorm bias — using LayerNorm instead of RMSNorm");
|
||||||
|
}
|
||||||
|
if is_fp8 {
|
||||||
|
eprintln!("gpt-oss: FP8 E4M3 quantized expert weights detected (W8A8 cuBLASLt mode)");
|
||||||
|
}
|
||||||
|
if is_mxfp4 {
|
||||||
|
eprintln!("gpt-oss: MXFP4 quantized expert weights detected (W4A16 fused-GEMV mode)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about unused weights that the model didn't consume
|
||||||
|
if rank == 0 && !w.is_empty() {
|
||||||
|
eprintln!("WARNING: {} unused weight(s) in model:", w.len());
|
||||||
|
let mut keys: Vec<_> = w.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
for k in &keys {
|
||||||
|
eprintln!(" {k}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
embed_tokens,
|
||||||
|
layers,
|
||||||
|
norm,
|
||||||
|
norm_bias,
|
||||||
|
lm_head_t,
|
||||||
|
rope_cache,
|
||||||
|
tp,
|
||||||
|
local_num_heads,
|
||||||
|
local_num_kv_heads,
|
||||||
|
has_norm_bias,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn all_reduce(&self, t: &Tensor) {
|
||||||
|
if let Some(tp) = &self.tp {
|
||||||
|
if tp.world > 1 {
|
||||||
|
let ptr = t.storage().gpu_buffer().as_ptr() as *mut c_void;
|
||||||
|
tp.all_reduce_sum_bf16_ptr(ptr, t.numel());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn norm(x: &Tensor, weight: &Tensor, bias: &Option<Tensor>, eps: f32) -> Tensor {
|
||||||
|
match bias {
|
||||||
|
Some(b) => layernorm(x, weight, b, eps),
|
||||||
|
None => rmsnorm(x, weight, eps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn add_norm(x: &Tensor, residual: &Tensor, weight: &Tensor, bias: &Option<Tensor>, eps: f32) -> (Tensor, Tensor) {
|
||||||
|
match bias {
|
||||||
|
Some(b) => {
|
||||||
|
let sum = xserv_kernels::add(x, residual);
|
||||||
|
let normed = layernorm(&sum, weight, b, eps);
|
||||||
|
(normed, sum)
|
||||||
|
}
|
||||||
|
None => add_rmsnorm(x, residual, weight, eps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn norm_eps(&self) -> f32 {
|
||||||
|
if self.has_norm_bias {
|
||||||
|
self.config.ln_eps()
|
||||||
|
} else {
|
||||||
|
self.config.rms_norm_eps.unwrap_or(1e-5) as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paged decode: process one token per sequence using paged KV cache.
|
||||||
|
pub fn forward_decode_paged(
|
||||||
|
&self,
|
||||||
|
tokens: &[u32],
|
||||||
|
positions: &[usize],
|
||||||
|
seq_slots: &[usize],
|
||||||
|
paged_cache: &mut PagedKVCache,
|
||||||
|
) -> Tensor {
|
||||||
|
let batch = tokens.len();
|
||||||
|
assert_eq!(positions.len(), batch);
|
||||||
|
assert_eq!(seq_slots.len(), batch);
|
||||||
|
assert!(batch > 0);
|
||||||
|
|
||||||
|
let num_heads = self.local_num_heads;
|
||||||
|
let num_kv_heads = self.local_num_kv_heads;
|
||||||
|
let head_dim = self.config.head_dim();
|
||||||
|
let eps = self.norm_eps();
|
||||||
|
|
||||||
|
let kv_lens: Vec<i32> = positions.iter().map(|&p| (p + 1) as i32).collect();
|
||||||
|
for (b, &slot) in seq_slots.iter().enumerate() {
|
||||||
|
paged_cache.ensure_capacity(slot, positions[b] + 1);
|
||||||
|
}
|
||||||
|
paged_cache.sync_active_batch_with_lens(seq_slots, &kv_lens);
|
||||||
|
|
||||||
|
let bt_ptr = paged_cache.block_table_gpu().as_ptr() as *const i32;
|
||||||
|
let cl_ptr = paged_cache.context_lens_gpu().as_ptr() as *const i32;
|
||||||
|
let max_blocks = paged_cache.max_blocks_per_seq();
|
||||||
|
|
||||||
|
let positions_u32: Vec<u32> = positions.iter().map(|&p| p as u32).collect();
|
||||||
|
|
||||||
|
let mut x = embedding(&self.embed_tokens, tokens);
|
||||||
|
|
||||||
|
for (layer_idx, layer) in self.layers.iter().enumerate() {
|
||||||
|
let residual = x.clone();
|
||||||
|
let normed = Self::norm(&x, &layer.input_norm, &layer.input_norm_bias, eps);
|
||||||
|
|
||||||
|
// Q/K/V projections with bias
|
||||||
|
let q_all = add_bias(&matmul_2d(&normed, &layer.q_proj_wt), &layer.q_proj_bias);
|
||||||
|
let k_all = add_bias(&matmul_2d(&normed, &layer.k_proj_wt), &layer.k_proj_bias);
|
||||||
|
let v_all = add_bias(&matmul_2d(&normed, &layer.v_proj_wt), &layer.v_proj_bias);
|
||||||
|
|
||||||
|
|
||||||
|
// Reshape for RoPE: [B, H*D] → [B, H, D]
|
||||||
|
let q_3d = q_all.reshape(&[batch, num_heads, head_dim]);
|
||||||
|
let k_3d = k_all.reshape(&[batch, num_kv_heads, head_dim]);
|
||||||
|
|
||||||
|
// RoPE (no QK-norm for gpt-oss)
|
||||||
|
rope_inplace(&q_3d, &self.rope_cache, &positions_u32);
|
||||||
|
rope_inplace(&k_3d, &self.rope_cache, &positions_u32);
|
||||||
|
|
||||||
|
let v_3d = v_all.reshape(&[batch, num_kv_heads, head_dim]);
|
||||||
|
|
||||||
|
// KV cache scatter
|
||||||
|
paged_cache.append_tokens_batched(layer_idx, &k_3d, &v_3d, batch);
|
||||||
|
|
||||||
|
// Paged attention with sinks + sliding window
|
||||||
|
let q_4d = q_3d.reshape(&[batch, num_heads, 1, head_dim]);
|
||||||
|
let k_pool_ptr = paged_cache.k_pool(layer_idx).as_ptr() as *const c_void;
|
||||||
|
let v_pool_ptr = paged_cache.v_pool(layer_idx).as_ptr() as *const c_void;
|
||||||
|
let sinks_ptr = layer.sinks.data_ptr() as *const c_void;
|
||||||
|
|
||||||
|
let attn_out = paged_decode_attention_sinks(
|
||||||
|
&q_4d, k_pool_ptr, v_pool_ptr, bt_ptr, cl_ptr,
|
||||||
|
sinks_ptr,
|
||||||
|
batch, num_heads, num_kv_heads, head_dim, max_blocks,
|
||||||
|
layer.window_size,
|
||||||
|
);
|
||||||
|
|
||||||
|
let attn_merged = attn_out.reshape(&[batch, num_heads * head_dim]);
|
||||||
|
let attn_proj = matmul_2d(&attn_merged, &layer.o_proj_wt);
|
||||||
|
self.all_reduce(&attn_proj);
|
||||||
|
let attn_proj = add_bias(&attn_proj, &layer.o_proj_bias);
|
||||||
|
|
||||||
|
|
||||||
|
// Residual + post-norm
|
||||||
|
let (normed, x_new) = Self::add_norm(&attn_proj, &residual, &layer.post_norm, &layer.post_norm_bias, eps);
|
||||||
|
|
||||||
|
let residual = x_new;
|
||||||
|
let normed = normed.contiguous();
|
||||||
|
|
||||||
|
// MoE MLP
|
||||||
|
let moe_out = self.moe_forward(&normed, layer, batch);
|
||||||
|
x = xserv_kernels::add(&residual, &moe_out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance KV cache
|
||||||
|
for &slot in seq_slots {
|
||||||
|
paged_cache.advance_seq_len(slot, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe { xserv_cuda::ffi::cudaDeviceSynchronize(); }
|
||||||
|
let x = Self::norm(&x, &self.norm, &self.norm_bias, eps);
|
||||||
|
unsafe { xserv_cuda::ffi::cudaDeviceSynchronize(); }
|
||||||
|
let logits = matmul_2d(&x, &self.lm_head_t);
|
||||||
|
unsafe { xserv_cuda::ffi::cudaDeviceSynchronize(); }
|
||||||
|
logits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paged prefill: process full prompt tokens.
|
||||||
|
pub fn forward_prefill_paged(
|
||||||
|
&self,
|
||||||
|
token_ids: &[u32],
|
||||||
|
slot: usize,
|
||||||
|
paged_cache: &mut PagedKVCache,
|
||||||
|
) -> Tensor {
|
||||||
|
let new_tokens = token_ids.len();
|
||||||
|
let pos_offset = paged_cache.seq_len(slot);
|
||||||
|
let num_heads = self.local_num_heads;
|
||||||
|
let num_kv_heads = self.local_num_kv_heads;
|
||||||
|
let head_dim = self.config.head_dim();
|
||||||
|
let eps = self.norm_eps();
|
||||||
|
|
||||||
|
paged_cache.ensure_capacity(slot, pos_offset + new_tokens);
|
||||||
|
paged_cache.advance_seq_len(slot, new_tokens);
|
||||||
|
|
||||||
|
let mut x = embedding(&self.embed_tokens, token_ids);
|
||||||
|
let positions: Vec<u32> = (pos_offset..pos_offset + new_tokens).map(|p| p as u32).collect();
|
||||||
|
|
||||||
|
for (layer_idx, layer) in self.layers.iter().enumerate() {
|
||||||
|
let residual = x.clone();
|
||||||
|
let normed = Self::norm(&x, &layer.input_norm, &layer.input_norm_bias, eps);
|
||||||
|
|
||||||
|
let q = add_bias(&matmul_2d(&normed, &layer.q_proj_wt), &layer.q_proj_bias);
|
||||||
|
let k = add_bias(&matmul_2d(&normed, &layer.k_proj_wt), &layer.k_proj_bias);
|
||||||
|
let v = add_bias(&matmul_2d(&normed, &layer.v_proj_wt), &layer.v_proj_bias);
|
||||||
|
|
||||||
|
let q = reshape_heads_gpu(&q, new_tokens, num_heads, head_dim);
|
||||||
|
let k = reshape_heads_gpu(&k, new_tokens, num_kv_heads, head_dim);
|
||||||
|
let v = reshape_heads_gpu(&v, new_tokens, num_kv_heads, head_dim);
|
||||||
|
|
||||||
|
// RoPE
|
||||||
|
let q = transpose_for_rope_gpu(&q, new_tokens, num_heads, head_dim);
|
||||||
|
let k = transpose_for_rope_gpu(&k, new_tokens, num_kv_heads, head_dim);
|
||||||
|
rope_inplace(&q, &self.rope_cache, &positions);
|
||||||
|
rope_inplace(&k, &self.rope_cache, &positions);
|
||||||
|
let q = transpose_from_rope_gpu(&q, new_tokens, num_heads, head_dim);
|
||||||
|
let k = transpose_from_rope_gpu(&k, new_tokens, num_kv_heads, head_dim);
|
||||||
|
|
||||||
|
// KV cache
|
||||||
|
paged_cache.append_tokens(slot, layer_idx, &k, &v, new_tokens, pos_offset);
|
||||||
|
let (k_full, v_full) = paged_cache.gather_kv_contiguous(slot, layer_idx);
|
||||||
|
|
||||||
|
// Flash attention with gpt-oss sinks + (per-layer) sliding window.
|
||||||
|
let attn_out = flash_attention_sinks(&q, &k_full, &v_full, &layer.sinks, layer.window_size);
|
||||||
|
|
||||||
|
let attn_merged = merge_heads_gpu(&attn_out, new_tokens, num_heads, head_dim);
|
||||||
|
let attn_proj = matmul_2d(&attn_merged, &layer.o_proj_wt);
|
||||||
|
self.all_reduce(&attn_proj);
|
||||||
|
let attn_proj = add_bias(&attn_proj, &layer.o_proj_bias);
|
||||||
|
|
||||||
|
let (normed, x_new) = Self::add_norm(&attn_proj, &residual, &layer.post_norm, &layer.post_norm_bias, eps);
|
||||||
|
let residual = x_new;
|
||||||
|
|
||||||
|
// MoE MLP
|
||||||
|
let moe_out = self.moe_forward(&normed, layer, new_tokens);
|
||||||
|
x = xserv_kernels::add(&residual, &moe_out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = Self::norm(&x, &self.norm, &self.norm_bias, eps);
|
||||||
|
let logits = matmul_2d(&x, &self.lm_head_t);
|
||||||
|
unsafe { xserv_cuda::ffi::cudaDeviceSynchronize(); }
|
||||||
|
logits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MoE forward pass — fully on GPU via batched GEMM.
|
||||||
|
///
|
||||||
|
/// Each rank owns `local_experts` experts. The input is replicated across all
|
||||||
|
/// local experts, processed via two batched cuBLAS GEMMs (gate_up and down),
|
||||||
|
/// and the selected experts' outputs are weighted-summed on GPU. Non-selected
|
||||||
|
/// experts contribute zero (via the routing weights), so no scatter/gather is
|
||||||
|
/// needed. AllReduce sums partial results across TP ranks.
|
||||||
|
fn moe_forward(&self, x: &Tensor, layer: &GptOssBlock, num_tokens: usize) -> Tensor {
|
||||||
|
let num_experts = self.config.num_experts();
|
||||||
|
let top_k = self.config.experts_per_token();
|
||||||
|
let rank = self.tp.as_ref().map(|tp| tp.rank).unwrap_or(0);
|
||||||
|
let local_experts = layer.local_experts;
|
||||||
|
let expert_start = rank * local_experts;
|
||||||
|
|
||||||
|
// 1. Router: [tokens, hidden] @ [hidden, num_experts] + bias → [tokens, num_experts]
|
||||||
|
let router_logits = add_bias(
|
||||||
|
&matmul_2d(x, &layer.router_wt),
|
||||||
|
&layer.router_bias,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. GPU top-k + softmax
|
||||||
|
let (topk_ids, topk_weights) = xserv_kernels::moe::moe_topk_softmax(
|
||||||
|
&router_logits, num_experts, top_k,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Replicate input: [tokens, hidden] → [local_experts, tokens, hidden]
|
||||||
|
let x_rep = xserv_kernels::moe::moe_replicate(x, local_experts);
|
||||||
|
|
||||||
|
// 4. Batched GEMM gate_up: [E, tokens, hidden] @ [E, hidden, 2*inter] → [E, tokens, 2*inter]
|
||||||
|
let gate_up = if let Some((ref packed, ref scales)) = layer.expert_gate_up_mxfp4 {
|
||||||
|
// MXFP4 W4A16: decode (M=1) uses the fused 4-bit dequant GEMV; prefill
|
||||||
|
// dequantizes to BF16 then reuses the batched GEMM.
|
||||||
|
let n = packed.shape()[1];
|
||||||
|
let k = packed.shape()[2] * 2;
|
||||||
|
if num_tokens == 1 {
|
||||||
|
let x2 = x_rep.reshape(&[local_experts, k]);
|
||||||
|
xserv_kernels::quantization::batched_gemv_mxfp4(&x2, packed, scales, n, k)
|
||||||
|
.reshape(&[local_experts, 1, n])
|
||||||
|
} else {
|
||||||
|
let w_bf16 = xserv_kernels::quantization::dequant_mxfp4_to_bf16_t(packed, scales, local_experts, n, k);
|
||||||
|
xserv_kernels::moe::batched_gemm_strided(&x_rep, &w_bf16)
|
||||||
|
}
|
||||||
|
} else if let Some(ref wt_fp8_t) = layer.expert_gate_up_fp8 {
|
||||||
|
// W8A8: quantize activations with per-expert scalar scale, use cuBLASLt FP8 GEMM
|
||||||
|
let (x_fp8, x_scales) = xserv_kernels::quantization::quantize_bf16_to_fp8_rowwise(&x_rep);
|
||||||
|
xserv_kernels::quantization::batched_gemm_fp8(
|
||||||
|
&x_fp8, &x_scales, wt_fp8_t, layer.expert_gate_up_scale.as_ref().unwrap(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
xserv_kernels::moe::batched_gemm_strided(&x_rep, &layer.expert_gate_up_wt)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Bias add: gate_up += expert_gate_up_bias (in-place)
|
||||||
|
xserv_kernels::moe::moe_bias_add_3d(&gate_up, &layer.expert_gate_up_bias);
|
||||||
|
|
||||||
|
// 6. GLU activation: treat [E * tokens, 2*inter] → [E * tokens, inter]
|
||||||
|
let inter2 = gate_up.shape()[2];
|
||||||
|
let flat_rows = local_experts * num_tokens;
|
||||||
|
let gate_up_flat = gate_up.reshape(&[flat_rows, inter2]);
|
||||||
|
let activated_flat = gpt_oss_glu(&gate_up_flat, layer.glu_alpha, layer.glu_limit);
|
||||||
|
let inter = inter2 / 2;
|
||||||
|
let activated = activated_flat.reshape(&[local_experts, num_tokens, inter]);
|
||||||
|
|
||||||
|
// 7. Batched GEMM down: [E, tokens, inter] @ [E, inter, hidden] → [E, tokens, hidden]
|
||||||
|
let down = if let Some((ref packed, ref scales)) = layer.expert_down_mxfp4 {
|
||||||
|
let n = packed.shape()[1];
|
||||||
|
let k = packed.shape()[2] * 2;
|
||||||
|
if num_tokens == 1 {
|
||||||
|
let a2 = activated.reshape(&[local_experts, k]);
|
||||||
|
xserv_kernels::quantization::batched_gemv_mxfp4(&a2, packed, scales, n, k)
|
||||||
|
.reshape(&[local_experts, 1, n])
|
||||||
|
} else {
|
||||||
|
let w_bf16 = xserv_kernels::quantization::dequant_mxfp4_to_bf16_t(packed, scales, local_experts, n, k);
|
||||||
|
xserv_kernels::moe::batched_gemm_strided(&activated, &w_bf16)
|
||||||
|
}
|
||||||
|
} else if let Some(ref wt_fp8) = layer.expert_down_fp8 {
|
||||||
|
// W8A8: quantize post-GLU activations to FP8, use cuBLASLt FP8 GEMM
|
||||||
|
let (act_fp8, act_scales) = xserv_kernels::quantization::quantize_bf16_to_fp8_rowwise(&activated);
|
||||||
|
xserv_kernels::quantization::batched_gemm_fp8(
|
||||||
|
&act_fp8, &act_scales, wt_fp8, layer.expert_down_scale.as_ref().unwrap(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
xserv_kernels::moe::batched_gemm_strided(&activated, &layer.expert_down_wt)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8. Bias add: down += expert_down_bias (in-place)
|
||||||
|
xserv_kernels::moe::moe_bias_add_3d(&down, &layer.expert_down_bias);
|
||||||
|
|
||||||
|
// 9. Weighted sum across experts → [tokens, hidden]
|
||||||
|
let moe_out = xserv_kernels::moe::moe_weighted_sum(
|
||||||
|
&down, &topk_ids, &topk_weights,
|
||||||
|
expert_start, local_experts, top_k,
|
||||||
|
);
|
||||||
|
|
||||||
|
self.all_reduce(&moe_out);
|
||||||
|
moe_out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
fn matmul_2d(a: &Tensor, b: &Tensor) -> Tensor {
|
||||||
|
assert_eq!(a.ndim(), 2);
|
||||||
|
assert_eq!(b.ndim(), 2);
|
||||||
|
matmul(a, b, GemmBackend::CuBlas)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add bias to a 2D tensor: [rows, cols] + [cols] → [rows, cols]
|
||||||
|
fn add_bias(x: &Tensor, bias: &Tensor) -> Tensor {
|
||||||
|
assert_eq!(x.ndim(), 2);
|
||||||
|
assert_eq!(bias.ndim(), 1);
|
||||||
|
let rows = x.shape()[0];
|
||||||
|
let cols = x.shape()[1];
|
||||||
|
assert_eq!(bias.shape()[0], cols, "bias size {} != cols {}", bias.shape()[0], cols);
|
||||||
|
|
||||||
|
let x_c = x.contiguous();
|
||||||
|
|
||||||
|
if rows == 1 {
|
||||||
|
// Fast path: reshape bias [cols] → [1, cols] (zero-copy), add directly on GPU
|
||||||
|
let bias_2d = bias.reshape(&[1, cols]);
|
||||||
|
return xserv_kernels::add(&x_c, &bias_2d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// General path: tile bias to [rows, cols] via CPU, then add on GPU
|
||||||
|
let bias_cpu = bias.to_device(Device::Cpu);
|
||||||
|
let bias_data = bias_cpu.as_slice::<bf16>();
|
||||||
|
let mut tiled = Vec::with_capacity(rows * cols);
|
||||||
|
for _ in 0..rows {
|
||||||
|
tiled.extend_from_slice(bias_data);
|
||||||
|
}
|
||||||
|
let bias_tiled = Tensor::from_slice(&tiled, &[rows, cols]).to_device(x.device());
|
||||||
|
xserv_kernels::add(&x_c, &bias_tiled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shard_rows(t: &Tensor, rank: usize, world: usize) -> Tensor {
|
||||||
|
if world == 1 { return t.clone(); }
|
||||||
|
let shape = t.shape();
|
||||||
|
assert_eq!(shape.len(), 2);
|
||||||
|
let (rows, cols) = (shape[0], shape[1]);
|
||||||
|
assert!(rows % world == 0, "rows {rows} not divisible by world {world}");
|
||||||
|
let local = rows / world;
|
||||||
|
let host = t.to_device(Device::Cpu);
|
||||||
|
let data = host.as_slice::<bf16>();
|
||||||
|
let start = rank * local * cols;
|
||||||
|
let shard = data[start..start + local * cols].to_vec();
|
||||||
|
Tensor::from_slice(&shard, &[local, cols])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shard_cols(t: &Tensor, rank: usize, world: usize) -> Tensor {
|
||||||
|
if world == 1 { return t.clone(); }
|
||||||
|
let shape = t.shape();
|
||||||
|
assert_eq!(shape.len(), 2);
|
||||||
|
let (rows, cols) = (shape[0], shape[1]);
|
||||||
|
assert!(cols % world == 0, "cols {cols} not divisible by world {world}");
|
||||||
|
let local = cols / world;
|
||||||
|
let c0 = rank * local;
|
||||||
|
let host = t.to_device(Device::Cpu);
|
||||||
|
let data = host.as_slice::<bf16>();
|
||||||
|
let mut shard = Vec::with_capacity(rows * local);
|
||||||
|
for r in 0..rows {
|
||||||
|
let base = r * cols + c0;
|
||||||
|
shard.extend_from_slice(&data[base..base + local]);
|
||||||
|
}
|
||||||
|
Tensor::from_slice(&shard, &[rows, local])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shard_1d(t: &Tensor, rank: usize, world: usize) -> Tensor {
|
||||||
|
if world == 1 { return t.clone(); }
|
||||||
|
let shape = t.shape();
|
||||||
|
assert_eq!(shape.len(), 1);
|
||||||
|
let total = shape[0];
|
||||||
|
assert!(total % world == 0, "dim {total} not divisible by world {world}");
|
||||||
|
let local = total / world;
|
||||||
|
let host = t.to_device(Device::Cpu);
|
||||||
|
let data = host.as_slice::<bf16>();
|
||||||
|
let start = rank * local;
|
||||||
|
let shard = data[start..start + local].to_vec();
|
||||||
|
Tensor::from_slice(&shard, &[local])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transpose the inner two dimensions of a [batch, rows, cols] tensor → [batch, cols, rows].
|
||||||
|
/// Works on raw bytes (any dtype). CPU-only.
|
||||||
|
fn transpose_3d_inner_raw(t: &Tensor, batch: usize, rows: usize, cols: usize) -> Tensor {
|
||||||
|
assert_eq!(t.ndim(), 3);
|
||||||
|
assert_eq!(t.shape(), &[batch, rows, cols]);
|
||||||
|
let host = t.to_device(Device::Cpu);
|
||||||
|
let es = t.dtype().size_bytes();
|
||||||
|
let raw = host.as_raw_bytes();
|
||||||
|
let mut out = vec![0u8; batch * cols * rows * es];
|
||||||
|
for b in 0..batch {
|
||||||
|
for r in 0..rows {
|
||||||
|
for c in 0..cols {
|
||||||
|
let src_off = (b * rows * cols + r * cols + c) * es;
|
||||||
|
let dst_off = (b * cols * rows + c * rows + r) * es;
|
||||||
|
out[dst_off..dst_off + es].copy_from_slice(&raw[src_off..src_off + es]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tensor::from_raw_bytes(&out, &[batch, cols, rows], t.dtype())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract experts [start..start+count) from a [num_experts, rows, cols] 3D tensor (any dtype, raw bytes).
|
||||||
|
fn slice_expert_range_3d_raw(t: &Tensor, start: usize, count: usize, rows: usize, cols: usize) -> Tensor {
|
||||||
|
assert_eq!(t.ndim(), 3);
|
||||||
|
let host = t.to_device(Device::Cpu);
|
||||||
|
let elem_size = t.dtype().size_bytes();
|
||||||
|
let raw = host.as_raw_bytes();
|
||||||
|
let stride = rows * cols * elem_size;
|
||||||
|
let offset = start * stride;
|
||||||
|
let slice = &raw[offset..offset + count * stride];
|
||||||
|
Tensor::from_raw_bytes(slice, &[count, rows, cols], t.dtype())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slice scale tensor [num_experts] F32 → [count] starting at `start`.
|
||||||
|
fn slice_scale_range(t: &Tensor, start: usize, count: usize) -> Tensor {
|
||||||
|
assert_eq!(t.ndim(), 1);
|
||||||
|
assert_eq!(t.dtype(), xserv_tensor::DType::F32);
|
||||||
|
let host = t.to_device(Device::Cpu);
|
||||||
|
let data = host.as_slice::<f32>();
|
||||||
|
let slice = data[start..start + count].to_vec();
|
||||||
|
Tensor::from_slice(&slice, &[count])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract experts [start..start+count) from a [num_experts, rows, cols] 3D tensor
|
||||||
|
fn slice_expert_range_3d(t: &Tensor, start: usize, count: usize, rows: usize, cols: usize) -> Tensor {
|
||||||
|
assert_eq!(t.ndim(), 3);
|
||||||
|
let host = t.to_device(Device::Cpu);
|
||||||
|
let data = host.as_slice::<bf16>();
|
||||||
|
let stride = rows * cols;
|
||||||
|
let offset = start * stride;
|
||||||
|
let slice = data[offset..offset + count * stride].to_vec();
|
||||||
|
Tensor::from_slice(&slice, &[count, rows, cols])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract experts [start..start+count) from a [num_experts, dim] 2D tensor
|
||||||
|
fn slice_expert_range_2d(t: &Tensor, start: usize, count: usize, dim: usize) -> Tensor {
|
||||||
|
assert_eq!(t.ndim(), 2);
|
||||||
|
let host = t.to_device(Device::Cpu);
|
||||||
|
let data = host.as_slice::<bf16>();
|
||||||
|
let offset = start * dim;
|
||||||
|
let slice = data[offset..offset + count * dim].to_vec();
|
||||||
|
Tensor::from_slice(&slice, &[count, dim])
|
||||||
|
}
|
||||||
151
crates/xserv-model/src/kv_cache.rs
Normal file
151
crates/xserv-model/src/kv_cache.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
use xserv_cuda::GpuBuffer;
|
||||||
|
use xserv_tensor::{DType, Device, Tensor};
|
||||||
|
use crate::config::ModelConfig;
|
||||||
|
|
||||||
|
/// GPU-resident KV cache. Pre-allocates max_seq_len on GPU,
|
||||||
|
/// appends new K/V via D2D copy at offset (no CPU round-trip).
|
||||||
|
pub struct GpuKVCache {
|
||||||
|
// Per layer: contiguous GPU buffer for K and V
|
||||||
|
// Layout: [num_kv_heads, max_seq_len, head_dim] — contiguous per head
|
||||||
|
k_bufs: Vec<GpuBuffer>,
|
||||||
|
v_bufs: Vec<GpuBuffer>,
|
||||||
|
// Per layer: pre-allocated staging buffers for get_kv_len output.
|
||||||
|
// Size: num_kv_heads * max_seq_len * head_dim * elem_size (max possible output).
|
||||||
|
// Avoids cudaMalloc/cudaFree on every get_kv_len call.
|
||||||
|
k_staging: Vec<GpuBuffer>,
|
||||||
|
v_staging: Vec<GpuBuffer>,
|
||||||
|
seq_len: usize,
|
||||||
|
max_seq_len: usize,
|
||||||
|
num_kv_heads: usize,
|
||||||
|
head_dim: usize,
|
||||||
|
elem_size: usize,
|
||||||
|
dtype: DType,
|
||||||
|
device: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GpuKVCache {
|
||||||
|
pub fn new(config: &ModelConfig, max_seq_len: usize, dtype: DType, device: u32) -> Self {
|
||||||
|
let num_layers = config.num_layers();
|
||||||
|
let num_kv_heads = config.num_kv_heads();
|
||||||
|
let head_dim = config.head_dim();
|
||||||
|
let elem_size = dtype.size_bytes();
|
||||||
|
let buf_size = num_kv_heads * max_seq_len * head_dim * elem_size;
|
||||||
|
|
||||||
|
let mut k_bufs = Vec::with_capacity(num_layers);
|
||||||
|
let mut v_bufs = Vec::with_capacity(num_layers);
|
||||||
|
let mut k_staging = Vec::with_capacity(num_layers);
|
||||||
|
let mut v_staging = Vec::with_capacity(num_layers);
|
||||||
|
for _ in 0..num_layers {
|
||||||
|
let mut k = GpuBuffer::alloc(buf_size).expect("alloc KV cache K");
|
||||||
|
let mut v = GpuBuffer::alloc(buf_size).expect("alloc KV cache V");
|
||||||
|
k.zero().unwrap();
|
||||||
|
v.zero().unwrap();
|
||||||
|
k_bufs.push(k);
|
||||||
|
v_bufs.push(v);
|
||||||
|
k_staging.push(GpuBuffer::alloc(buf_size).expect("alloc KV staging K"));
|
||||||
|
v_staging.push(GpuBuffer::alloc(buf_size).expect("alloc KV staging V"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { k_bufs, v_bufs, k_staging, v_staging, seq_len: 0, max_seq_len, num_kv_heads, head_dim, elem_size, dtype, device }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seq_len(&self) -> usize { self.seq_len }
|
||||||
|
pub fn max_seq_len(&self) -> usize { self.max_seq_len }
|
||||||
|
|
||||||
|
/// Append new K/V tensors for a given layer.
|
||||||
|
/// k_new, v_new: [1, num_kv_heads, new_tokens, head_dim] on GPU, contiguous.
|
||||||
|
/// `write_pos` is the sequence position to write at (caller manages this).
|
||||||
|
pub fn append(&mut self, layer: usize, k_new: &Tensor, v_new: &Tensor, new_tokens: usize, write_pos: usize) {
|
||||||
|
assert!(write_pos + new_tokens <= self.max_seq_len, "KV cache overflow");
|
||||||
|
let es = self.elem_size;
|
||||||
|
let hd = self.head_dim;
|
||||||
|
let max_s = self.max_seq_len;
|
||||||
|
let nh = self.num_kv_heads;
|
||||||
|
|
||||||
|
let k_src = k_new.storage().gpu_buffer();
|
||||||
|
let v_src = v_new.storage().gpu_buffer();
|
||||||
|
|
||||||
|
for h in 0..nh {
|
||||||
|
let src_off = h * new_tokens * hd * es;
|
||||||
|
let dst_off = (h * max_s + write_pos) * hd * es;
|
||||||
|
let count = new_tokens * hd * es;
|
||||||
|
self.k_bufs[layer].copy_from_device_at(k_src, src_off, dst_off, count).unwrap();
|
||||||
|
self.v_bufs[layer].copy_from_device_at(v_src, src_off, dst_off, count).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance_seq_len(&mut self, new_tokens: usize) {
|
||||||
|
self.seq_len += new_tokens;
|
||||||
|
assert!(self.seq_len <= self.max_seq_len, "KV cache seq_len ({}) exceeds max_seq_len ({})", self.seq_len, self.max_seq_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get K/V cache tensors for a layer up to `seq_len` tokens: [1, num_kv_heads, seq_len, head_dim]
|
||||||
|
pub fn get_kv(&mut self, layer: usize) -> (Tensor, Tensor) {
|
||||||
|
let sl = self.seq_len;
|
||||||
|
self.get_kv_len(layer, sl)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_kv_len(&mut self, layer: usize, sl: usize) -> (Tensor, Tensor) {
|
||||||
|
assert!(sl <= self.max_seq_len, "get_kv_len: sl ({sl}) exceeds max_seq_len ({})", self.max_seq_len);
|
||||||
|
let hd = self.head_dim;
|
||||||
|
let nh = self.num_kv_heads;
|
||||||
|
let es = self.elem_size;
|
||||||
|
let max_s = self.max_seq_len;
|
||||||
|
|
||||||
|
// Copy each head's valid portion into pre-allocated staging buffers.
|
||||||
|
// Split borrows: staging (mut) vs cache (shared) are separate struct fields,
|
||||||
|
// so the borrow checker allows simultaneous &mut staging + &cache.
|
||||||
|
let out_size = nh * sl * hd * es;
|
||||||
|
let k_stg = &mut self.k_staging[layer];
|
||||||
|
let k_buf = &self.k_bufs[layer];
|
||||||
|
let v_stg = &mut self.v_staging[layer];
|
||||||
|
let v_buf = &self.v_bufs[layer];
|
||||||
|
for h in 0..nh {
|
||||||
|
let src_off = (h * max_s) * hd * es;
|
||||||
|
let dst_off = (h * sl) * hd * es;
|
||||||
|
let count = sl * hd * es;
|
||||||
|
k_stg.copy_from_device_at(k_buf, src_off, dst_off, count).unwrap();
|
||||||
|
v_stg.copy_from_device_at(v_buf, src_off, dst_off, count).unwrap();
|
||||||
|
}
|
||||||
|
// Grab raw pointers before dropping the mutable borrows
|
||||||
|
let k_ptr = k_stg.as_mut_ptr();
|
||||||
|
let v_ptr = v_stg.as_mut_ptr();
|
||||||
|
|
||||||
|
// Create Tensors that borrow from the staging buffers (no cudaMalloc/cudaFree).
|
||||||
|
// Safety: staging buffers are owned by GpuKVCache and outlive the returned Tensors
|
||||||
|
// in practice (Tensors are consumed within the same forward pass before the next
|
||||||
|
// get_kv_len call overwrites the staging buffer).
|
||||||
|
let shape = &[1usize, nh, sl, hd];
|
||||||
|
let k = unsafe {
|
||||||
|
tensor_from_gpu_buffer(GpuBuffer::borrow_raw(k_ptr, out_size), shape, self.dtype, self.device)
|
||||||
|
};
|
||||||
|
let v = unsafe {
|
||||||
|
tensor_from_gpu_buffer(GpuBuffer::borrow_raw(v_ptr, out_size), shape, self.dtype, self.device)
|
||||||
|
};
|
||||||
|
(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a Tensor from a GpuBuffer (takes ownership).
|
||||||
|
unsafe fn tensor_from_gpu_buffer(buf: GpuBuffer, shape: &[usize], dtype: DType, device: u32) -> Tensor {
|
||||||
|
use xserv_tensor::storage::Storage;
|
||||||
|
use xserv_tensor::shape::contiguous_strides;
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
let storage = Storage::cuda(buf, device);
|
||||||
|
Tensor::from_storage(
|
||||||
|
storage,
|
||||||
|
SmallVec::from_slice(shape),
|
||||||
|
contiguous_strides(shape),
|
||||||
|
0,
|
||||||
|
dtype,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public version for use by other modules (e.g., batched decode concat).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `buf` must be a valid GPU allocation with at least `product(shape) * dtype.size_bytes()` bytes.
|
||||||
|
pub unsafe fn tensor_from_gpu_buffer_pub(buf: GpuBuffer, shape: &[usize], dtype: DType, device: u32) -> Tensor {
|
||||||
|
tensor_from_gpu_buffer(buf, shape, dtype, device)
|
||||||
|
}
|
||||||
@@ -1,8 +1,24 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod decode_graph;
|
||||||
pub mod gpt2;
|
pub mod gpt2;
|
||||||
|
pub mod gpt_oss;
|
||||||
|
pub mod kv_cache;
|
||||||
pub mod loader;
|
pub mod loader;
|
||||||
|
pub mod paged_kv_cache;
|
||||||
pub mod qwen3;
|
pub mod qwen3;
|
||||||
|
pub mod sampling;
|
||||||
|
|
||||||
pub use config::ModelConfig;
|
pub use config::ModelConfig;
|
||||||
|
pub use decode_graph::{DecodeGraphState, LayerWeightPtrs};
|
||||||
pub use gpt2::{GPT2, KVCache};
|
pub use gpt2::{GPT2, KVCache};
|
||||||
|
pub use gpt_oss::GptOss;
|
||||||
|
pub use kv_cache::GpuKVCache;
|
||||||
|
pub use paged_kv_cache::{BlockAllocator, Location, PagedKVCache, BLOCK_SIZE};
|
||||||
pub use qwen3::Qwen3;
|
pub use qwen3::Qwen3;
|
||||||
|
pub use sampling::{SamplingParams, sample, sample_greedy_penalized};
|
||||||
|
|
||||||
|
/// Initialize GPU kernel hooks. Called automatically by model constructors,
|
||||||
|
/// but safe to call multiple times (idempotent via OnceLock).
|
||||||
|
pub fn init_kernels() {
|
||||||
|
xserv_kernels::init();
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub fn load_safetensors(path: &Path, device: Device) -> HashMap<String, Tensor>
|
|||||||
safetensors::Dtype::F32 => DType::F32,
|
safetensors::Dtype::F32 => DType::F32,
|
||||||
safetensors::Dtype::F16 => DType::F16,
|
safetensors::Dtype::F16 => DType::F16,
|
||||||
safetensors::Dtype::BF16 => DType::BF16,
|
safetensors::Dtype::BF16 => DType::BF16,
|
||||||
|
safetensors::Dtype::F8_E4M3 => DType::FP8E4M3,
|
||||||
other => {
|
other => {
|
||||||
eprintln!("skipping tensor {name}: unsupported dtype {other:?}");
|
eprintln!("skipping tensor {name}: unsupported dtype {other:?}");
|
||||||
continue;
|
continue;
|
||||||
@@ -83,5 +84,8 @@ fn make_tensor(raw_bytes: &[u8], shape: &[usize], dtype: DType) -> Tensor {
|
|||||||
};
|
};
|
||||||
Tensor::from_slice(bfs, shape)
|
Tensor::from_slice(bfs, shape)
|
||||||
}
|
}
|
||||||
|
DType::FP8E4M3 => {
|
||||||
|
Tensor::from_raw_bytes(raw_bytes, shape, DType::FP8E4M3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
639
crates/xserv-model/src/paged_kv_cache.rs
Normal file
639
crates/xserv-model/src/paged_kv_cache.rs
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
//! Paged KV cache: vLLM-style block-based KV cache with O(1) allocation
|
||||||
|
//! and indirection via per-sequence block tables.
|
||||||
|
//!
|
||||||
|
//! Physical layout per layer:
|
||||||
|
//! K pool: [total_blocks, num_kv_heads, BLOCK_SIZE, head_dim] BF16
|
||||||
|
//! V pool: same
|
||||||
|
//!
|
||||||
|
//! Logical view per sequence: a list of physical block ids. Token at logical
|
||||||
|
//! position p lives in block_ids[p / BLOCK_SIZE] at slot (p % BLOCK_SIZE).
|
||||||
|
|
||||||
|
use crate::config::ModelConfig;
|
||||||
|
use xserv_cuda::{GpuBuffer, PinnedBuffer};
|
||||||
|
use xserv_tensor::{DType, Tensor};
|
||||||
|
|
||||||
|
pub const BLOCK_SIZE: usize = 16;
|
||||||
|
|
||||||
|
/// Stack-based block allocator: O(1) alloc/free.
|
||||||
|
pub struct BlockAllocator {
|
||||||
|
free_stack: Vec<u32>,
|
||||||
|
total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlockAllocator {
|
||||||
|
pub fn new(total_blocks: usize) -> Self {
|
||||||
|
// Reserve block 0 as a sentinel "null" block (never allocated).
|
||||||
|
// Free list contains [total-1, total-2, ..., 1] so pop returns 1 first.
|
||||||
|
// total_blocks==0 means "disabled" (e.g. swap off): empty free list.
|
||||||
|
let mut free_stack = Vec::with_capacity(total_blocks.saturating_sub(1));
|
||||||
|
for b in (1..total_blocks).rev() {
|
||||||
|
free_stack.push(b as u32);
|
||||||
|
}
|
||||||
|
Self { free_stack, total: total_blocks }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn alloc(&mut self) -> Option<u32> {
|
||||||
|
self.free_stack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn free(&mut self, block: u32) {
|
||||||
|
debug_assert!((block as usize) < self.total && block != 0);
|
||||||
|
self.free_stack.push(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn free_count(&self) -> usize {
|
||||||
|
self.free_stack.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total(&self) -> usize {
|
||||||
|
self.total
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_alloc(&self, n: usize) -> bool {
|
||||||
|
self.free_stack.len() >= n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a sequence's KV blocks currently live.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Location {
|
||||||
|
Gpu,
|
||||||
|
Cpu,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-sequence state held in the cache.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SeqState {
|
||||||
|
/// Block ids into the GPU pool when `location == Gpu`, or into the CPU
|
||||||
|
/// (pinned host) pool when `location == Cpu`.
|
||||||
|
pub block_ids: Vec<u32>,
|
||||||
|
pub seq_len: usize,
|
||||||
|
pub location: Location,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PagedKVCache {
|
||||||
|
// [layer]: GpuBuffer of size total_blocks * nkv * BLOCK_SIZE * hd * elem_size
|
||||||
|
k_pools: Vec<GpuBuffer>,
|
||||||
|
v_pools: Vec<GpuBuffer>,
|
||||||
|
|
||||||
|
// CPU (pinned host) swap pools, same per-layer layout as the GPU pools but
|
||||||
|
// sized for `cpu_total_blocks`. Empty when swap is disabled.
|
||||||
|
cpu_k_pools: Vec<PinnedBuffer>,
|
||||||
|
cpu_v_pools: Vec<PinnedBuffer>,
|
||||||
|
cpu_allocator: BlockAllocator,
|
||||||
|
|
||||||
|
// Bytes occupied by one block within a single layer pool:
|
||||||
|
// num_kv_heads * BLOCK_SIZE * head_dim * elem_size.
|
||||||
|
block_bytes: usize,
|
||||||
|
|
||||||
|
allocator: BlockAllocator,
|
||||||
|
seq_states: Vec<Option<SeqState>>,
|
||||||
|
|
||||||
|
// GPU-resident per-sequence metadata. Uploaded each step via sync_to_gpu().
|
||||||
|
// block_table_gpu: i32 [max_seqs, max_blocks_per_seq]
|
||||||
|
// context_lens_gpu: i32 [max_seqs]
|
||||||
|
block_table_gpu: GpuBuffer,
|
||||||
|
context_lens_gpu: GpuBuffer,
|
||||||
|
// Host-side staging mirroring the GPU buffers above.
|
||||||
|
block_table_host: Vec<i32>,
|
||||||
|
context_lens_host: Vec<i32>,
|
||||||
|
|
||||||
|
// Config
|
||||||
|
num_layers: usize,
|
||||||
|
num_kv_heads: usize,
|
||||||
|
head_dim: usize,
|
||||||
|
elem_size: usize,
|
||||||
|
dtype: DType,
|
||||||
|
device: u32,
|
||||||
|
max_seqs: usize,
|
||||||
|
max_blocks_per_seq: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PagedKVCache {
|
||||||
|
/// Bytes occupied by all KV blocks for ONE physical block across the whole
|
||||||
|
/// model (both K and V, all layers). Use this to size pools against VRAM.
|
||||||
|
pub fn bytes_per_block(config: &ModelConfig, dtype: DType) -> usize {
|
||||||
|
2 * config.num_layers()
|
||||||
|
* config.num_kv_heads()
|
||||||
|
* BLOCK_SIZE
|
||||||
|
* config.head_dim()
|
||||||
|
* dtype.size_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new paged cache.
|
||||||
|
/// - `total_blocks`: total number of physical GPU blocks across all sequences.
|
||||||
|
/// - `cpu_total_blocks`: physical blocks in the pinned-host swap pool (0 = swap off).
|
||||||
|
/// - `max_seqs`: max number of concurrent sequences (slots), incl. swapped.
|
||||||
|
/// - `max_blocks_per_seq`: capacity of the block table per slot
|
||||||
|
/// (must be >= ceil(max_seq_len / BLOCK_SIZE)).
|
||||||
|
pub fn new(
|
||||||
|
config: &ModelConfig,
|
||||||
|
total_blocks: usize,
|
||||||
|
cpu_total_blocks: usize,
|
||||||
|
max_seqs: usize,
|
||||||
|
max_blocks_per_seq: usize,
|
||||||
|
dtype: DType,
|
||||||
|
device: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self::new_tp(
|
||||||
|
config, config.num_kv_heads(), total_blocks, cpu_total_blocks,
|
||||||
|
max_seqs, max_blocks_per_seq, dtype, device,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `new`, but with an explicit `num_kv_heads` — under tensor parallelism
|
||||||
|
/// each rank only stores its `num_kv_heads / world` heads, so the pool is
|
||||||
|
/// sized for the local head count, not the model's full count.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new_tp(
|
||||||
|
config: &ModelConfig,
|
||||||
|
num_kv_heads: usize,
|
||||||
|
total_blocks: usize,
|
||||||
|
cpu_total_blocks: usize,
|
||||||
|
max_seqs: usize,
|
||||||
|
max_blocks_per_seq: usize,
|
||||||
|
dtype: DType,
|
||||||
|
device: u32,
|
||||||
|
) -> Self {
|
||||||
|
assert!(total_blocks >= 2, "need at least 2 blocks (one is sentinel)");
|
||||||
|
let num_layers = config.num_layers();
|
||||||
|
let head_dim = config.head_dim();
|
||||||
|
let elem_size = dtype.size_bytes();
|
||||||
|
let block_bytes = num_kv_heads * BLOCK_SIZE * head_dim * elem_size;
|
||||||
|
let pool_bytes = total_blocks * block_bytes;
|
||||||
|
|
||||||
|
let mut k_pools = Vec::with_capacity(num_layers);
|
||||||
|
let mut v_pools = Vec::with_capacity(num_layers);
|
||||||
|
for _ in 0..num_layers {
|
||||||
|
let mut k = GpuBuffer::alloc(pool_bytes).expect("alloc paged K pool");
|
||||||
|
let mut v = GpuBuffer::alloc(pool_bytes).expect("alloc paged V pool");
|
||||||
|
k.zero().unwrap();
|
||||||
|
v.zero().unwrap();
|
||||||
|
k_pools.push(k);
|
||||||
|
v_pools.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned-host swap pools (one per layer, mirroring the GPU layout).
|
||||||
|
let mut cpu_k_pools = Vec::new();
|
||||||
|
let mut cpu_v_pools = Vec::new();
|
||||||
|
if cpu_total_blocks >= 2 {
|
||||||
|
let cpu_pool_bytes = cpu_total_blocks * block_bytes;
|
||||||
|
for _ in 0..num_layers {
|
||||||
|
cpu_k_pools.push(PinnedBuffer::alloc(cpu_pool_bytes).expect("alloc CPU K swap pool"));
|
||||||
|
cpu_v_pools.push(PinnedBuffer::alloc(cpu_pool_bytes).expect("alloc CPU V swap pool"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cpu_allocator = BlockAllocator::new(if cpu_total_blocks >= 2 { cpu_total_blocks } else { 0 });
|
||||||
|
|
||||||
|
let block_table_gpu =
|
||||||
|
GpuBuffer::alloc(max_seqs * max_blocks_per_seq * std::mem::size_of::<i32>())
|
||||||
|
.expect("alloc block table");
|
||||||
|
let context_lens_gpu =
|
||||||
|
GpuBuffer::alloc(max_seqs * std::mem::size_of::<i32>()).expect("alloc context lens");
|
||||||
|
|
||||||
|
let block_table_host = vec![0i32; max_seqs * max_blocks_per_seq];
|
||||||
|
let context_lens_host = vec![0i32; max_seqs];
|
||||||
|
|
||||||
|
let seq_states = (0..max_seqs).map(|_| None).collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
k_pools,
|
||||||
|
v_pools,
|
||||||
|
cpu_k_pools,
|
||||||
|
cpu_v_pools,
|
||||||
|
cpu_allocator,
|
||||||
|
block_bytes,
|
||||||
|
allocator: BlockAllocator::new(total_blocks),
|
||||||
|
seq_states,
|
||||||
|
block_table_gpu,
|
||||||
|
context_lens_gpu,
|
||||||
|
block_table_host,
|
||||||
|
context_lens_host,
|
||||||
|
num_layers,
|
||||||
|
num_kv_heads,
|
||||||
|
head_dim,
|
||||||
|
elem_size,
|
||||||
|
dtype,
|
||||||
|
device,
|
||||||
|
max_seqs,
|
||||||
|
max_blocks_per_seq,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn num_layers(&self) -> usize { self.num_layers }
|
||||||
|
pub fn num_kv_heads(&self) -> usize { self.num_kv_heads }
|
||||||
|
pub fn head_dim(&self) -> usize { self.head_dim }
|
||||||
|
pub fn dtype(&self) -> DType { self.dtype }
|
||||||
|
pub fn max_seqs(&self) -> usize { self.max_seqs }
|
||||||
|
pub fn max_blocks_per_seq(&self) -> usize { self.max_blocks_per_seq }
|
||||||
|
pub fn free_blocks(&self) -> usize { self.allocator.free_count() }
|
||||||
|
pub fn total_blocks(&self) -> usize { self.allocator.total() }
|
||||||
|
|
||||||
|
pub fn k_pool(&self, layer: usize) -> &GpuBuffer { &self.k_pools[layer] }
|
||||||
|
pub fn v_pool(&self, layer: usize) -> &GpuBuffer { &self.v_pools[layer] }
|
||||||
|
pub fn block_table_gpu(&self) -> &GpuBuffer { &self.block_table_gpu }
|
||||||
|
pub fn context_lens_gpu(&self) -> &GpuBuffer { &self.context_lens_gpu }
|
||||||
|
|
||||||
|
pub fn seq_len(&self, slot: usize) -> usize {
|
||||||
|
self.seq_states[slot].as_ref().map(|s| s.seq_len).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_slot_free(&self, slot: usize) -> bool {
|
||||||
|
self.seq_states[slot].is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new sequence at `slot`. Allocates the first block.
|
||||||
|
/// Returns Err(()) if no slot or no blocks are available.
|
||||||
|
pub fn register_sequence(&mut self, slot: usize) -> Result<(), &'static str> {
|
||||||
|
if slot >= self.max_seqs {
|
||||||
|
return Err("slot out of range");
|
||||||
|
}
|
||||||
|
if self.seq_states[slot].is_some() {
|
||||||
|
return Err("slot already in use");
|
||||||
|
}
|
||||||
|
let block = self.allocator.alloc().ok_or("out of blocks")?;
|
||||||
|
self.seq_states[slot] = Some(SeqState {
|
||||||
|
block_ids: vec![block],
|
||||||
|
seq_len: 0,
|
||||||
|
location: Location::Gpu,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free all blocks for `slot` and clear the slot. Frees from whichever pool
|
||||||
|
/// (GPU or CPU) the sequence currently lives in.
|
||||||
|
pub fn free_sequence(&mut self, slot: usize) {
|
||||||
|
if let Some(state) = self.seq_states[slot].take() {
|
||||||
|
let alloc = match state.location {
|
||||||
|
Location::Gpu => &mut self.allocator,
|
||||||
|
Location::Cpu => &mut self.cpu_allocator,
|
||||||
|
};
|
||||||
|
for b in state.block_ids {
|
||||||
|
alloc.free(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of blocks needed to hold `seq_len + new_tokens` tokens, beyond
|
||||||
|
/// what is currently allocated for `slot`.
|
||||||
|
pub fn additional_blocks_needed(&self, slot: usize, new_tokens: usize) -> usize {
|
||||||
|
let state = self.seq_states[slot].as_ref().expect("unregistered slot");
|
||||||
|
let cur = state.block_ids.len();
|
||||||
|
let needed_total = (state.seq_len + new_tokens + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
if needed_total > cur { needed_total - cur } else { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-allocate enough physical blocks in `slot` to cover positions
|
||||||
|
/// `[0, end_pos)`. Call once before the per-layer append loop so that
|
||||||
|
/// every layer's append uses the same block table.
|
||||||
|
pub fn ensure_capacity(&mut self, slot: usize, end_pos: usize) {
|
||||||
|
let state = self.seq_states[slot].as_mut().expect("unregistered slot");
|
||||||
|
let needed_total = (end_pos + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
while state.block_ids.len() < needed_total {
|
||||||
|
let b = self.allocator.alloc().expect("out of blocks (caller must check)");
|
||||||
|
assert!(state.block_ids.len() < self.max_blocks_per_seq, "block table overflow");
|
||||||
|
state.block_ids.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append `num_tokens` of K/V into the paged pool for `slot` at logical
|
||||||
|
/// position `start_pos`. Caller must have called `ensure_capacity(slot, start_pos + num_tokens)`
|
||||||
|
/// first (or accept that this method may also extend block list).
|
||||||
|
/// Does NOT touch `seq_len`. Call `advance_seq_len(slot, num_tokens)` after
|
||||||
|
/// every layer has been written.
|
||||||
|
///
|
||||||
|
/// `k_new`, `v_new`: GPU tensors with logical shape
|
||||||
|
/// [1, num_kv_heads, num_tokens, head_dim]
|
||||||
|
/// stored contiguously (head-major, then tokens, then dim).
|
||||||
|
///
|
||||||
|
/// Implementation: a single `reshape_and_cache` kernel per call. The
|
||||||
|
/// previous Rust loop fired `num_tokens * num_kv_heads` cudaMemcpys per
|
||||||
|
/// layer (≈290k for a 1024-token Qwen3 prefill across 36 layers).
|
||||||
|
pub fn append_tokens(
|
||||||
|
&mut self,
|
||||||
|
slot: usize,
|
||||||
|
layer: usize,
|
||||||
|
k_new: &Tensor,
|
||||||
|
v_new: &Tensor,
|
||||||
|
num_tokens: usize,
|
||||||
|
start_pos: usize,
|
||||||
|
) {
|
||||||
|
if num_tokens == 0 { return; }
|
||||||
|
// Make sure blocks exist for the target range.
|
||||||
|
self.ensure_capacity(slot, start_pos + num_tokens);
|
||||||
|
|
||||||
|
let nkv = self.num_kv_heads;
|
||||||
|
let hd = self.head_dim;
|
||||||
|
let bs = BLOCK_SIZE;
|
||||||
|
|
||||||
|
// Stage block_ids on the GPU. Pool-allocated so this is essentially
|
||||||
|
// free after the first call (same bucket every step).
|
||||||
|
let block_ids: Vec<i32> = self.seq_states[slot].as_ref().unwrap()
|
||||||
|
.block_ids.iter().map(|&b| b as i32).collect();
|
||||||
|
let bytes = block_ids.len() * std::mem::size_of::<i32>();
|
||||||
|
let mut block_ids_gpu = xserv_cuda::allocator::cached_alloc(bytes)
|
||||||
|
.expect("alloc append block_ids");
|
||||||
|
let block_ids_bytes = unsafe {
|
||||||
|
std::slice::from_raw_parts(block_ids.as_ptr() as *const u8, bytes)
|
||||||
|
};
|
||||||
|
block_ids_gpu.copy_from_host(block_ids_bytes).expect("upload block_ids");
|
||||||
|
|
||||||
|
let k_src = k_new.data_ptr() as *const std::ffi::c_void;
|
||||||
|
let v_src = v_new.data_ptr() as *const std::ffi::c_void;
|
||||||
|
let k_pool_ptr = self.k_pools[layer].as_mut_ptr() as *mut std::ffi::c_void;
|
||||||
|
let v_pool_ptr = self.v_pools[layer].as_mut_ptr() as *mut std::ffi::c_void;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
xserv_kernels::reshape_and_cache_bf16(
|
||||||
|
k_src, v_src,
|
||||||
|
k_pool_ptr, v_pool_ptr,
|
||||||
|
block_ids_gpu.as_ptr() as *const i32,
|
||||||
|
num_tokens, nkv, hd, start_pos, bs,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// block_ids_gpu drops here; the launch on the null stream will have
|
||||||
|
// finished consuming it before any subsequent op alloc()s the same
|
||||||
|
// bucket (null stream is sequential).
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batched append for the multi-sequence decode step: writes one new
|
||||||
|
/// K/V token per active sequence into `layer`'s pool, using
|
||||||
|
/// `block_table_gpu` and `context_lens_gpu` directly. Caller must have
|
||||||
|
/// just run `sync_active_batch_with_lens(slots, kv_lens)` so that:
|
||||||
|
/// - row `i` of block_table_gpu holds the block ids for `slots[i]`
|
||||||
|
/// - context_lens_gpu[i] == seq_len(slots[i]) + 1 (the kv_len **after**
|
||||||
|
/// this step — i.e., the new token will be written at index kv_len-1)
|
||||||
|
///
|
||||||
|
/// `k_new`, `v_new`: GPU tensors, contiguous, BF16, shape
|
||||||
|
/// `[batch, num_kv_heads, head_dim]`.
|
||||||
|
///
|
||||||
|
/// Like `append_tokens`, this does **not** touch `seq_len`. Call
|
||||||
|
/// `advance_seq_len(slot, 1)` for each slot after every layer has been
|
||||||
|
/// written.
|
||||||
|
pub fn append_tokens_batched(
|
||||||
|
&mut self,
|
||||||
|
layer: usize,
|
||||||
|
k_new: &Tensor,
|
||||||
|
v_new: &Tensor,
|
||||||
|
batch: usize,
|
||||||
|
) {
|
||||||
|
if batch == 0 { return; }
|
||||||
|
let nkv = self.num_kv_heads;
|
||||||
|
let hd = self.head_dim;
|
||||||
|
debug_assert_eq!(k_new.shape(), &[batch, nkv, hd]);
|
||||||
|
debug_assert_eq!(v_new.shape(), &[batch, nkv, hd]);
|
||||||
|
|
||||||
|
let k_src = k_new.data_ptr() as *const std::ffi::c_void;
|
||||||
|
let v_src = v_new.data_ptr() as *const std::ffi::c_void;
|
||||||
|
let k_pool_ptr = self.k_pools[layer].as_mut_ptr() as *mut std::ffi::c_void;
|
||||||
|
let v_pool_ptr = self.v_pools[layer].as_mut_ptr() as *mut std::ffi::c_void;
|
||||||
|
let bt_ptr = self.block_table_gpu.as_ptr() as *const i32;
|
||||||
|
let cl_ptr = self.context_lens_gpu.as_ptr() as *const i32;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
xserv_kernels::reshape_and_cache_batched_bf16(
|
||||||
|
k_src, v_src,
|
||||||
|
k_pool_ptr, v_pool_ptr,
|
||||||
|
bt_ptr, cl_ptr,
|
||||||
|
batch, nkv, hd, BLOCK_SIZE, self.max_blocks_per_seq,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the logical seq_len after append_tokens for ALL layers has completed.
|
||||||
|
pub fn advance_seq_len(&mut self, slot: usize, num_tokens: usize) {
|
||||||
|
let state = self.seq_states[slot].as_mut().expect("unregistered slot");
|
||||||
|
state.seq_len += num_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the host-side block table + context lens from `seq_states`,
|
||||||
|
/// then upload to GPU. Call once per decode step before the paged kernel.
|
||||||
|
pub fn sync_to_gpu(&mut self) {
|
||||||
|
let stride = self.max_blocks_per_seq;
|
||||||
|
for slot in 0..self.max_seqs {
|
||||||
|
let row = &mut self.block_table_host[slot * stride..(slot + 1) * stride];
|
||||||
|
row.fill(0);
|
||||||
|
let len = match &self.seq_states[slot] {
|
||||||
|
Some(s) => {
|
||||||
|
for (i, b) in s.block_ids.iter().enumerate() {
|
||||||
|
row[i] = *b as i32;
|
||||||
|
}
|
||||||
|
s.seq_len as i32
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.context_lens_host[slot] = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.upload_metadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack the given active slots into rows 0..slots.len() of block_table_gpu
|
||||||
|
/// and context_lens_gpu, then upload. Used by paged decode where the kernel
|
||||||
|
/// iterates over `batch` active sequences in order.
|
||||||
|
pub fn sync_active_batch_to_gpu(&mut self, slots: &[usize]) {
|
||||||
|
let lens: Vec<i32> = slots
|
||||||
|
.iter()
|
||||||
|
.map(|&s| self.seq_states[s].as_ref().unwrap().seq_len as i32)
|
||||||
|
.collect();
|
||||||
|
self.sync_active_batch_with_lens(slots, &lens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like sync_active_batch_to_gpu but uses caller-supplied kv_lens (number
|
||||||
|
/// of valid K/V tokens to attend over per active row). Useful when the
|
||||||
|
/// kv_len for the current step differs from the cached seq_len (e.g.
|
||||||
|
/// before advance_seq_len has run).
|
||||||
|
pub fn sync_active_batch_with_lens(&mut self, slots: &[usize], kv_lens: &[i32]) {
|
||||||
|
assert_eq!(slots.len(), kv_lens.len());
|
||||||
|
assert!(slots.len() <= self.max_seqs, "active batch exceeds max_seqs");
|
||||||
|
let stride = self.max_blocks_per_seq;
|
||||||
|
for row in &mut self.block_table_host {
|
||||||
|
*row = 0;
|
||||||
|
}
|
||||||
|
for cl in &mut self.context_lens_host {
|
||||||
|
*cl = 0;
|
||||||
|
}
|
||||||
|
for (i, &slot) in slots.iter().enumerate() {
|
||||||
|
let s = self.seq_states[slot].as_ref().expect("unregistered slot in active batch");
|
||||||
|
let row = &mut self.block_table_host[i * stride..(i + 1) * stride];
|
||||||
|
for (j, b) in s.block_ids.iter().enumerate() {
|
||||||
|
row[j] = *b as i32;
|
||||||
|
}
|
||||||
|
self.context_lens_host[i] = kv_lens[i];
|
||||||
|
}
|
||||||
|
self.upload_metadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload_metadata(&mut self) {
|
||||||
|
let bt_bytes = unsafe {
|
||||||
|
std::slice::from_raw_parts(
|
||||||
|
self.block_table_host.as_ptr() as *const u8,
|
||||||
|
self.block_table_host.len() * std::mem::size_of::<i32>(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.block_table_gpu.copy_from_host(bt_bytes).unwrap();
|
||||||
|
|
||||||
|
let cl_bytes = unsafe {
|
||||||
|
std::slice::from_raw_parts(
|
||||||
|
self.context_lens_host.as_ptr() as *const u8,
|
||||||
|
self.context_lens_host.len() * std::mem::size_of::<i32>(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.context_lens_gpu.copy_from_host(cl_bytes).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materialize a contiguous K/V tensor for a sequence at `layer`, shaped
|
||||||
|
/// [1, num_kv_heads, seq_len, head_dim]. Used for prefill, where Flash
|
||||||
|
/// Attention 2 expects contiguous K/V.
|
||||||
|
///
|
||||||
|
/// Allocates from the cached allocator; the returned Tensors own their storage.
|
||||||
|
pub fn gather_kv_contiguous(&self, slot: usize, layer: usize) -> (Tensor, Tensor) {
|
||||||
|
let state = self.seq_states[slot].as_ref().expect("unregistered slot");
|
||||||
|
let sl = state.seq_len;
|
||||||
|
let nkv = self.num_kv_heads;
|
||||||
|
let hd = self.head_dim;
|
||||||
|
let es = self.elem_size;
|
||||||
|
let bs = BLOCK_SIZE;
|
||||||
|
|
||||||
|
let out_bytes = nkv * sl * hd * es;
|
||||||
|
let mut k_dst = xserv_cuda::allocator::cached_alloc(out_bytes).expect("alloc gather K");
|
||||||
|
let mut v_dst = xserv_cuda::allocator::cached_alloc(out_bytes).expect("alloc gather V");
|
||||||
|
|
||||||
|
let k_pool = &self.k_pools[layer];
|
||||||
|
let v_pool = &self.v_pools[layer];
|
||||||
|
|
||||||
|
let mut p = 0usize;
|
||||||
|
while p < sl {
|
||||||
|
let logical_blk = p / bs;
|
||||||
|
let slot_in_blk = p % bs;
|
||||||
|
let chunk = (bs - slot_in_blk).min(sl - p);
|
||||||
|
let phys = state.block_ids[logical_blk] as usize;
|
||||||
|
|
||||||
|
for h in 0..nkv {
|
||||||
|
let src_off = ((phys * nkv + h) * bs + slot_in_blk) * hd * es;
|
||||||
|
let dst_off = (h * sl + p) * hd * es;
|
||||||
|
let count = chunk * hd * es;
|
||||||
|
k_dst.copy_from_device_at(k_pool, src_off, dst_off, count).unwrap();
|
||||||
|
v_dst.copy_from_device_at(v_pool, src_off, dst_off, count).unwrap();
|
||||||
|
}
|
||||||
|
p += chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shape = &[1usize, nkv, sl, hd];
|
||||||
|
let k = unsafe { tensor_from_owned_buf(k_dst, shape, self.dtype, self.device) };
|
||||||
|
let v = unsafe { tensor_from_owned_buf(v_dst, shape, self.dtype, self.device) };
|
||||||
|
(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Swapping (vLLM-style preemption to pinned host memory) -----
|
||||||
|
|
||||||
|
pub fn free_cpu_blocks(&self) -> usize { self.cpu_allocator.free_count() }
|
||||||
|
pub fn swap_enabled(&self) -> bool { !self.cpu_k_pools.is_empty() }
|
||||||
|
|
||||||
|
pub fn is_swapped(&self, slot: usize) -> bool {
|
||||||
|
matches!(self.seq_states[slot].as_ref().map(|s| s.location), Some(Location::Cpu))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of physical blocks currently held by `slot` (in either pool).
|
||||||
|
pub fn block_count(&self, slot: usize) -> usize {
|
||||||
|
self.seq_states[slot].as_ref().map(|s| s.block_ids.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a swapped sequence at `slot` can be brought back (enough free GPU blocks).
|
||||||
|
pub fn can_swap_in(&self, slot: usize) -> bool {
|
||||||
|
self.allocator.can_alloc(self.block_count(slot))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the GPU sequence at `slot` can be evicted (enough free CPU blocks).
|
||||||
|
pub fn can_swap_out(&self, slot: usize) -> bool {
|
||||||
|
self.cpu_allocator.can_alloc(self.block_count(slot))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict `slot`'s KV from GPU to pinned host memory and free its GPU blocks.
|
||||||
|
/// The slot stays registered (location = Cpu); the sequence is paused.
|
||||||
|
pub fn swap_out(&mut self, slot: usize) -> Result<(), &'static str> {
|
||||||
|
let state = self.seq_states[slot].as_ref().ok_or("swap_out: empty slot")?;
|
||||||
|
if state.location == Location::Cpu { return Ok(()); }
|
||||||
|
let gpu_ids = state.block_ids.clone();
|
||||||
|
let n = gpu_ids.len();
|
||||||
|
if !self.cpu_allocator.can_alloc(n) { return Err("swap_out: CPU pool full"); }
|
||||||
|
|
||||||
|
let cpu_ids: Vec<u32> = (0..n)
|
||||||
|
.map(|_| self.cpu_allocator.alloc().expect("checked can_alloc"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let bb = self.block_bytes;
|
||||||
|
for layer in 0..self.num_layers {
|
||||||
|
for i in 0..n {
|
||||||
|
let g_off = gpu_ids[i] as usize * bb;
|
||||||
|
let c_off = cpu_ids[i] as usize * bb;
|
||||||
|
self.k_pools[layer]
|
||||||
|
.copy_to_host_at(&mut self.cpu_k_pools[layer].as_mut_slice()[c_off..c_off + bb], g_off, bb)
|
||||||
|
.unwrap();
|
||||||
|
self.v_pools[layer]
|
||||||
|
.copy_to_host_at(&mut self.cpu_v_pools[layer].as_mut_slice()[c_off..c_off + bb], g_off, bb)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for b in gpu_ids {
|
||||||
|
self.allocator.free(b);
|
||||||
|
}
|
||||||
|
let state = self.seq_states[slot].as_mut().unwrap();
|
||||||
|
state.block_ids = cpu_ids;
|
||||||
|
state.location = Location::Cpu;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring `slot`'s KV back from host to GPU and free its CPU blocks.
|
||||||
|
pub fn swap_in(&mut self, slot: usize) -> Result<(), &'static str> {
|
||||||
|
let state = self.seq_states[slot].as_ref().ok_or("swap_in: empty slot")?;
|
||||||
|
if state.location == Location::Gpu { return Ok(()); }
|
||||||
|
let cpu_ids = state.block_ids.clone();
|
||||||
|
let n = cpu_ids.len();
|
||||||
|
if !self.allocator.can_alloc(n) { return Err("swap_in: GPU pool full"); }
|
||||||
|
|
||||||
|
let gpu_ids: Vec<u32> = (0..n)
|
||||||
|
.map(|_| self.allocator.alloc().expect("checked can_alloc"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let bb = self.block_bytes;
|
||||||
|
for layer in 0..self.num_layers {
|
||||||
|
for i in 0..n {
|
||||||
|
let g_off = gpu_ids[i] as usize * bb;
|
||||||
|
let c_off = cpu_ids[i] as usize * bb;
|
||||||
|
self.k_pools[layer]
|
||||||
|
.copy_from_host_at(&self.cpu_k_pools[layer].as_slice()[c_off..c_off + bb], g_off, bb)
|
||||||
|
.unwrap();
|
||||||
|
self.v_pools[layer]
|
||||||
|
.copy_from_host_at(&self.cpu_v_pools[layer].as_slice()[c_off..c_off + bb], g_off, bb)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for b in cpu_ids {
|
||||||
|
self.cpu_allocator.free(b);
|
||||||
|
}
|
||||||
|
let state = self.seq_states[slot].as_mut().unwrap();
|
||||||
|
state.block_ids = gpu_ids;
|
||||||
|
state.location = Location::Gpu;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn tensor_from_owned_buf(buf: GpuBuffer, shape: &[usize], dtype: DType, device: u32) -> Tensor {
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
use xserv_tensor::shape::contiguous_strides;
|
||||||
|
use xserv_tensor::storage::Storage;
|
||||||
|
|
||||||
|
let storage = Storage::cuda(buf, device);
|
||||||
|
Tensor::from_storage(
|
||||||
|
storage,
|
||||||
|
SmallVec::from_slice(shape),
|
||||||
|
contiguous_strides(shape),
|
||||||
|
0,
|
||||||
|
dtype,
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
162
crates/xserv-model/src/sampling.rs
Normal file
162
crates/xserv-model/src/sampling.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use half::bf16;
|
||||||
|
use rand::Rng;
|
||||||
|
use xserv_tensor::{DType, Device, Tensor};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SamplingParams {
|
||||||
|
pub temperature: f32,
|
||||||
|
pub top_k: usize,
|
||||||
|
pub top_p: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SamplingParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { temperature: 0.0, top_k: 0, top_p: 1.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample a token from logits with shape [seq_len, vocab_size].
|
||||||
|
/// Uses the last position's logits. Handles both F32 and BF16 dtypes.
|
||||||
|
pub fn sample(logits: &Tensor, params: &SamplingParams) -> u32 {
|
||||||
|
assert_eq!(logits.ndim(), 2);
|
||||||
|
let vocab_size = logits.shape()[1];
|
||||||
|
let seq_len = logits.shape()[0];
|
||||||
|
let logits_cpu = logits.to_device(Device::Cpu);
|
||||||
|
|
||||||
|
// Extract last row as f32
|
||||||
|
let last_row: Vec<f32> = match logits.dtype() {
|
||||||
|
DType::F32 => {
|
||||||
|
let data = logits_cpu.as_slice::<f32>();
|
||||||
|
data[(seq_len - 1) * vocab_size..seq_len * vocab_size].to_vec()
|
||||||
|
}
|
||||||
|
DType::BF16 => {
|
||||||
|
let data = logits_cpu.as_slice::<bf16>();
|
||||||
|
data[(seq_len - 1) * vocab_size..seq_len * vocab_size]
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.to_f32())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
_ => panic!("unsupported dtype for sampling: {:?}", logits.dtype()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Greedy
|
||||||
|
if params.temperature == 0.0 {
|
||||||
|
return argmax(&last_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply temperature
|
||||||
|
let mut logits_f32: Vec<f32> = last_row.iter().map(|v| v / params.temperature).collect();
|
||||||
|
|
||||||
|
// Top-k filtering
|
||||||
|
if params.top_k > 0 && params.top_k < vocab_size {
|
||||||
|
let mut indices: Vec<usize> = (0..vocab_size).collect();
|
||||||
|
indices.select_nth_unstable_by(params.top_k, |&a, &b| {
|
||||||
|
logits_f32[b].partial_cmp(&logits_f32[a]).unwrap()
|
||||||
|
});
|
||||||
|
// Everything after top_k should be masked
|
||||||
|
for &i in &indices[params.top_k..] {
|
||||||
|
logits_f32[i] = f32::NEG_INFINITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-p (nucleus) filtering
|
||||||
|
if params.top_p < 1.0 {
|
||||||
|
// Sort indices by descending logit value
|
||||||
|
let mut indices: Vec<usize> = (0..vocab_size).collect();
|
||||||
|
indices.sort_unstable_by(|&a, &b| logits_f32[b].partial_cmp(&logits_f32[a]).unwrap());
|
||||||
|
|
||||||
|
// Compute softmax probabilities for the sorted order
|
||||||
|
let max_val = logits_f32[indices[0]];
|
||||||
|
let sorted_probs: Vec<f32> = indices
|
||||||
|
.iter()
|
||||||
|
.map(|&i| (logits_f32[i] - max_val).exp())
|
||||||
|
.collect();
|
||||||
|
let sum: f32 = sorted_probs.iter().sum();
|
||||||
|
let sorted_probs: Vec<f32> = sorted_probs.iter().map(|v| v / sum).collect();
|
||||||
|
|
||||||
|
// Cumulative sum, find cutoff
|
||||||
|
let mut cumsum = 0.0f32;
|
||||||
|
let mut cutoff = indices.len();
|
||||||
|
for (rank, &prob) in sorted_probs.iter().enumerate() {
|
||||||
|
cumsum += prob;
|
||||||
|
if cumsum > params.top_p {
|
||||||
|
cutoff = rank + 1; // keep at least this many
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask everything beyond cutoff
|
||||||
|
for &i in &indices[cutoff..] {
|
||||||
|
logits_f32[i] = f32::NEG_INFINITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Softmax
|
||||||
|
let max_val = logits_f32.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
let exps: Vec<f32> = logits_f32.iter().map(|v| (v - max_val).exp()).collect();
|
||||||
|
let sum: f32 = exps.iter().sum();
|
||||||
|
let probs: Vec<f32> = exps.iter().map(|v| v / sum).collect();
|
||||||
|
|
||||||
|
// Weighted random sampling
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let r: f32 = rng.r#gen();
|
||||||
|
let mut cumsum = 0.0f32;
|
||||||
|
for (i, &p) in probs.iter().enumerate() {
|
||||||
|
cumsum += p;
|
||||||
|
if cumsum > r {
|
||||||
|
return i as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (rounding edge case)
|
||||||
|
(vocab_size - 1) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Greedy argmax with a repetition penalty applied to `recent` token ids
|
||||||
|
/// (HF-style: divide positive logits by `penalty`, multiply negative by it).
|
||||||
|
/// `penalty <= 1.0` is a no-op. Mitigates greedy repetition loops on reasoning
|
||||||
|
/// models without changing the forward pass. NaN-safe.
|
||||||
|
pub fn sample_greedy_penalized(logits: &Tensor, recent: &[u32], penalty: f32) -> u32 {
|
||||||
|
assert_eq!(logits.ndim(), 2);
|
||||||
|
let vocab_size = logits.shape()[1];
|
||||||
|
let seq_len = logits.shape()[0];
|
||||||
|
let logits_cpu = logits.to_device(Device::Cpu);
|
||||||
|
let mut last_row: Vec<f32> = match logits.dtype() {
|
||||||
|
DType::F32 => logits_cpu.as_slice::<f32>()[(seq_len - 1) * vocab_size..seq_len * vocab_size].to_vec(),
|
||||||
|
DType::BF16 => logits_cpu.as_slice::<bf16>()[(seq_len - 1) * vocab_size..seq_len * vocab_size]
|
||||||
|
.iter().map(|v| v.to_f32()).collect(),
|
||||||
|
_ => panic!("unsupported dtype for sampling: {:?}", logits.dtype()),
|
||||||
|
};
|
||||||
|
if penalty > 1.0 {
|
||||||
|
for &id in recent {
|
||||||
|
let i = id as usize;
|
||||||
|
if i < last_row.len() {
|
||||||
|
let v = last_row[i];
|
||||||
|
last_row[i] = if v > 0.0 { v / penalty } else { v * penalty };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
argmax(&last_row)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn argmax(data: &[f32]) -> u32 {
|
||||||
|
// NaN-safe: a single NaN logit must not crash the engine thread (a
|
||||||
|
// partial_cmp().unwrap() panics on NaN). Skip NaNs; warn once if seen.
|
||||||
|
let mut best_i = 0usize;
|
||||||
|
let mut best = f32::NEG_INFINITY;
|
||||||
|
let mut nan_seen = false;
|
||||||
|
for (i, &v) in data.iter().enumerate() {
|
||||||
|
if v.is_nan() {
|
||||||
|
nan_seen = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if v > best {
|
||||||
|
best = v;
|
||||||
|
best_i = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nan_seen {
|
||||||
|
eprintln!("[sampling] WARNING: NaN logits encountered in argmax");
|
||||||
|
}
|
||||||
|
best_i as u32
|
||||||
|
}
|
||||||
24
crates/xserv-server/Cargo.toml
Normal file
24
crates/xserv-server/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "xserv-server"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "xserv-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
xserv-cuda = { path = "../xserv-cuda" }
|
||||||
|
xserv-tensor = { path = "../xserv-tensor" }
|
||||||
|
xserv-kernels = { path = "../xserv-kernels" }
|
||||||
|
xserv-model = { path = "../xserv-model" }
|
||||||
|
xserv-tokenizer = { path = "../xserv-tokenizer" }
|
||||||
|
xserv-distributed = { path = "../xserv-distributed" }
|
||||||
|
half.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
tokio-stream.workspace = true
|
||||||
|
minijinja.workspace = true
|
||||||
516
crates/xserv-server/src/api.rs
Normal file
516
crates/xserv-server/src/api.rs
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::engine::{GenerateEvent, GenerateRequest};
|
||||||
|
use xserv_model::SamplingParams;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
#[serde(default = "default_max_tokens")]
|
||||||
|
pub max_tokens: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stream: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub top_k: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub top_p: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub struct Message {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_tokens() -> usize {
|
||||||
|
256
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ModelsResponse {
|
||||||
|
object: &'static str,
|
||||||
|
data: Vec<ModelInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ModelInfo {
|
||||||
|
id: String,
|
||||||
|
object: &'static str,
|
||||||
|
owned_by: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chat Template: Jinja2 rendering via minijinja
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct ChatTemplate {
|
||||||
|
source: String,
|
||||||
|
model_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatTemplate {
|
||||||
|
pub fn load(model_dir: &Path, model_type: &str) -> Self {
|
||||||
|
// 1. Try standalone chat_template.jinja file
|
||||||
|
let jinja_path = model_dir.join("chat_template.jinja");
|
||||||
|
if jinja_path.exists() {
|
||||||
|
let source = std::fs::read_to_string(&jinja_path)
|
||||||
|
.unwrap_or_else(|e| panic!("failed to read {}: {e}", jinja_path.display()));
|
||||||
|
eprintln!("[chat-template] loaded from {}", jinja_path.display());
|
||||||
|
return Self { source, model_type: model_type.to_string() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try tokenizer_config.json → chat_template field
|
||||||
|
let tok_cfg_path = model_dir.join("tokenizer_config.json");
|
||||||
|
if tok_cfg_path.exists() {
|
||||||
|
if let Ok(data) = std::fs::read_to_string(&tok_cfg_path) {
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) {
|
||||||
|
if let Some(ct) = v.get("chat_template").and_then(|v| v.as_str()) {
|
||||||
|
eprintln!("[chat-template] loaded from tokenizer_config.json");
|
||||||
|
return Self { source: ct.to_string(), model_type: model_type.to_string() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No template found — use empty source, will fall back to hardcoded
|
||||||
|
eprintln!("[chat-template] no Jinja template found, using hardcoded fallback");
|
||||||
|
Self { source: String::new(), model_type: model_type.to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(&self, messages: &[Message]) -> String {
|
||||||
|
if self.source.is_empty() {
|
||||||
|
return build_prompt_hardcoded(messages, &self.model_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.render_jinja(messages) {
|
||||||
|
Ok(prompt) => prompt,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[chat-template] Jinja render error: {e}, falling back to hardcoded");
|
||||||
|
build_prompt_hardcoded(messages, &self.model_type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_jinja(&self, messages: &[Message]) -> Result<String, minijinja::Error> {
|
||||||
|
let mut env = minijinja::Environment::new();
|
||||||
|
|
||||||
|
// Register custom functions the template may call.
|
||||||
|
env.add_function("strftime_now", strftime_now);
|
||||||
|
env.add_function("raise_exception", raise_exception);
|
||||||
|
|
||||||
|
// Python str methods used by harmony/gpt-oss templates.
|
||||||
|
env.add_filter("startswith", |s: String, prefix: String| -> bool {
|
||||||
|
s.starts_with(&prefix)
|
||||||
|
});
|
||||||
|
|
||||||
|
env.add_template("chat", &self.source)?;
|
||||||
|
let tmpl = env.get_template("chat")?;
|
||||||
|
|
||||||
|
let ctx = minijinja::context! {
|
||||||
|
messages => minijinja::Value::from_serialize(messages),
|
||||||
|
add_generation_prompt => true,
|
||||||
|
bos_token => "",
|
||||||
|
eos_token => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
tmpl.render(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strftime_now(fmt: String) -> String {
|
||||||
|
use std::time::SystemTime;
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
// Only support %Y-%m-%d (the only format used by known templates)
|
||||||
|
let days = now / 86400;
|
||||||
|
let (y, m, d) = days_to_ymd(days);
|
||||||
|
fmt.replace("%Y", &format!("{y:04}"))
|
||||||
|
.replace("%m", &format!("{m:02}"))
|
||||||
|
.replace("%d", &format!("{d:02}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn days_to_ymd(days_since_epoch: u64) -> (u32, u32, u32) {
|
||||||
|
// Civil calendar from days since 1970-01-01 (Rata Die algorithm)
|
||||||
|
let z = days_since_epoch as i64 + 719468;
|
||||||
|
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
|
||||||
|
let doe = (z - era * 146097) as u32;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||||
|
let y = yoe as i64 + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let y = if m <= 2 { y + 1 } else { y };
|
||||||
|
(y as u32, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raise_exception(msg: String) -> Result<String, minijinja::Error> {
|
||||||
|
Err(minijinja::Error::new(
|
||||||
|
minijinja::ErrorKind::InvalidOperation,
|
||||||
|
msg,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hardcoded fallback templates (for models without a Jinja template)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn build_prompt_hardcoded(messages: &[Message], model_type: &str) -> String {
|
||||||
|
if model_type == "gpt_oss" {
|
||||||
|
return build_prompt_gpt_oss(messages);
|
||||||
|
}
|
||||||
|
// Default: Qwen3 ChatML format
|
||||||
|
let mut prompt = String::new();
|
||||||
|
for msg in messages {
|
||||||
|
match msg.role.as_str() {
|
||||||
|
"system" | "user" | "assistant" => {
|
||||||
|
prompt.push_str("<|im_start|>");
|
||||||
|
prompt.push_str(&msg.role);
|
||||||
|
prompt.push('\n');
|
||||||
|
prompt.push_str(&msg.content);
|
||||||
|
prompt.push_str("<|im_end|>\n");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompt.push_str("<|im_start|>assistant\n");
|
||||||
|
prompt.push_str("<think>\n\n</think>\n\n");
|
||||||
|
prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_prompt_gpt_oss(messages: &[Message]) -> String {
|
||||||
|
let mut prompt = String::new();
|
||||||
|
// Canonical harmony system message (mirrors the model's chat_template.jinja
|
||||||
|
// build_system_message macro). A hand-rolled substitute puts gpt-oss out of
|
||||||
|
// distribution and destabilizes channel selection. This hardcoded builder is
|
||||||
|
// only a fallback for gpt-oss models that ship no Jinja template; the
|
||||||
|
// gpt-oss-20b release does ship one, so the template path is normally used.
|
||||||
|
prompt.push_str("<|start|>system<|message|>");
|
||||||
|
prompt.push_str("You are ChatGPT, a large language model trained by OpenAI.\n");
|
||||||
|
prompt.push_str("Knowledge cutoff: 2024-06\n");
|
||||||
|
prompt.push_str(&format!("Current date: {}\n\n", strftime_now("%Y-%m-%d".to_string())));
|
||||||
|
prompt.push_str("Reasoning: low\n\n");
|
||||||
|
prompt.push_str("# Valid channels: analysis, commentary, final. Channel must be included for every message.");
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
let dev_instructions: String = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "system")
|
||||||
|
.map(|m| m.content.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n");
|
||||||
|
if !dev_instructions.is_empty() {
|
||||||
|
prompt.push_str("<|start|>developer<|message|># Instructions\n\n");
|
||||||
|
prompt.push_str(&dev_instructions);
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
}
|
||||||
|
for msg in messages {
|
||||||
|
match msg.role.as_str() {
|
||||||
|
"user" => {
|
||||||
|
prompt.push_str("<|start|>user<|message|>");
|
||||||
|
prompt.push_str(&msg.content);
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
}
|
||||||
|
"assistant" => {
|
||||||
|
prompt.push_str("<|start|>assistant<|channel|>final<|message|>");
|
||||||
|
prompt.push_str(&msg.content);
|
||||||
|
prompt.push_str("<|end|>");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompt.push_str("<|start|>assistant<|channel|>final<|message|>");
|
||||||
|
prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn health() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_models(Extension(state): Extension<Arc<AppState>>) -> Json<ModelsResponse> {
|
||||||
|
Json(ModelsResponse {
|
||||||
|
object: "list",
|
||||||
|
data: vec![ModelInfo {
|
||||||
|
id: state.model_name.clone(),
|
||||||
|
object: "model",
|
||||||
|
owned_by: "xserv",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat_completions(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
Json(req): Json<ChatRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if req.stream == Some(true) {
|
||||||
|
chat_stream(state, req)
|
||||||
|
} else {
|
||||||
|
chat_non_stream(state, req).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_non_stream(state: Arc<AppState>, req: ChatRequest) -> Response {
|
||||||
|
let id = format!("chatcmpl-{}", Uuid::new_v4());
|
||||||
|
let model_name = state.model_name.clone();
|
||||||
|
let created = unix_timestamp();
|
||||||
|
|
||||||
|
if let Some(response) = validate_request(&req, &model_name) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = state.chat_template.render(&req.messages);
|
||||||
|
let prompt_tokens = state.engine_tokenizer.lock().unwrap().encode(&prompt);
|
||||||
|
let prompt_token_count = prompt_tokens.len();
|
||||||
|
|
||||||
|
let max_seq_len = state.max_seq_len;
|
||||||
|
if prompt_token_count >= max_seq_len {
|
||||||
|
return bad_request(format!(
|
||||||
|
"prompt is {} tokens, exceeds max_seq_len {}",
|
||||||
|
prompt_token_count, max_seq_len
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let max_tokens = req.max_tokens.min(max_seq_len - prompt_token_count);
|
||||||
|
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<GenerateEvent>(64);
|
||||||
|
let gen_req = GenerateRequest {
|
||||||
|
prompt_tokens,
|
||||||
|
max_tokens,
|
||||||
|
sampling: sampling_params(&req),
|
||||||
|
sender: tx,
|
||||||
|
};
|
||||||
|
if let Err(resp) = submit_to_engine(&state, gen_req) {
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut content = String::new();
|
||||||
|
let mut completion_token_count: usize = 0;
|
||||||
|
let mut finish_reason = "length".to_string();
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
match event {
|
||||||
|
GenerateEvent::Token { text, .. } => {
|
||||||
|
completion_token_count += 1;
|
||||||
|
content.push_str(&text);
|
||||||
|
}
|
||||||
|
GenerateEvent::Done { finish_reason: fr } => {
|
||||||
|
finish_reason = fr;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": created,
|
||||||
|
"model": model_name,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": { "role": "assistant", "content": content },
|
||||||
|
"finish_reason": finish_reason,
|
||||||
|
}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": prompt_token_count,
|
||||||
|
"completion_tokens": completion_token_count,
|
||||||
|
"total_tokens": prompt_token_count + completion_token_count
|
||||||
|
}
|
||||||
|
})).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_stream(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
req: ChatRequest,
|
||||||
|
) -> Response {
|
||||||
|
let id = format!("chatcmpl-{}", Uuid::new_v4());
|
||||||
|
let model_name = state.model_name.clone();
|
||||||
|
let created = unix_timestamp();
|
||||||
|
|
||||||
|
if let Some(response) = validate_request(&req, &model_name) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = state.chat_template.render(&req.messages);
|
||||||
|
let prompt_tokens = state.engine_tokenizer.lock().unwrap().encode(&prompt);
|
||||||
|
|
||||||
|
let max_seq_len = state.max_seq_len;
|
||||||
|
if prompt_tokens.len() >= max_seq_len {
|
||||||
|
return bad_request(format!(
|
||||||
|
"prompt is {} tokens, exceeds max_seq_len {}",
|
||||||
|
prompt_tokens.len(), max_seq_len
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let max_tokens = req.max_tokens.min(max_seq_len - prompt_tokens.len());
|
||||||
|
|
||||||
|
let (engine_tx, engine_rx) = tokio::sync::mpsc::channel::<GenerateEvent>(64);
|
||||||
|
let gen_req = GenerateRequest {
|
||||||
|
prompt_tokens,
|
||||||
|
max_tokens,
|
||||||
|
sampling: sampling_params(&req),
|
||||||
|
sender: engine_tx,
|
||||||
|
};
|
||||||
|
if let Err(resp) = submit_to_engine(&state, gen_req) {
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE event channel: engine events -> SSE events
|
||||||
|
let (sse_tx, sse_rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(64);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut engine_stream = ReceiverStream::new(engine_rx);
|
||||||
|
let mut first = true;
|
||||||
|
|
||||||
|
while let Some(event) = engine_stream.next().await {
|
||||||
|
match event {
|
||||||
|
GenerateEvent::Token { text, .. } => {
|
||||||
|
if first {
|
||||||
|
// First chunk: role announcement
|
||||||
|
let chunk =
|
||||||
|
make_chunk(&id, &model_name, created, None, Some("assistant"), None);
|
||||||
|
let _ = sse_tx.send(Ok(Event::default().data(chunk))).await;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
let chunk = make_chunk(&id, &model_name, created, Some(&text), None, None);
|
||||||
|
if sse_tx.send(Ok(Event::default().data(chunk))).await.is_err() {
|
||||||
|
return; // client disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GenerateEvent::Done { finish_reason } => {
|
||||||
|
if first {
|
||||||
|
// Edge case: Done arrived with no tokens
|
||||||
|
let chunk =
|
||||||
|
make_chunk(&id, &model_name, created, None, Some("assistant"), None);
|
||||||
|
let _ = sse_tx.send(Ok(Event::default().data(chunk))).await;
|
||||||
|
}
|
||||||
|
let chunk =
|
||||||
|
make_chunk(&id, &model_name, created, None, None, Some(&finish_reason));
|
||||||
|
let _ = sse_tx.send(Ok(Event::default().data(chunk))).await;
|
||||||
|
let _ = sse_tx
|
||||||
|
.send(Ok(Event::default().data("[DONE]".to_string())))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Sse::new(ReceiverStream::new(sse_rx)).keep_alive(KeepAlive::default()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_request(req: &ChatRequest, model_name: &str) -> Option<Response> {
|
||||||
|
if let Some(model) = &req.model {
|
||||||
|
if model != model_name {
|
||||||
|
return Some(bad_request(format!(
|
||||||
|
"model '{model}' is not loaded; available model is '{model_name}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.max_tokens == 0 {
|
||||||
|
return Some(bad_request("max_tokens must be greater than 0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand a request to the engine thread. Poison-tolerant (recovers the lock if a
|
||||||
|
/// prior handler panicked) and returns a clean 503 instead of panicking when the
|
||||||
|
/// engine thread is gone, so one dead engine doesn't cascade into every request.
|
||||||
|
fn submit_to_engine(state: &AppState, req: GenerateRequest) -> Result<(), Response> {
|
||||||
|
let sender = state.engine_sender.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
sender.send(req).map_err(|_| service_unavailable("inference engine is not available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_unavailable(message: impl Into<String>) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": { "message": message.into(), "type": "server_error" }
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_request(message: impl Into<String>) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"message": message.into(),
|
||||||
|
"type": "invalid_request_error"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_chunk(
|
||||||
|
id: &str,
|
||||||
|
model: &str,
|
||||||
|
created: u64,
|
||||||
|
content: Option<&str>,
|
||||||
|
role: Option<&str>,
|
||||||
|
finish_reason: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let mut delta = serde_json::Map::new();
|
||||||
|
if let Some(r) = role {
|
||||||
|
delta.insert("role".into(), serde_json::Value::String(r.into()));
|
||||||
|
// Role chunk also includes empty content per OpenAI spec
|
||||||
|
delta.insert("content".into(), serde_json::Value::String(String::new()));
|
||||||
|
}
|
||||||
|
if let Some(c) = content {
|
||||||
|
delta.insert("content".into(), serde_json::Value::String(c.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let fr = match finish_reason {
|
||||||
|
Some(r) => serde_json::Value::String(r.into()),
|
||||||
|
None => serde_json::Value::Null,
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": created,
|
||||||
|
"model": model,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": delta,
|
||||||
|
"finish_reason": fr,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_timestamp() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sampling_params(req: &ChatRequest) -> SamplingParams {
|
||||||
|
SamplingParams {
|
||||||
|
temperature: req.temperature.unwrap_or(0.0),
|
||||||
|
top_k: req.top_k.unwrap_or(0),
|
||||||
|
top_p: req.top_p.unwrap_or(1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
379
crates/xserv-server/src/engine.rs
Normal file
379
crates/xserv-server/src/engine.rs
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::Once;
|
||||||
|
use std::time::Instant;
|
||||||
|
use xserv_model::{ModelConfig, PagedKVCache, Qwen3, SamplingParams, sample, BLOCK_SIZE};
|
||||||
|
use xserv_model::loader;
|
||||||
|
use xserv_tensor::{DType, Device};
|
||||||
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
|
pub struct Engine {
|
||||||
|
model: Qwen3,
|
||||||
|
config: ModelConfig,
|
||||||
|
tokenizer: Tokenizer,
|
||||||
|
max_batch_size: usize,
|
||||||
|
max_seq_len: usize,
|
||||||
|
paged_cache: PagedKVCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GenerateRequest {
|
||||||
|
pub prompt_tokens: Vec<u32>,
|
||||||
|
pub max_tokens: usize,
|
||||||
|
pub sampling: SamplingParams,
|
||||||
|
pub sender: tokio::sync::mpsc::Sender<GenerateEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum GenerateEvent {
|
||||||
|
Token { id: u32, text: String },
|
||||||
|
Done { finish_reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sequence {
|
||||||
|
id: u64,
|
||||||
|
prompt_tokens: Vec<u32>,
|
||||||
|
generated_tokens: Vec<u32>,
|
||||||
|
max_tokens: usize,
|
||||||
|
sampling: SamplingParams,
|
||||||
|
seq_slot: Option<usize>,
|
||||||
|
sender: tokio::sync::mpsc::Sender<GenerateEvent>,
|
||||||
|
prefilled: bool,
|
||||||
|
eos_token_id: Option<u32>,
|
||||||
|
decode_buffer: Vec<u8>,
|
||||||
|
created_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn load(model_dir: &Path, max_batch_size: usize, max_seq_len: usize) -> Self {
|
||||||
|
Self::load_with_swap(model_dir, max_batch_size, max_seq_len, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_with_swap(
|
||||||
|
model_dir: &Path,
|
||||||
|
max_batch_size: usize,
|
||||||
|
max_seq_len: usize,
|
||||||
|
swap_space_gb: usize,
|
||||||
|
) -> Self {
|
||||||
|
xserv_cuda::device::set_device(0).unwrap();
|
||||||
|
let config = ModelConfig::from_file(&model_dir.join("config.json"));
|
||||||
|
eprintln!("[engine] Loading weights...");
|
||||||
|
let weights = loader::load_model_dir(model_dir, Device::Cuda(0));
|
||||||
|
eprintln!("[engine] Loaded {} tensors", weights.len());
|
||||||
|
let model = Qwen3::from_weights(config.clone(), weights);
|
||||||
|
let tokenizer = Tokenizer::from_file(&model_dir.join("tokenizer.json"));
|
||||||
|
|
||||||
|
// Tier-1 sizing: size the GPU block pool to *available VRAM* after the
|
||||||
|
// weights are resident, not to worst-case max_batch * max_ctx. This is
|
||||||
|
// what makes paged attention elastic — sequences share the pool on
|
||||||
|
// demand, and overflow is swapped to host (Tier-2) rather than reserved.
|
||||||
|
let bytes_per_block = PagedKVCache::bytes_per_block(&config, DType::BF16);
|
||||||
|
let info = xserv_cuda::device::device_info(0).expect("device info");
|
||||||
|
// Reserve headroom for activations, cuBLAS workspace and the [B, vocab]
|
||||||
|
// logits buffer; the transpose peak during load is already behind us.
|
||||||
|
const ACTIVATION_RESERVE: usize = 3 * 1024 * 1024 * 1024; // 3 GiB
|
||||||
|
let util_num = 90; // use 90% of remaining free memory for KV
|
||||||
|
let usable = info.free_memory.saturating_sub(ACTIVATION_RESERVE);
|
||||||
|
let mut total_blocks = (usable * util_num / 100) / bytes_per_block;
|
||||||
|
// Cap at a sane upper bound and ensure a floor.
|
||||||
|
total_blocks = total_blocks.max(256);
|
||||||
|
// Test hook: force a small GPU pool to exercise the swap path. Must stay
|
||||||
|
// >= max_blocks_per_seq so a single max-length sequence still fits.
|
||||||
|
if let Ok(v) = std::env::var("XSERV_MAX_KV_BLOCKS") {
|
||||||
|
if let Ok(n) = v.parse::<usize>() {
|
||||||
|
total_blocks = total_blocks.min(n);
|
||||||
|
eprintln!("[engine] XSERV_MAX_KV_BLOCKS override: gpu_blocks={total_blocks}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_blocks_per_seq = (max_seq_len + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
// Slots must cover running + swapped sequences, so be generous (cheap:
|
||||||
|
// each slot is just a block-table row of i32s).
|
||||||
|
let max_seqs_slots = (max_batch_size * 8).max(32);
|
||||||
|
// CPU swap pool: swap_space_gb of pinned host memory.
|
||||||
|
let cpu_total_blocks = (swap_space_gb * 1024 * 1024 * 1024) / bytes_per_block;
|
||||||
|
|
||||||
|
let paged_cache = PagedKVCache::new(
|
||||||
|
&config,
|
||||||
|
total_blocks,
|
||||||
|
cpu_total_blocks,
|
||||||
|
max_seqs_slots,
|
||||||
|
max_blocks_per_seq,
|
||||||
|
DType::BF16,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[engine] Ready (max_batch={max_batch_size}, max_seq_len={max_seq_len}, \
|
||||||
|
gpu_blocks={total_blocks} ({:.1} GiB), swap_blocks={cpu_total_blocks} ({swap_space_gb} GiB), \
|
||||||
|
free_vram={:.1} GiB)",
|
||||||
|
(total_blocks * bytes_per_block) as f64 / 1e9,
|
||||||
|
info.free_memory as f64 / 1e9,
|
||||||
|
);
|
||||||
|
Self { model, config, tokenizer, max_batch_size, max_seq_len, paged_cache }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tokenizer(&self) -> &Tokenizer { &self.tokenizer }
|
||||||
|
|
||||||
|
pub fn max_seq_len(&self) -> usize { self.max_seq_len }
|
||||||
|
|
||||||
|
/// Main scheduler loop. Receives requests from channel, manages concurrent sequences.
|
||||||
|
///
|
||||||
|
/// Sequences move between three sets:
|
||||||
|
/// waiting — admitted to the queue, no GPU slot yet
|
||||||
|
/// running — KV resident on GPU, actively prefilling/decoding
|
||||||
|
/// swapped — KV evicted to pinned host memory (preempted), paused
|
||||||
|
/// When running sequences grow past the GPU block pool, the newest are
|
||||||
|
/// swapped out to host (vLLM-style) and swapped back in when blocks free up.
|
||||||
|
pub fn run(&mut self, rx: mpsc::Receiver<GenerateRequest>) {
|
||||||
|
let mut waiting: VecDeque<Sequence> = VecDeque::new();
|
||||||
|
let mut running: Vec<Sequence> = Vec::new();
|
||||||
|
let mut swapped: Vec<Sequence> = Vec::new();
|
||||||
|
let mut next_id: u64 = 0;
|
||||||
|
|
||||||
|
eprintln!("[scheduler] Listening for requests...");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Step 1: Remove finished sequences and return their slots.
|
||||||
|
let finished_slots: Vec<usize> = running.iter()
|
||||||
|
.filter(|s| is_finished(s))
|
||||||
|
.filter_map(|s| s.seq_slot)
|
||||||
|
.collect();
|
||||||
|
for slot in finished_slots {
|
||||||
|
self.paged_cache.free_sequence(slot);
|
||||||
|
}
|
||||||
|
running.retain(|seq| !is_finished(seq));
|
||||||
|
|
||||||
|
// Step 2: Swap previously-evicted sequences back in when there is
|
||||||
|
// room (oldest first). They resume decoding from where they paused.
|
||||||
|
while running.len() < self.max_batch_size && !swapped.is_empty() {
|
||||||
|
let slot = swapped[0].seq_slot.expect("swapped slot");
|
||||||
|
if !self.paged_cache.can_swap_in(slot) { break; }
|
||||||
|
self.paged_cache.swap_in(slot).expect("swap_in");
|
||||||
|
let seq = swapped.remove(0);
|
||||||
|
eprintln!("[scheduler] swapped in seq {} ({} blocks)", seq.id, self.paged_cache.block_count(slot));
|
||||||
|
running.push(seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Admit new sequences (block-aware). Only admit if the GPU
|
||||||
|
// pool can hold the prompt AND leave one block of decode headroom
|
||||||
|
// per already-running sequence, so admission never starves decode.
|
||||||
|
{
|
||||||
|
let mut avail = self.paged_cache.free_blocks();
|
||||||
|
let decode_reserve = running.len();
|
||||||
|
while running.len() < self.max_batch_size {
|
||||||
|
let Some(front) = waiting.front() else { break; };
|
||||||
|
let prompt_blocks = front.prompt_tokens.len().div_ceil(BLOCK_SIZE).max(1);
|
||||||
|
if avail < prompt_blocks + decode_reserve { break; }
|
||||||
|
let free_slot = (0..self.paged_cache.max_seqs())
|
||||||
|
.find(|&s| self.paged_cache.is_slot_free(s));
|
||||||
|
let Some(slot) = free_slot else { break; };
|
||||||
|
let mut seq = waiting.pop_front().unwrap();
|
||||||
|
self.paged_cache.register_sequence(slot).expect("register paged slot");
|
||||||
|
seq.seq_slot = Some(slot);
|
||||||
|
running.push(seq);
|
||||||
|
avail -= prompt_blocks; // projected free after this seq prefills
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: If nothing to do, blocking wait for new request.
|
||||||
|
if running.is_empty() && waiting.is_empty() && swapped.is_empty() {
|
||||||
|
match rx.recv() {
|
||||||
|
Ok(req) => {
|
||||||
|
let seq = self.make_sequence(req, &mut next_id);
|
||||||
|
waiting.push_back(seq);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(_) => break, // channel closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing runnable this iteration (e.g. all swapped, waiting on
|
||||||
|
// blocks to free): loop to retry swap-in/admission next iteration.
|
||||||
|
if running.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5a: Process prefills (one at a time — different prompt lengths).
|
||||||
|
// Admission guaranteed block headroom, so ensure_capacity won't starve.
|
||||||
|
let mut newly_prefilled = Vec::new();
|
||||||
|
for seq in running.iter_mut() {
|
||||||
|
if !seq.prefilled {
|
||||||
|
let slot = seq.seq_slot.expect("slot");
|
||||||
|
let logits = self.model.forward_prefill_paged(
|
||||||
|
&seq.prompt_tokens, slot, &mut self.paged_cache,
|
||||||
|
);
|
||||||
|
let next = sample(&logits, &seq.sampling);
|
||||||
|
seq.generated_tokens.push(next);
|
||||||
|
seq.prefilled = true;
|
||||||
|
emit_token(&self.tokenizer, seq, next);
|
||||||
|
newly_prefilled.push(seq.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5b: Ensure block headroom for this decode step; preempt the
|
||||||
|
// newest running sequences to host if the pool can't cover it.
|
||||||
|
let mut needed = decode_block_need(&self.paged_cache, &running, &newly_prefilled);
|
||||||
|
while self.paged_cache.free_blocks() < needed {
|
||||||
|
// Victim: newest prefilled, decoding (not just-prefilled) sequence.
|
||||||
|
let victim = (0..running.len()).rev().find(|&p| {
|
||||||
|
running[p].prefilled
|
||||||
|
&& !newly_prefilled.contains(&running[p].id)
|
||||||
|
&& running[p].seq_slot.is_some()
|
||||||
|
});
|
||||||
|
let Some(pos) = victim else { break; };
|
||||||
|
let seq = running.remove(pos);
|
||||||
|
let slot = seq.seq_slot.unwrap();
|
||||||
|
if self.paged_cache.can_swap_out(slot) {
|
||||||
|
let nblocks = self.paged_cache.block_count(slot);
|
||||||
|
self.paged_cache.swap_out(slot).expect("swap_out");
|
||||||
|
eprintln!("[scheduler] preempt: swapped out seq {} ({nblocks} blocks) to host", seq.id);
|
||||||
|
swapped.push(seq);
|
||||||
|
needed = decode_block_need(&self.paged_cache, &running, &newly_prefilled);
|
||||||
|
} else {
|
||||||
|
running.insert(pos, seq); // CPU pool full — can't evict further
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5c: Batched paged decode for the surviving prefilled sequences.
|
||||||
|
let decode_indices: Vec<usize> = running.iter().enumerate()
|
||||||
|
.filter(|(_, s)| s.prefilled && !newly_prefilled.contains(&s.id))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !decode_indices.is_empty() {
|
||||||
|
static LOG_ONCE: Once = Once::new();
|
||||||
|
LOG_ONCE.call_once(|| {
|
||||||
|
eprintln!("[scheduler] paged decode active");
|
||||||
|
});
|
||||||
|
|
||||||
|
let tokens: Vec<u32> = decode_indices.iter()
|
||||||
|
.map(|&i| *running[i].generated_tokens.last().unwrap())
|
||||||
|
.collect();
|
||||||
|
let positions: Vec<usize> = decode_indices.iter()
|
||||||
|
.map(|&i| self.paged_cache.seq_len(running[i].seq_slot.unwrap()))
|
||||||
|
.collect();
|
||||||
|
let slots: Vec<usize> = decode_indices.iter()
|
||||||
|
.map(|&i| running[i].seq_slot.unwrap())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let logits = self.model.forward_decode_paged(
|
||||||
|
&tokens, &positions, &slots, &mut self.paged_cache,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast path: every active sequence is greedy → run argmax on
|
||||||
|
// the GPU and only D2H the chosen token ids (a few bytes per
|
||||||
|
// sequence) instead of the full [B, vocab_size] BF16 logits
|
||||||
|
// (~1.2 MB for B=4, Qwen3 vocab=152K).
|
||||||
|
let all_greedy = decode_indices.iter()
|
||||||
|
.all(|&i| running[i].sampling.temperature == 0.0);
|
||||||
|
if all_greedy {
|
||||||
|
let next_ids = xserv_kernels::argmax_bf16_to_host(&logits);
|
||||||
|
for (j, &i) in decode_indices.iter().enumerate() {
|
||||||
|
let next = next_ids[j];
|
||||||
|
running[i].generated_tokens.push(next);
|
||||||
|
emit_token(&self.tokenizer, &mut running[i], next);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mixed sampling: keep the CPU path for now (top-k/top-p
|
||||||
|
// sampling still runs there). Only the rows that need it
|
||||||
|
// get exercised; greedy rows could in principle reuse the
|
||||||
|
// GPU argmax but the CPU pass is short for B<=4.
|
||||||
|
let vocab_size = logits.shape()[1];
|
||||||
|
let logits_cpu = logits.to_device(xserv_tensor::Device::Cpu);
|
||||||
|
let data = logits_cpu.as_slice::<half::bf16>();
|
||||||
|
for (j, &i) in decode_indices.iter().enumerate() {
|
||||||
|
let row_start = j * vocab_size;
|
||||||
|
let row_logits = &data[row_start..row_start + vocab_size];
|
||||||
|
let next = if running[i].sampling.temperature == 0.0 {
|
||||||
|
row_logits.iter().enumerate()
|
||||||
|
.max_by(|a, b| a.1.to_f32().partial_cmp(&b.1.to_f32()).unwrap())
|
||||||
|
.map(|(idx, _)| idx as u32).unwrap()
|
||||||
|
} else {
|
||||||
|
let row_tensor = xserv_tensor::Tensor::from_slice(row_logits, &[1, vocab_size]);
|
||||||
|
sample(&row_tensor, &running[i].sampling)
|
||||||
|
};
|
||||||
|
running[i].generated_tokens.push(next);
|
||||||
|
emit_token(&self.tokenizer, &mut running[i], next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Check for newly arrived requests (non-blocking)
|
||||||
|
loop {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(req) => {
|
||||||
|
let seq = self.make_sequence(req, &mut next_id);
|
||||||
|
waiting.push_back(seq);
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => break,
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_sequence(&mut self, req: GenerateRequest, next_id: &mut u64) -> Sequence {
|
||||||
|
let id = *next_id;
|
||||||
|
*next_id += 1;
|
||||||
|
Sequence {
|
||||||
|
id,
|
||||||
|
prompt_tokens: req.prompt_tokens,
|
||||||
|
generated_tokens: Vec::new(),
|
||||||
|
max_tokens: req.max_tokens,
|
||||||
|
sampling: req.sampling,
|
||||||
|
seq_slot: None,
|
||||||
|
sender: req.sender,
|
||||||
|
prefilled: false,
|
||||||
|
eos_token_id: self.tokenizer.eos_token_id(),
|
||||||
|
decode_buffer: Vec::new(),
|
||||||
|
created_at: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total additional GPU blocks the next decode step needs across all
|
||||||
|
/// currently-decoding (prefilled, not just-prefilled) sequences.
|
||||||
|
fn decode_block_need(paged: &PagedKVCache, running: &[Sequence], newly_prefilled: &[u64]) -> usize {
|
||||||
|
running.iter()
|
||||||
|
.filter(|s| s.prefilled && !newly_prefilled.contains(&s.id))
|
||||||
|
.filter_map(|s| s.seq_slot)
|
||||||
|
.map(|slot| paged.additional_blocks_needed(slot, 1))
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_token(tokenizer: &Tokenizer, seq: &mut Sequence, token_id: u32) {
|
||||||
|
if tokenizer.eos_token_id() == Some(token_id) {
|
||||||
|
let tail = tokenizer.flush_decode_stream(&mut seq.decode_buffer);
|
||||||
|
send_token_if_nonempty(seq, tail);
|
||||||
|
let _ = seq.sender.blocking_send(GenerateEvent::Done {
|
||||||
|
finish_reason: "stop".to_string(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = tokenizer.decode_token_stream(token_id, &mut seq.decode_buffer);
|
||||||
|
if seq.generated_tokens.len() >= seq.max_tokens {
|
||||||
|
let tail = tokenizer.flush_decode_stream(&mut seq.decode_buffer);
|
||||||
|
send_token_if_nonempty(seq, text);
|
||||||
|
send_token_if_nonempty(seq, tail);
|
||||||
|
let _ = seq.sender.blocking_send(GenerateEvent::Done {
|
||||||
|
finish_reason: "length".to_string(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
send_token_if_nonempty(seq, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_token_if_nonempty(seq: &Sequence, text: String) {
|
||||||
|
if !text.is_empty() {
|
||||||
|
let id = *seq.generated_tokens.last().unwrap_or(&0);
|
||||||
|
let _ = seq.sender.blocking_send(GenerateEvent::Token { id, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_finished(seq: &Sequence) -> bool {
|
||||||
|
if seq.generated_tokens.is_empty() { return false; }
|
||||||
|
let last = *seq.generated_tokens.last().unwrap();
|
||||||
|
if seq.generated_tokens.len() >= seq.max_tokens { return true; }
|
||||||
|
seq.sender.is_closed() || seq.eos_token_id == Some(last)
|
||||||
|
}
|
||||||
123
crates/xserv-server/src/main.rs
Normal file
123
crates/xserv-server/src/main.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
mod api;
|
||||||
|
mod engine;
|
||||||
|
mod pp_engine;
|
||||||
|
mod tp_engine;
|
||||||
|
|
||||||
|
use axum::{routing::{get, post}, Extension, Router};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use engine::GenerateRequest;
|
||||||
|
use xserv_model::ModelConfig;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub model_name: String,
|
||||||
|
pub chat_template: api::ChatTemplate,
|
||||||
|
pub engine_sender: Mutex<mpsc::Sender<GenerateRequest>>,
|
||||||
|
pub engine_tokenizer: Mutex<xserv_tokenizer::Tokenizer>,
|
||||||
|
pub max_seq_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: xserv-server <model-dir> [--port PORT] [--max-batch N] [--max-seq-len N] [--swap-space-gb N] [--tp N] [--pp N]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_dir = PathBuf::from(&args[1]);
|
||||||
|
let port: u16 = args.iter()
|
||||||
|
.position(|a| a == "--port")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(8080);
|
||||||
|
let max_batch: usize = args.iter()
|
||||||
|
.position(|a| a == "--max-batch")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(4)
|
||||||
|
.max(1);
|
||||||
|
let requested_max_seq_len: usize = args.iter()
|
||||||
|
.position(|a| a == "--max-seq-len")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(2048)
|
||||||
|
.max(1);
|
||||||
|
let swap_space_gb: usize = args.iter()
|
||||||
|
.position(|a| a == "--swap-space-gb")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(8);
|
||||||
|
let tp: usize = args.iter()
|
||||||
|
.position(|a| a == "--tp")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1);
|
||||||
|
let pp: usize = args.iter()
|
||||||
|
.position(|a| a == "--pp")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1);
|
||||||
|
if tp > 1 && pp > 1 {
|
||||||
|
eprintln!("--tp and --pp cannot be combined yet (2D TP×PP is future work)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let model_config = ModelConfig::from_file(&model_dir.join("config.json"));
|
||||||
|
let model_max_seq_len = model_config.max_seq_len();
|
||||||
|
if model_max_seq_len == 0 {
|
||||||
|
eprintln!("model config has invalid max_seq_len=0");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let max_seq_len = requested_max_seq_len.min(model_max_seq_len);
|
||||||
|
if max_seq_len != requested_max_seq_len {
|
||||||
|
eprintln!(
|
||||||
|
"[server] --max-seq-len {requested_max_seq_len} exceeds model limit {model_max_seq_len}; using {max_seq_len}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_name = model_dir.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let tokenizer = xserv_tokenizer::Tokenizer::from_file(&model_dir.join("tokenizer.json"));
|
||||||
|
|
||||||
|
// Unbounded channel: allows multiple requests to queue up
|
||||||
|
let (tx, rx) = mpsc::channel::<GenerateRequest>();
|
||||||
|
|
||||||
|
let model_dir_clone = model_dir.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if pp > 1 {
|
||||||
|
// Pipeline-parallel path: stage-0 coordinator + worker stage threads.
|
||||||
|
pp_engine::run_pp(&model_dir_clone, pp, max_seq_len, rx);
|
||||||
|
} else if tp <= 1 {
|
||||||
|
let mut engine = engine::Engine::load_with_swap(&model_dir_clone, max_batch, max_seq_len, swap_space_gb);
|
||||||
|
engine.run(rx);
|
||||||
|
} else {
|
||||||
|
// Tensor-parallel path: rank-0 coordinator + worker rank threads.
|
||||||
|
tp_engine::run_tp(&model_dir_clone, tp, max_seq_len, rx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let model_type = model_config.model_type.clone().unwrap_or_default();
|
||||||
|
let chat_template = api::ChatTemplate::load(&model_dir, &model_type);
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
model_name,
|
||||||
|
chat_template,
|
||||||
|
engine_sender: Mutex::new(tx),
|
||||||
|
engine_tokenizer: Mutex::new(tokenizer),
|
||||||
|
max_seq_len,
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(api::health))
|
||||||
|
.route("/v1/models", get(api::list_models))
|
||||||
|
.route("/v1/chat/completions", post(api::chat_completions))
|
||||||
|
.layer(Extension(state));
|
||||||
|
|
||||||
|
let addr = format!("0.0.0.0:{port}");
|
||||||
|
eprintln!("[server] Listening on {addr} (max_batch={max_batch}, max_seq_len={max_seq_len})");
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
263
crates/xserv-server/src/pp_engine.rs
Normal file
263
crates/xserv-server/src/pp_engine.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
//! Pipeline-parallel inference engine for the HTTP server (Phase 18).
|
||||||
|
//!
|
||||||
|
//! Layer-wise split: stage `s` holds layers `[s*L, (s+1)*L)`. Stage 0 owns the
|
||||||
|
//! token embedding and acts as the coordinator (scheduler + tokenizer + response
|
||||||
|
//! sender + stop logic); the last stage owns `norm`/`lm_head` and does sampling.
|
||||||
|
//! Hidden states are handed off stage->stage via NCCL P2P (`PpContext`); the
|
||||||
|
//! sampled token id (a single u32) is returned last-stage -> stage0 over an
|
||||||
|
//! in-process channel (same process, so no NCCL needed for that).
|
||||||
|
//!
|
||||||
|
//! v1 is serial: one request at a time, one token per step, the pipeline is
|
||||||
|
//! filled and drained each step (stage0's decode step t+1 depends on the token
|
||||||
|
//! the last stage sampled at step t). This gives correctness + per-GPU memory
|
||||||
|
//! savings; throughput via microbatch/1F1B overlap is future work
|
||||||
|
//! (see docs/18-pipeline-parallelism.md).
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use half::bf16;
|
||||||
|
use xserv_distributed::{PpContext, UniqueId};
|
||||||
|
use xserv_model::loader;
|
||||||
|
use xserv_model::sampling::SamplingParams;
|
||||||
|
use xserv_model::{sample, ModelConfig, PagedKVCache, Qwen3, BLOCK_SIZE};
|
||||||
|
use xserv_tensor::{DType, Device, Tensor};
|
||||||
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
|
use crate::engine::{GenerateEvent, GenerateRequest};
|
||||||
|
|
||||||
|
/// Control messages from the coordinator (stage 0) to a worker stage. The heavy
|
||||||
|
/// hidden-state tensors do NOT travel here — they go GPU->GPU over NCCL. Only
|
||||||
|
/// tiny control info (slot ids, token count, sampling params) is sent.
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum PpCommand {
|
||||||
|
Register(usize),
|
||||||
|
Free(usize),
|
||||||
|
/// Receive `[n_tokens, hidden]` from the previous stage, run this stage's
|
||||||
|
/// layers; if last stage, sample with `sampling` and return the token.
|
||||||
|
Prefill { n_tokens: usize, slot: usize, sampling: SamplingParams },
|
||||||
|
/// Receive `[1, hidden]`, run this stage's layers; last stage samples.
|
||||||
|
Decode { slot: usize, sampling: SamplingParams },
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StageCtx {
|
||||||
|
model: Qwen3,
|
||||||
|
cache: PagedKVCache,
|
||||||
|
pp: Arc<PpContext>,
|
||||||
|
hidden: usize,
|
||||||
|
device: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build this stage: NCCL init, load + slice weights, size a per-stage KV pool
|
||||||
|
/// for THIS stage's layers only (so per-GPU KV is ~1/P).
|
||||||
|
fn build_stage(
|
||||||
|
model_dir: &Path,
|
||||||
|
config: &ModelConfig,
|
||||||
|
stage: usize,
|
||||||
|
world: usize,
|
||||||
|
device: u32,
|
||||||
|
max_seq_len: usize,
|
||||||
|
id: UniqueId,
|
||||||
|
) -> StageCtx {
|
||||||
|
let pp = Arc::new(PpContext::init(stage, world, id, device));
|
||||||
|
let weights = loader::load_model_dir(model_dir, Device::Cpu);
|
||||||
|
let model = Qwen3::from_weights_pp(config.clone(), weights, stage, world, device);
|
||||||
|
|
||||||
|
// The KV cache only needs this stage's layers; build it from a config clone
|
||||||
|
// whose layer count is the per-stage count (heads are NOT split under PP).
|
||||||
|
let per_stage = config.num_layers() / world;
|
||||||
|
let mut stage_config = config.clone();
|
||||||
|
stage_config.num_hidden_layers = Some(per_stage);
|
||||||
|
|
||||||
|
let max_blocks_per_seq = max_seq_len.div_ceil(BLOCK_SIZE);
|
||||||
|
let total_blocks = max_blocks_per_seq + 8; // v1 serial: one active sequence
|
||||||
|
let cache = PagedKVCache::new(
|
||||||
|
&stage_config, total_blocks, 0, 4, max_blocks_per_seq, DType::BF16, device,
|
||||||
|
);
|
||||||
|
StageCtx { model, cache, pp, hidden: config.hidden(), device }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate a zeroed `[n, hidden]` device tensor and receive into it from `peer`.
|
||||||
|
fn recv_hidden(sc: &StageCtx, n: usize, peer: usize) -> Tensor {
|
||||||
|
let zeros = vec![bf16::ZERO; n * sc.hidden];
|
||||||
|
let x = Tensor::from_slice(&zeros, &[n, sc.hidden]).to_device(Device::Cuda(sc.device));
|
||||||
|
let ptr = x.storage().gpu_buffer().as_ptr() as *mut c_void;
|
||||||
|
sc.pp.recv_bf16_ptr(ptr, n * sc.hidden, peer);
|
||||||
|
xserv_cuda::device::synchronize().unwrap();
|
||||||
|
x
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send the `[*, hidden]` hidden state to `peer`, then synchronize so NCCL has
|
||||||
|
/// finished reading `x` before it is dropped/reused.
|
||||||
|
fn send_hidden(sc: &StageCtx, x: &Tensor, peer: usize) {
|
||||||
|
let ptr = x.storage().gpu_buffer().as_ptr() as *const c_void;
|
||||||
|
sc.pp.send_bf16_ptr(ptr, x.numel(), peer);
|
||||||
|
xserv_cuda::device::synchronize().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_loop(
|
||||||
|
stage: usize,
|
||||||
|
world: usize,
|
||||||
|
id: UniqueId,
|
||||||
|
model_dir: PathBuf,
|
||||||
|
config: ModelConfig,
|
||||||
|
max_seq_len: usize,
|
||||||
|
cmd_rx: mpsc::Receiver<PpCommand>,
|
||||||
|
ack_tx: mpsc::Sender<()>,
|
||||||
|
token_tx: mpsc::Sender<u32>,
|
||||||
|
) {
|
||||||
|
let mut sc = build_stage(&model_dir, &config, stage, world, stage as u32, max_seq_len, id);
|
||||||
|
let is_last = stage == world - 1;
|
||||||
|
let prev = stage - 1;
|
||||||
|
let next = stage + 1;
|
||||||
|
|
||||||
|
while let Ok(cmd) = cmd_rx.recv() {
|
||||||
|
match cmd {
|
||||||
|
PpCommand::Register(slot) => {
|
||||||
|
let _ = sc.cache.register_sequence(slot);
|
||||||
|
let _ = ack_tx.send(());
|
||||||
|
}
|
||||||
|
PpCommand::Free(slot) => {
|
||||||
|
sc.cache.free_sequence(slot);
|
||||||
|
let _ = ack_tx.send(());
|
||||||
|
}
|
||||||
|
PpCommand::Prefill { n_tokens, slot, sampling } => {
|
||||||
|
let x = recv_hidden(&sc, n_tokens, prev);
|
||||||
|
let x = sc.model.forward_layers_prefill(x, slot, &mut sc.cache);
|
||||||
|
if is_last {
|
||||||
|
let logits = sc.model.head(&x);
|
||||||
|
let _ = token_tx.send(sample(&logits, &sampling));
|
||||||
|
} else {
|
||||||
|
send_hidden(&sc, &x, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PpCommand::Decode { slot, sampling } => {
|
||||||
|
let x = recv_hidden(&sc, 1, prev);
|
||||||
|
let x = sc.model.forward_layers_decode(x, &[slot], &mut sc.cache);
|
||||||
|
if is_last {
|
||||||
|
let logits = sc.model.head(&x);
|
||||||
|
let _ = token_tx.send(sample(&logits, &sampling));
|
||||||
|
} else {
|
||||||
|
send_hidden(&sc, &x, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PpCommand::Shutdown => {
|
||||||
|
let _ = ack_tx.send(());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the PP coordinator (stage 0) on the calling thread. Spawns worker stages
|
||||||
|
/// 1..world and consumes generation requests from `rx`.
|
||||||
|
pub fn run_pp(model_dir: &Path, world: usize, max_seq_len: usize, rx: mpsc::Receiver<GenerateRequest>) {
|
||||||
|
assert!(world >= 2, "run_pp requires world >= 2");
|
||||||
|
let config = ModelConfig::from_file(&model_dir.join("config.json"));
|
||||||
|
assert!(
|
||||||
|
config.num_layers() % world == 0,
|
||||||
|
"num_layers {} not divisible by pp {world}",
|
||||||
|
config.num_layers()
|
||||||
|
);
|
||||||
|
let tokenizer = Tokenizer::from_file(&model_dir.join("tokenizer.json"));
|
||||||
|
let id = xserv_distributed::get_unique_id();
|
||||||
|
|
||||||
|
// Worker stages 1..world. Each gets a control channel; all share one ack
|
||||||
|
// channel and one token channel (only the last stage actually sends tokens).
|
||||||
|
let (ack_tx, ack_rx) = mpsc::channel::<()>();
|
||||||
|
let (token_tx, token_rx) = mpsc::channel::<u32>();
|
||||||
|
let mut cmd_txs: Vec<mpsc::Sender<PpCommand>> = Vec::new();
|
||||||
|
for stage in 1..world {
|
||||||
|
let (ctx_tx, ctx_rx) = mpsc::channel::<PpCommand>();
|
||||||
|
cmd_txs.push(ctx_tx);
|
||||||
|
let ack_tx = ack_tx.clone();
|
||||||
|
let token_tx = token_tx.clone();
|
||||||
|
let model_dir = model_dir.to_path_buf();
|
||||||
|
let config = config.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
worker_loop(stage, world, id, model_dir, config, max_seq_len, ctx_rx, ack_tx, token_tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 0 (this thread): coordinator + embedding + first layers.
|
||||||
|
let mut sc = build_stage(model_dir, &config, 0, world, 0, max_seq_len, id);
|
||||||
|
eprintln!("[pp-engine] ready (pp={world}, max_seq_len={max_seq_len})");
|
||||||
|
|
||||||
|
let n_workers = world - 1;
|
||||||
|
let next_peer = 1usize;
|
||||||
|
let broadcast = |txs: &[mpsc::Sender<PpCommand>], cmd: PpCommand| {
|
||||||
|
for t in txs {
|
||||||
|
let _ = t.send(cmd.clone());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let wait_acks = |rx: &mpsc::Receiver<()>| {
|
||||||
|
for _ in 0..n_workers {
|
||||||
|
let _ = rx.recv();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let slot = 0usize;
|
||||||
|
while let Ok(req) = rx.recv() {
|
||||||
|
broadcast(&cmd_txs, PpCommand::Register(slot));
|
||||||
|
sc.cache.register_sequence(slot).expect("register slot");
|
||||||
|
wait_acks(&ack_rx);
|
||||||
|
|
||||||
|
// Prefill: embed prompt, run stage-0 layers, push hidden into the pipe.
|
||||||
|
broadcast(&cmd_txs, PpCommand::Prefill {
|
||||||
|
n_tokens: req.prompt_tokens.len(),
|
||||||
|
slot,
|
||||||
|
sampling: req.sampling.clone(),
|
||||||
|
});
|
||||||
|
let x = sc.model.embed(&req.prompt_tokens);
|
||||||
|
let x = sc.model.forward_layers_prefill(x, slot, &mut sc.cache);
|
||||||
|
send_hidden(&sc, &x, next_peer);
|
||||||
|
let mut next = token_rx.recv().expect("prefill token");
|
||||||
|
|
||||||
|
let mut decode_buf: Vec<u8> = Vec::new();
|
||||||
|
let mut generated = 1usize;
|
||||||
|
emit_text(&tokenizer, &req, next, &mut decode_buf);
|
||||||
|
|
||||||
|
let finish = loop {
|
||||||
|
if tokenizer.is_eos(next) {
|
||||||
|
break "stop";
|
||||||
|
}
|
||||||
|
if generated >= req.max_tokens {
|
||||||
|
break "length";
|
||||||
|
}
|
||||||
|
broadcast(&cmd_txs, PpCommand::Decode { slot, sampling: req.sampling.clone() });
|
||||||
|
let x = sc.model.embed(&[next]);
|
||||||
|
let x = sc.model.forward_layers_decode(x, &[slot], &mut sc.cache);
|
||||||
|
send_hidden(&sc, &x, next_peer);
|
||||||
|
next = token_rx.recv().expect("decode token");
|
||||||
|
generated += 1;
|
||||||
|
emit_text(&tokenizer, &req, next, &mut decode_buf);
|
||||||
|
};
|
||||||
|
|
||||||
|
let tail = tokenizer.flush_decode_stream(&mut decode_buf);
|
||||||
|
if !tail.is_empty() {
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Token { id: next, text: tail });
|
||||||
|
}
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Done { finish_reason: finish.to_string() });
|
||||||
|
|
||||||
|
broadcast(&cmd_txs, PpCommand::Free(slot));
|
||||||
|
sc.cache.free_sequence(slot);
|
||||||
|
wait_acks(&ack_rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(&cmd_txs, PpCommand::Shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a token's decoded text to the client (EOS contributes no text).
|
||||||
|
fn emit_text(tokenizer: &Tokenizer, req: &GenerateRequest, token_id: u32, buf: &mut Vec<u8>) {
|
||||||
|
if tokenizer.is_eos(token_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = tokenizer.decode_token_stream(token_id, buf);
|
||||||
|
if !text.is_empty() {
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Token { id: token_id, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
239
crates/xserv-server/src/tp_engine.rs
Normal file
239
crates/xserv-server/src/tp_engine.rs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
//! Tensor-parallel inference engine for the HTTP server.
|
||||||
|
//!
|
||||||
|
//! Serial coordinator model: one rank-0 coordinator thread (the caller) drives
|
||||||
|
//! generation and owns the scheduler; ranks 1..world are worker threads. For
|
||||||
|
//! each step the coordinator broadcasts a command (Register/Prefill/Decode/Free)
|
||||||
|
//! to the workers and runs the same op on its own shard; the per-layer NCCL
|
||||||
|
//! AllReduces keep all ranks in lockstep. Only the coordinator samples — the
|
||||||
|
//! chosen token is carried in the next Decode command, so this is correct for
|
||||||
|
//! both greedy and stochastic sampling.
|
||||||
|
//!
|
||||||
|
//! Requests are processed one at a time (sufficient for the quality benchmark,
|
||||||
|
//! which issues serial requests). Continuous batching across ranks is future
|
||||||
|
//! work; the single-GPU `Engine` still handles TP=1.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use xserv_distributed::{TpContext, UniqueId};
|
||||||
|
use xserv_model::loader;
|
||||||
|
use xserv_model::{sample, sample_greedy_penalized, GptOss, ModelConfig, PagedKVCache, Qwen3, BLOCK_SIZE};
|
||||||
|
use xserv_tensor::{DType, Device, Tensor};
|
||||||
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
|
use crate::engine::{GenerateEvent, GenerateRequest};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum TpCommand {
|
||||||
|
Register(usize),
|
||||||
|
Free(usize),
|
||||||
|
Prefill { tokens: Vec<u32>, slot: usize },
|
||||||
|
Decode { tokens: Vec<u32>, positions: Vec<usize>, slots: Vec<usize> },
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TpModel {
|
||||||
|
Qwen3(Qwen3),
|
||||||
|
GptOss(GptOss),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TpModel {
|
||||||
|
fn forward_prefill_paged(&self, tokens: &[u32], slot: usize, cache: &mut PagedKVCache) -> Tensor {
|
||||||
|
match self {
|
||||||
|
TpModel::Qwen3(m) => m.forward_prefill_paged(tokens, slot, cache),
|
||||||
|
TpModel::GptOss(m) => m.forward_prefill_paged(tokens, slot, cache),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forward_decode_paged(&self, tokens: &[u32], positions: &[usize], slots: &[usize], cache: &mut PagedKVCache) -> Tensor {
|
||||||
|
match self {
|
||||||
|
TpModel::Qwen3(m) => m.forward_decode_paged(tokens, positions, slots, cache),
|
||||||
|
TpModel::GptOss(m) => m.forward_decode_paged(tokens, positions, slots, cache),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RankCtx {
|
||||||
|
model: TpModel,
|
||||||
|
cache: PagedKVCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_rank(
|
||||||
|
model_dir: &Path,
|
||||||
|
config: &ModelConfig,
|
||||||
|
rank: usize,
|
||||||
|
world: usize,
|
||||||
|
device: u32,
|
||||||
|
max_seq_len: usize,
|
||||||
|
tp: Option<Arc<TpContext>>,
|
||||||
|
) -> RankCtx {
|
||||||
|
let weights = loader::load_model_dir(model_dir, Device::Cpu);
|
||||||
|
let model = if config.is_moe() {
|
||||||
|
TpModel::GptOss(GptOss::from_weights_tp(config.clone(), weights, rank, world, device, tp))
|
||||||
|
} else {
|
||||||
|
TpModel::Qwen3(Qwen3::from_weights_tp(config.clone(), weights, rank, world, device, tp))
|
||||||
|
};
|
||||||
|
let local_kv = config.num_kv_heads() / world;
|
||||||
|
let max_blocks_per_seq = (max_seq_len + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
let total_blocks = max_blocks_per_seq + 8;
|
||||||
|
let cache = PagedKVCache::new_tp(
|
||||||
|
config, local_kv, total_blocks, 0, 4, max_blocks_per_seq, DType::BF16, device,
|
||||||
|
);
|
||||||
|
RankCtx { model, cache }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_loop(
|
||||||
|
rank: usize,
|
||||||
|
world: usize,
|
||||||
|
id: UniqueId,
|
||||||
|
model_dir: PathBuf,
|
||||||
|
config: ModelConfig,
|
||||||
|
max_seq_len: usize,
|
||||||
|
cmd_rx: mpsc::Receiver<TpCommand>,
|
||||||
|
ack_tx: mpsc::Sender<()>,
|
||||||
|
) {
|
||||||
|
let tp = Arc::new(TpContext::init(rank, world, id, rank as u32));
|
||||||
|
let mut rc = build_rank(&model_dir, &config, rank, world, rank as u32, max_seq_len, Some(tp));
|
||||||
|
while let Ok(cmd) = cmd_rx.recv() {
|
||||||
|
match cmd {
|
||||||
|
TpCommand::Register(slot) => {
|
||||||
|
let _ = rc.cache.register_sequence(slot);
|
||||||
|
}
|
||||||
|
TpCommand::Free(slot) => rc.cache.free_sequence(slot),
|
||||||
|
TpCommand::Prefill { tokens, slot } => {
|
||||||
|
let _ = rc.model.forward_prefill_paged(&tokens, slot, &mut rc.cache);
|
||||||
|
}
|
||||||
|
TpCommand::Decode { tokens, positions, slots } => {
|
||||||
|
let _ = rc.model.forward_decode_paged(&tokens, &positions, &slots, &mut rc.cache);
|
||||||
|
}
|
||||||
|
TpCommand::Shutdown => {
|
||||||
|
let _ = ack_tx.send(());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = ack_tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the TP coordinator (rank 0) on the calling thread. Spawns worker ranks
|
||||||
|
/// internally and consumes generation requests from `rx`.
|
||||||
|
pub fn run_tp(model_dir: &Path, world: usize, max_seq_len: usize, rx: mpsc::Receiver<GenerateRequest>) {
|
||||||
|
assert!(world >= 2, "run_tp requires world >= 2");
|
||||||
|
let config = ModelConfig::from_file(&model_dir.join("config.json"));
|
||||||
|
assert!(
|
||||||
|
config.num_kv_heads() % world == 0,
|
||||||
|
"num_kv_heads {} not divisible by tp {world}",
|
||||||
|
config.num_kv_heads()
|
||||||
|
);
|
||||||
|
let tokenizer = Tokenizer::from_file(&model_dir.join("tokenizer.json"));
|
||||||
|
let id = xserv_distributed::get_unique_id();
|
||||||
|
|
||||||
|
// Spawn worker ranks 1..world.
|
||||||
|
let (ack_tx, ack_rx) = mpsc::channel::<()>();
|
||||||
|
let mut cmd_txs: Vec<mpsc::Sender<TpCommand>> = Vec::new();
|
||||||
|
for rank in 1..world {
|
||||||
|
let (ctx_tx, ctx_rx) = mpsc::channel::<TpCommand>();
|
||||||
|
cmd_txs.push(ctx_tx);
|
||||||
|
let ack_tx = ack_tx.clone();
|
||||||
|
let model_dir = model_dir.to_path_buf();
|
||||||
|
let config = config.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
worker_loop(rank, world, id, model_dir, config, max_seq_len, ctx_rx, ack_tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank 0 (this thread).
|
||||||
|
let tp = Arc::new(TpContext::init(0, world, id, 0));
|
||||||
|
let mut rc = build_rank(model_dir, &config, 0, world, 0, max_seq_len, Some(tp));
|
||||||
|
eprintln!("[tp-engine] ready (tp={world}, max_seq_len={max_seq_len})");
|
||||||
|
|
||||||
|
// Optional repetition penalty to break greedy repetition loops (reasoning
|
||||||
|
// models loop under pure greedy when numerics diverge from the reference).
|
||||||
|
// Off by default; XSERV_REP_PENALTY>1 enables it over the last
|
||||||
|
// XSERV_REP_WINDOW generated tokens. Applied only on the greedy path.
|
||||||
|
let rep_penalty: f32 = std::env::var("XSERV_REP_PENALTY").ok()
|
||||||
|
.and_then(|s| s.parse().ok()).unwrap_or(1.0);
|
||||||
|
let rep_window: usize = std::env::var("XSERV_REP_WINDOW").ok()
|
||||||
|
.and_then(|s| s.parse().ok()).unwrap_or(128);
|
||||||
|
let pick = |logits: &Tensor, sp: &xserv_model::SamplingParams, history: &[u32]| -> u32 {
|
||||||
|
if rep_penalty > 1.0 && sp.temperature == 0.0 {
|
||||||
|
let start = history.len().saturating_sub(rep_window);
|
||||||
|
sample_greedy_penalized(logits, &history[start..], rep_penalty)
|
||||||
|
} else {
|
||||||
|
sample(logits, sp)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let n_workers = world - 1;
|
||||||
|
let broadcast = |txs: &[mpsc::Sender<TpCommand>], cmd: TpCommand| {
|
||||||
|
for t in txs {
|
||||||
|
let _ = t.send(cmd.clone());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let wait_acks = |rx: &mpsc::Receiver<()>| {
|
||||||
|
for _ in 0..n_workers {
|
||||||
|
let _ = rx.recv();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let slot = 0usize;
|
||||||
|
while let Ok(req) = rx.recv() {
|
||||||
|
broadcast(&cmd_txs, TpCommand::Register(slot));
|
||||||
|
rc.cache.register_sequence(slot).expect("register slot");
|
||||||
|
wait_acks(&ack_rx);
|
||||||
|
|
||||||
|
// Prefill.
|
||||||
|
broadcast(&cmd_txs, TpCommand::Prefill { tokens: req.prompt_tokens.clone(), slot });
|
||||||
|
let logits = rc.model.forward_prefill_paged(&req.prompt_tokens, slot, &mut rc.cache);
|
||||||
|
wait_acks(&ack_rx);
|
||||||
|
let mut gen_ids: Vec<u32> = Vec::new();
|
||||||
|
let mut next = pick(&logits, &req.sampling, &gen_ids);
|
||||||
|
gen_ids.push(next);
|
||||||
|
|
||||||
|
let mut decode_buf: Vec<u8> = Vec::new();
|
||||||
|
let mut generated = 1usize;
|
||||||
|
emit_text(&tokenizer, &req, next, &mut decode_buf);
|
||||||
|
|
||||||
|
let finish = loop {
|
||||||
|
if tokenizer.is_eos(next) {
|
||||||
|
break "stop";
|
||||||
|
}
|
||||||
|
if generated >= req.max_tokens {
|
||||||
|
break "length";
|
||||||
|
}
|
||||||
|
let pos = rc.cache.seq_len(slot);
|
||||||
|
broadcast(&cmd_txs, TpCommand::Decode { tokens: vec![next], positions: vec![pos], slots: vec![slot] });
|
||||||
|
let logits = rc.model.forward_decode_paged(&[next], &[pos], &[slot], &mut rc.cache);
|
||||||
|
wait_acks(&ack_rx);
|
||||||
|
next = pick(&logits, &req.sampling, &gen_ids);
|
||||||
|
gen_ids.push(next);
|
||||||
|
generated += 1;
|
||||||
|
emit_text(&tokenizer, &req, next, &mut decode_buf);
|
||||||
|
};
|
||||||
|
|
||||||
|
let tail = tokenizer.flush_decode_stream(&mut decode_buf);
|
||||||
|
if !tail.is_empty() {
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Token { id: next, text: tail });
|
||||||
|
}
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Done { finish_reason: finish.to_string() });
|
||||||
|
|
||||||
|
broadcast(&cmd_txs, TpCommand::Free(slot));
|
||||||
|
rc.cache.free_sequence(slot);
|
||||||
|
wait_acks(&ack_rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(&cmd_txs, TpCommand::Shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a token's decoded text to the client (EOS contributes no text).
|
||||||
|
fn emit_text(tokenizer: &Tokenizer, req: &GenerateRequest, token_id: u32, buf: &mut Vec<u8>) {
|
||||||
|
if tokenizer.is_eos(token_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = tokenizer.decode_token_stream(token_id, buf);
|
||||||
|
if !text.is_empty() {
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Token { id: token_id, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub enum DType {
|
|||||||
F32,
|
F32,
|
||||||
F16,
|
F16,
|
||||||
BF16,
|
BF16,
|
||||||
|
FP8E4M3,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DType {
|
impl DType {
|
||||||
@@ -13,6 +14,7 @@ impl DType {
|
|||||||
DType::F32 => 4,
|
DType::F32 => 4,
|
||||||
DType::F16 => 2,
|
DType::F16 => 2,
|
||||||
DType::BF16 => 2,
|
DType::BF16 => 2,
|
||||||
|
DType::FP8E4M3 => 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ impl DType {
|
|||||||
DType::F32 => "f32",
|
DType::F32 => "f32",
|
||||||
DType::F16 => "f16",
|
DType::F16 => "f16",
|
||||||
DType::BF16 => "bf16",
|
DType::BF16 => "bf16",
|
||||||
|
DType::FP8E4M3 => "fp8e4m3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ pub mod storage;
|
|||||||
pub mod tensor;
|
pub mod tensor;
|
||||||
|
|
||||||
pub use dtype::{DType, TensorDType};
|
pub use dtype::{DType, TensorDType};
|
||||||
pub use storage::Device;
|
pub use shape::Dims;
|
||||||
pub use tensor::Tensor;
|
pub use storage::{Device, Storage};
|
||||||
|
pub use tensor::{register_gpu_contiguous, Tensor};
|
||||||
|
|||||||
@@ -18,12 +18,21 @@ pub fn contiguous_strides(shape: &[usize]) -> Dims {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the given strides represent contiguous (row-major) layout for the shape.
|
/// Check if the given strides represent contiguous (row-major) layout for the shape.
|
||||||
|
/// A stride mismatch on a dimension of size 1 is allowed because that
|
||||||
|
/// dimension is never stepped.
|
||||||
pub fn is_contiguous(shape: &[usize], strides: &[usize]) -> bool {
|
pub fn is_contiguous(shape: &[usize], strides: &[usize]) -> bool {
|
||||||
if shape.is_empty() {
|
if shape.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let expected = contiguous_strides(shape);
|
let ndim = shape.len();
|
||||||
strides == expected.as_slice()
|
let mut expected_stride = 1usize;
|
||||||
|
for d in (0..ndim).rev() {
|
||||||
|
if shape[d] != 1 && strides[d] != expected_stride {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
expected_stride *= shape[d];
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total number of elements given a shape.
|
/// Total number of elements given a shape.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use xserv_cuda::{GpuBuffer, Result as CudaResult};
|
|||||||
|
|
||||||
enum StorageInner {
|
enum StorageInner {
|
||||||
Cpu { data: Vec<u8> },
|
Cpu { data: Vec<u8> },
|
||||||
Cuda { buffer: GpuBuffer },
|
Cuda { buffer: GpuBuffer, device: u32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reference-counted storage for tensor data. Multiple tensors can share
|
/// Reference-counted storage for tensor data. Multiple tensors can share
|
||||||
@@ -31,21 +31,21 @@ impl Storage {
|
|||||||
Self(Arc::new(StorageInner::Cpu { data }))
|
Self(Arc::new(StorageInner::Cpu { data }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cuda(buffer: GpuBuffer) -> Self {
|
pub fn cuda(buffer: GpuBuffer, device: u32) -> Self {
|
||||||
Self(Arc::new(StorageInner::Cuda { buffer }))
|
Self(Arc::new(StorageInner::Cuda { buffer, device }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn device(&self) -> Device {
|
pub fn device(&self) -> Device {
|
||||||
match self.0.as_ref() {
|
match self.0.as_ref() {
|
||||||
StorageInner::Cpu { .. } => Device::Cpu,
|
StorageInner::Cpu { .. } => Device::Cpu,
|
||||||
StorageInner::Cuda { .. } => Device::Cuda(0),
|
StorageInner::Cuda { device, .. } => Device::Cuda(*device),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn len_bytes(&self) -> usize {
|
pub fn len_bytes(&self) -> usize {
|
||||||
match self.0.as_ref() {
|
match self.0.as_ref() {
|
||||||
StorageInner::Cpu { data } => data.len(),
|
StorageInner::Cpu { data } => data.len(),
|
||||||
StorageInner::Cuda { buffer } => buffer.len(),
|
StorageInner::Cuda { buffer, .. } => buffer.len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ impl Storage {
|
|||||||
|
|
||||||
pub fn gpu_buffer(&self) -> &GpuBuffer {
|
pub fn gpu_buffer(&self) -> &GpuBuffer {
|
||||||
match self.0.as_ref() {
|
match self.0.as_ref() {
|
||||||
StorageInner::Cuda { buffer } => buffer,
|
StorageInner::Cuda { buffer, .. } => buffer,
|
||||||
StorageInner::Cpu { .. } => panic!("cannot access CPU storage as GPU buffer"),
|
StorageInner::Cpu { .. } => panic!("cannot access CPU storage as GPU buffer"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,11 +71,11 @@ impl Storage {
|
|||||||
return Ok(self.clone());
|
return Ok(self.clone());
|
||||||
}
|
}
|
||||||
match (current, target) {
|
match (current, target) {
|
||||||
(Device::Cpu, Device::Cuda(_dev)) => {
|
(Device::Cpu, Device::Cuda(dev)) => {
|
||||||
let cpu_data = self.as_cpu_bytes();
|
let cpu_data = self.as_cpu_bytes();
|
||||||
let mut buf = GpuBuffer::alloc(cpu_data.len())?;
|
let mut buf = xserv_cuda::allocator::cached_alloc(cpu_data.len())?;
|
||||||
buf.copy_from_host(cpu_data)?;
|
buf.copy_from_host(cpu_data)?;
|
||||||
Ok(Storage::cuda(buf))
|
Ok(Storage::cuda(buf, dev))
|
||||||
}
|
}
|
||||||
(Device::Cuda(_), Device::Cpu) => {
|
(Device::Cuda(_), Device::Cpu) => {
|
||||||
let gpu_buf = self.gpu_buffer();
|
let gpu_buf = self.gpu_buffer();
|
||||||
@@ -83,11 +83,11 @@ impl Storage {
|
|||||||
gpu_buf.copy_to_host(&mut data)?;
|
gpu_buf.copy_to_host(&mut data)?;
|
||||||
Ok(Storage::cpu(data))
|
Ok(Storage::cpu(data))
|
||||||
}
|
}
|
||||||
(Device::Cuda(_), Device::Cuda(_)) => {
|
(Device::Cuda(_), Device::Cuda(dev)) => {
|
||||||
let src = self.gpu_buffer();
|
let src = self.gpu_buffer();
|
||||||
let mut dst = GpuBuffer::alloc(src.len())?;
|
let mut dst = xserv_cuda::allocator::cached_alloc(src.len())?;
|
||||||
dst.copy_from_device(src)?;
|
dst.copy_from_device(src)?;
|
||||||
Ok(Storage::cuda(dst))
|
Ok(Storage::cuda(dst, dev))
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
@@ -97,10 +97,10 @@ impl Storage {
|
|||||||
pub fn deep_copy(&self) -> CudaResult<Self> {
|
pub fn deep_copy(&self) -> CudaResult<Self> {
|
||||||
match self.0.as_ref() {
|
match self.0.as_ref() {
|
||||||
StorageInner::Cpu { data } => Ok(Storage::cpu(data.clone())),
|
StorageInner::Cpu { data } => Ok(Storage::cpu(data.clone())),
|
||||||
StorageInner::Cuda { buffer } => {
|
StorageInner::Cuda { buffer, device } => {
|
||||||
let mut dst = GpuBuffer::alloc(buffer.len())?;
|
let mut dst = xserv_cuda::allocator::cached_alloc(buffer.len())?;
|
||||||
dst.copy_from_device(buffer)?;
|
dst.copy_from_device(buffer)?;
|
||||||
Ok(Storage::cuda(dst))
|
Ok(Storage::cuda(dst, *device))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,10 +109,24 @@ impl Storage {
|
|||||||
pub fn zeros(len_bytes: usize, device: Device) -> CudaResult<Self> {
|
pub fn zeros(len_bytes: usize, device: Device) -> CudaResult<Self> {
|
||||||
match device {
|
match device {
|
||||||
Device::Cpu => Ok(Storage::cpu(vec![0u8; len_bytes])),
|
Device::Cpu => Ok(Storage::cpu(vec![0u8; len_bytes])),
|
||||||
Device::Cuda(_) => {
|
Device::Cuda(dev) => {
|
||||||
let mut buf = GpuBuffer::alloc(len_bytes)?;
|
let mut buf = xserv_cuda::allocator::cached_alloc(len_bytes)?;
|
||||||
buf.zero()?;
|
buf.zero()?;
|
||||||
Ok(Storage::cuda(buf))
|
Ok(Storage::cuda(buf, dev))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate storage **without zeroing** on the given device.
|
||||||
|
/// The buffer may contain stale data from the caching allocator's pool.
|
||||||
|
/// Only use when the caller guarantees the kernel will fully overwrite
|
||||||
|
/// every element before any read.
|
||||||
|
pub fn empty(len_bytes: usize, device: Device) -> CudaResult<Self> {
|
||||||
|
match device {
|
||||||
|
Device::Cpu => Ok(Storage::cpu(vec![0u8; len_bytes])), // CPU still zeros (cheap)
|
||||||
|
Device::Cuda(dev) => {
|
||||||
|
let buf = xserv_cuda::allocator::cached_alloc(len_bytes)?;
|
||||||
|
Ok(Storage::cuda(buf, dev))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use crate::dtype::{DType, TensorDType};
|
use crate::dtype::{DType, TensorDType};
|
||||||
use crate::shape::{self, Dims};
|
use crate::shape::{self, Dims};
|
||||||
use crate::storage::{Device, Storage};
|
use crate::storage::{Device, Storage};
|
||||||
|
|
||||||
|
/// Global hook for GPU strided-to-contiguous copy.
|
||||||
|
/// Set by `xserv-kernels` (or any crate that provides a GPU kernel) via
|
||||||
|
/// `register_gpu_contiguous`. When set, `contiguous()` on a non-contiguous
|
||||||
|
/// GPU tensor calls this instead of doing a CPU round-trip.
|
||||||
|
static GPU_CONTIGUOUS_FN: OnceLock<fn(&Tensor) -> Tensor> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Register a function that makes a non-contiguous GPU tensor contiguous.
|
||||||
|
/// Intended to be called once by the kernel crate at startup.
|
||||||
|
pub fn register_gpu_contiguous(f: fn(&Tensor) -> Tensor) {
|
||||||
|
let _ = GPU_CONTIGUOUS_FN.set(f);
|
||||||
|
}
|
||||||
|
|
||||||
/// Multi-dimensional array with CPU or GPU storage.
|
/// Multi-dimensional array with CPU or GPU storage.
|
||||||
///
|
///
|
||||||
/// Tensors support view semantics: transpose, slice, etc. share
|
/// Tensors support view semantics: transpose, slice, etc. share
|
||||||
@@ -18,6 +32,11 @@ pub struct Tensor {
|
|||||||
impl Tensor {
|
impl Tensor {
|
||||||
// --- Creation ---
|
// --- Creation ---
|
||||||
|
|
||||||
|
/// Create a tensor from raw components (for advanced use like GPU KV cache).
|
||||||
|
pub fn from_storage(storage: Storage, shape: Dims, strides: Dims, offset: usize, dtype: DType) -> Self {
|
||||||
|
Self { storage, shape, strides, offset, dtype }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_slice<T: TensorDType>(data: &[T], shape: &[usize]) -> Self {
|
pub fn from_slice<T: TensorDType>(data: &[T], shape: &[usize]) -> Self {
|
||||||
let numel: usize = shape.iter().product();
|
let numel: usize = shape.iter().product();
|
||||||
assert_eq!(data.len(), numel, "data length mismatch with shape");
|
assert_eq!(data.len(), numel, "data length mismatch with shape");
|
||||||
@@ -33,6 +52,25 @@ impl Tensor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a tensor from raw bytes. Used for dtypes without a Rust type
|
||||||
|
/// (e.g. FP8 E4M3) where we store the bit pattern as-is.
|
||||||
|
pub fn from_raw_bytes(data: &[u8], shape: &[usize], dtype: DType) -> Self {
|
||||||
|
let numel: usize = shape.iter().product();
|
||||||
|
assert_eq!(
|
||||||
|
data.len(),
|
||||||
|
numel * dtype.size_bytes(),
|
||||||
|
"raw bytes length {} != expected {} (numel={} * elem_size={})",
|
||||||
|
data.len(), numel * dtype.size_bytes(), numel, dtype.size_bytes()
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
storage: Storage::cpu(data.to_vec()),
|
||||||
|
shape: Dims::from_slice(shape),
|
||||||
|
strides: shape::contiguous_strides(shape),
|
||||||
|
offset: 0,
|
||||||
|
dtype,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn zeros(shape: &[usize], dtype: DType, device: Device) -> Self {
|
pub fn zeros(shape: &[usize], dtype: DType, device: Device) -> Self {
|
||||||
let numel = shape::num_elements(shape);
|
let numel = shape::num_elements(shape);
|
||||||
let len_bytes = numel * dtype.size_bytes();
|
let len_bytes = numel * dtype.size_bytes();
|
||||||
@@ -46,12 +84,29 @@ impl Tensor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allocate a tensor **without zeroing** the backing memory.
|
||||||
|
/// The buffer may contain stale data. Only use when the calling kernel
|
||||||
|
/// will fully overwrite every element before any read.
|
||||||
|
pub fn empty(shape: &[usize], dtype: DType, device: Device) -> Self {
|
||||||
|
let numel = shape::num_elements(shape);
|
||||||
|
let len_bytes = numel * dtype.size_bytes();
|
||||||
|
let storage = Storage::empty(len_bytes, device).expect("alloc failed");
|
||||||
|
Self {
|
||||||
|
storage,
|
||||||
|
shape: Dims::from_slice(shape),
|
||||||
|
strides: shape::contiguous_strides(shape),
|
||||||
|
offset: 0,
|
||||||
|
dtype,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ones(shape: &[usize], dtype: DType) -> Self {
|
pub fn ones(shape: &[usize], dtype: DType) -> Self {
|
||||||
let numel = shape::num_elements(shape);
|
let numel = shape::num_elements(shape);
|
||||||
match dtype {
|
match dtype {
|
||||||
DType::F32 => Self::from_slice(&vec![1.0f32; numel], shape),
|
DType::F32 => Self::from_slice(&vec![1.0f32; numel], shape),
|
||||||
DType::F16 => Self::from_slice(&vec![half::f16::from_f32(1.0); numel], shape),
|
DType::F16 => Self::from_slice(&vec![half::f16::from_f32(1.0); numel], shape),
|
||||||
DType::BF16 => Self::from_slice(&vec![half::bf16::from_f32(1.0); numel], shape),
|
DType::BF16 => Self::from_slice(&vec![half::bf16::from_f32(1.0); numel], shape),
|
||||||
|
DType::FP8E4M3 => panic!("ones() not supported for FP8E4M3"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +140,21 @@ impl Tensor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zero-copy slice along `dim`: keeps elements `[start, start+len)`.
|
||||||
|
pub fn narrow(&self, dim: usize, start: usize, len: usize) -> Self {
|
||||||
|
assert!(dim < self.ndim());
|
||||||
|
assert!(start + len <= self.shape[dim], "narrow out of bounds");
|
||||||
|
let mut new_shape = self.shape.clone();
|
||||||
|
new_shape[dim] = len;
|
||||||
|
Self {
|
||||||
|
storage: self.storage.clone(),
|
||||||
|
shape: new_shape,
|
||||||
|
strides: self.strides.clone(),
|
||||||
|
offset: self.offset + start * self.strides[dim],
|
||||||
|
dtype: self.dtype,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn transpose(&self, dim0: usize, dim1: usize) -> Self {
|
pub fn transpose(&self, dim0: usize, dim1: usize) -> Self {
|
||||||
assert!(dim0 < self.ndim() && dim1 < self.ndim());
|
assert!(dim0 < self.ndim() && dim1 < self.ndim());
|
||||||
let mut new_shape = self.shape.clone();
|
let mut new_shape = self.shape.clone();
|
||||||
@@ -118,10 +188,15 @@ impl Tensor {
|
|||||||
pub fn unsqueeze(&self, dim: usize) -> Self {
|
pub fn unsqueeze(&self, dim: usize) -> Self {
|
||||||
assert!(dim <= self.ndim());
|
assert!(dim <= self.ndim());
|
||||||
let mut new_shape = self.shape.clone();
|
let mut new_shape = self.shape.clone();
|
||||||
let mut new_strides = self.strides.clone();
|
|
||||||
new_shape.insert(dim, 1);
|
new_shape.insert(dim, 1);
|
||||||
let stride_val = if dim < self.strides.len() { self.strides[dim] } else { 1 };
|
let new_strides = if self.is_contiguous() {
|
||||||
new_strides.insert(dim, stride_val);
|
shape::contiguous_strides(&new_shape)
|
||||||
|
} else {
|
||||||
|
let mut s = self.strides.clone();
|
||||||
|
let stride_val = if dim < self.strides.len() { self.strides[dim] } else { 1 };
|
||||||
|
s.insert(dim, stride_val);
|
||||||
|
s
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
storage: self.storage.clone(),
|
storage: self.storage.clone(),
|
||||||
shape: new_shape,
|
shape: new_shape,
|
||||||
@@ -137,9 +212,12 @@ impl Tensor {
|
|||||||
if self.is_contiguous() {
|
if self.is_contiguous() {
|
||||||
return self.clone();
|
return self.clone();
|
||||||
}
|
}
|
||||||
// For GPU tensors: round-trip through CPU (correct but slow).
|
// For GPU tensors: use the registered GPU kernel if available,
|
||||||
// TODO: write a GPU contiguous-copy kernel for performance.
|
// otherwise fall back to CPU round-trip.
|
||||||
if matches!(self.device(), Device::Cuda(_)) {
|
if matches!(self.device(), Device::Cuda(_)) {
|
||||||
|
if let Some(gpu_fn) = GPU_CONTIGUOUS_FN.get() {
|
||||||
|
return gpu_fn(self);
|
||||||
|
}
|
||||||
let cpu = self.to_device(Device::Cpu);
|
let cpu = self.to_device(Device::Cpu);
|
||||||
let contig = cpu.contiguous();
|
let contig = cpu.contiguous();
|
||||||
return contig.to_device(self.device());
|
return contig.to_device(self.device());
|
||||||
@@ -207,6 +285,17 @@ impl Tensor {
|
|||||||
unsafe { std::slice::from_raw_parts(bytes[start..].as_ptr() as *const T, len) }
|
unsafe { std::slice::from_raw_parts(bytes[start..].as_ptr() as *const T, len) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raw byte access for dtypes without a Rust type (e.g. FP8).
|
||||||
|
pub fn as_raw_bytes(&self) -> &[u8] {
|
||||||
|
assert!(self.is_contiguous(), "as_raw_bytes requires contiguous");
|
||||||
|
assert_eq!(self.device(), Device::Cpu, "as_raw_bytes requires CPU");
|
||||||
|
let bytes = self.storage.as_cpu_bytes();
|
||||||
|
let elem_size = self.dtype.size_bytes();
|
||||||
|
let start = self.offset * elem_size;
|
||||||
|
let len = self.numel() * elem_size;
|
||||||
|
&bytes[start..start + len]
|
||||||
|
}
|
||||||
|
|
||||||
/// Raw pointer to storage start (for GPU kernel launch).
|
/// Raw pointer to storage start (for GPU kernel launch).
|
||||||
pub fn data_ptr(&self) -> *const u8 {
|
pub fn data_ptr(&self) -> *const u8 {
|
||||||
match self.device() {
|
match self.device() {
|
||||||
@@ -232,3 +321,58 @@ impl std::fmt::Debug for Tensor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn contiguous_2d() -> Tensor {
|
||||||
|
Tensor::from_slice(&[1.0f32; 12], &[3, 4])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsqueeze_dim0_contiguous() {
|
||||||
|
let t = contiguous_2d();
|
||||||
|
let u = t.unsqueeze(0);
|
||||||
|
assert_eq!(u.shape(), &[1, 3, 4]);
|
||||||
|
assert!(u.is_contiguous());
|
||||||
|
assert_eq!(u.strides(), &[12, 4, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsqueeze_dim1_contiguous() {
|
||||||
|
let t = contiguous_2d();
|
||||||
|
let u = t.unsqueeze(1);
|
||||||
|
assert_eq!(u.shape(), &[3, 1, 4]);
|
||||||
|
assert!(u.is_contiguous());
|
||||||
|
assert_eq!(u.strides(), &[4, 4, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsqueeze_dim2_contiguous() {
|
||||||
|
let t = contiguous_2d();
|
||||||
|
let u = t.unsqueeze(2);
|
||||||
|
assert_eq!(u.shape(), &[3, 4, 1]);
|
||||||
|
assert!(u.is_contiguous());
|
||||||
|
assert_eq!(u.strides(), &[4, 1, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsqueeze_noncontiguous() {
|
||||||
|
// Transpose makes [3,4] into [4,3] with strides [1,4] (non-contiguous)
|
||||||
|
let t = contiguous_2d().transpose(0, 1);
|
||||||
|
assert!(!t.is_contiguous());
|
||||||
|
let u = t.unsqueeze(0);
|
||||||
|
assert_eq!(u.shape(), &[1, 4, 3]);
|
||||||
|
// Non-contiguous path: stride_val copied from strides[0]=1
|
||||||
|
assert_eq!(u.strides(), &[1, 1, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsqueeze_squeeze_roundtrip() {
|
||||||
|
let t = contiguous_2d();
|
||||||
|
let u = t.unsqueeze(1).squeeze(1);
|
||||||
|
assert_eq!(u.shape(), t.shape());
|
||||||
|
assert!(u.is_contiguous());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct Tokenizer {
|
|||||||
special_token_ids: HashMap<u32, String>,
|
special_token_ids: HashMap<u32, String>,
|
||||||
pre_tokenize_re: Regex,
|
pre_tokenize_re: Regex,
|
||||||
eos_token_id: Option<u32>,
|
eos_token_id: Option<u32>,
|
||||||
|
eos_token_ids: Vec<u32>,
|
||||||
byte_fallback: bool,
|
byte_fallback: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +21,24 @@ struct TokenizerJson {
|
|||||||
model: ModelSection,
|
model: ModelSection,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
added_tokens: Vec<AddedToken>,
|
added_tokens: Vec<AddedToken>,
|
||||||
|
#[serde(default)]
|
||||||
|
pre_tokenizer: Option<PreTokenizerSection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PreTokenizerSection {
|
||||||
|
#[serde(default, rename = "type")]
|
||||||
|
kind: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pattern: Option<PatternSpec>,
|
||||||
|
#[serde(default)]
|
||||||
|
pretokenizers: Option<Vec<PreTokenizerSection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PatternSpec {
|
||||||
|
#[serde(rename = "Regex")]
|
||||||
|
regex: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -41,6 +60,7 @@ enum MergeEntry {
|
|||||||
struct AddedToken {
|
struct AddedToken {
|
||||||
id: u32,
|
id: u32,
|
||||||
content: String,
|
content: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
special: bool,
|
special: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +71,6 @@ impl Tokenizer {
|
|||||||
let tj: TokenizerJson = serde_json::from_str(&data)
|
let tj: TokenizerJson = serde_json::from_str(&data)
|
||||||
.unwrap_or_else(|e| panic!("failed to parse tokenizer.json: {e}"));
|
.unwrap_or_else(|e| panic!("failed to parse tokenizer.json: {e}"));
|
||||||
|
|
||||||
let byte_fallback = tj.model.byte_fallback;
|
|
||||||
|
|
||||||
// Build encoder: token bytes → ID
|
// Build encoder: token bytes → ID
|
||||||
// All HF tokenizers use GPT-2 byte-to-unicode mapping for vocab keys.
|
// All HF tokenizers use GPT-2 byte-to-unicode mapping for vocab keys.
|
||||||
let mut encoder = HashMap::new();
|
let mut encoder = HashMap::new();
|
||||||
@@ -92,28 +110,77 @@ impl Tokenizer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special tokens
|
// Added tokens are matched as indivisible tokens by HF tokenizers,
|
||||||
|
// even when their `special` flag is false (for example Qwen3's
|
||||||
|
// <think> and </think> tokens).
|
||||||
let mut special_tokens = HashMap::new();
|
let mut special_tokens = HashMap::new();
|
||||||
let mut special_token_ids = HashMap::new();
|
let mut special_token_ids = HashMap::new();
|
||||||
let mut eos_token_id = None;
|
|
||||||
for at in &tj.added_tokens {
|
for at in &tj.added_tokens {
|
||||||
if at.special {
|
special_tokens.insert(at.content.clone(), at.id);
|
||||||
special_tokens.insert(at.content.clone(), at.id);
|
special_token_ids.insert(at.id, at.content.clone());
|
||||||
special_token_ids.insert(at.id, at.content.clone());
|
decoder.resize(decoder.len().max(at.id as usize + 1), vec![]);
|
||||||
decoder.resize(decoder.len().max(at.id as usize + 1), vec![]);
|
decoder[at.id as usize] = at.content.as_bytes().to_vec();
|
||||||
decoder[at.id as usize] = at.content.as_bytes().to_vec();
|
}
|
||||||
if at.content == "<|endoftext|>" || at.content == "<|end_of_text|>" {
|
// End-of-generation tokens, in priority order. Families differ:
|
||||||
eos_token_id = Some(at.id);
|
// Qwen uses <|im_end|>, Llama <|end_of_text|>, GPT-2 <|endoftext|>.
|
||||||
|
// gpt-oss (harmony) ends the assistant turn with <|return|> and also
|
||||||
|
// treats <|call|> (tool call) and <|endoftext|> as terminators
|
||||||
|
// (see generation_config.json eos_token_id = [200002, 199999, 200012]).
|
||||||
|
let eos_names = [
|
||||||
|
"<|im_end|>",
|
||||||
|
"<|end_of_text|>",
|
||||||
|
"<|return|>",
|
||||||
|
"<|call|>",
|
||||||
|
"<|endoftext|>",
|
||||||
|
];
|
||||||
|
let mut eos_token_ids: Vec<u32> = Vec::new();
|
||||||
|
for name in eos_names {
|
||||||
|
if let Some(&id) = special_tokens.get(name) {
|
||||||
|
if !eos_token_ids.contains(&id) {
|
||||||
|
eos_token_ids.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let eos_token_id = eos_token_ids.first().copied();
|
||||||
|
|
||||||
// Pre-tokenization regex
|
// Pre-tokenization regex: prefer the model's own regex from tokenizer.json,
|
||||||
let pre_tokenize_re = if byte_fallback {
|
// fall back to GPT-2/Qwen heuristic if not present or unsupported.
|
||||||
// Qwen-style: split on whitespace boundaries, keep Unicode words/numbers
|
let model_regex = tj.pre_tokenizer.as_ref().and_then(|pt| {
|
||||||
|
// Direct Split with regex
|
||||||
|
if pt.kind.as_deref() == Some("Split") {
|
||||||
|
return pt.pattern.as_ref().and_then(|p| p.regex.clone());
|
||||||
|
}
|
||||||
|
// Sequence → find the Split entry
|
||||||
|
if let Some(subs) = &pt.pretokenizers {
|
||||||
|
for sub in subs {
|
||||||
|
if sub.kind.as_deref() == Some("Split") {
|
||||||
|
if let Some(r) = sub.pattern.as_ref().and_then(|p| p.regex.clone()) {
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
});
|
||||||
|
|
||||||
|
let pre_tokenize_re = if let Some(ref pat) = model_regex {
|
||||||
|
// Strip unsupported lookahead (?!\S) — Rust regex doesn't support it.
|
||||||
|
// The lookahead only affects trailing-whitespace edge cases.
|
||||||
|
let cleaned = pat.replace(r"(?!\S)", "");
|
||||||
|
match Regex::new(&cleaned) {
|
||||||
|
Ok(re) => re,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("warning: model pre_tokenizer regex failed ({e}), using fallback");
|
||||||
|
if byte_fallback {
|
||||||
|
Regex::new(r"[\p{L}\p{N}]+|[^\s\p{L}\p{N}]|\s+").unwrap()
|
||||||
|
} else {
|
||||||
|
Regex::new(r"'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+").unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if byte_fallback {
|
||||||
Regex::new(r"[\p{L}\p{N}]+|[^\s\p{L}\p{N}]|\s+").unwrap()
|
Regex::new(r"[\p{L}\p{N}]+|[^\s\p{L}\p{N}]|\s+").unwrap()
|
||||||
} else {
|
} else {
|
||||||
// GPT-2 style
|
|
||||||
Regex::new(r"'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+").unwrap()
|
Regex::new(r"'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+").unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +192,7 @@ impl Tokenizer {
|
|||||||
special_token_ids,
|
special_token_ids,
|
||||||
pre_tokenize_re,
|
pre_tokenize_re,
|
||||||
eos_token_id,
|
eos_token_id,
|
||||||
|
eos_token_ids,
|
||||||
byte_fallback,
|
byte_fallback,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,10 +238,26 @@ impl Tokenizer {
|
|||||||
}
|
}
|
||||||
// Fall back to per-byte encoding
|
// Fall back to per-byte encoding
|
||||||
let word_bytes: Vec<u8> = word.bytes().collect();
|
let word_bytes: Vec<u8> = word.bytes().collect();
|
||||||
let mut token_ids: Vec<u32> = word_bytes.iter().map(|&b| {
|
let mut token_ids: Vec<u32> = word_bytes.iter().filter_map(|&b| {
|
||||||
*self.encoder.get(&vec![b]).unwrap_or_else(|| {
|
if let Some(&id) = self.encoder.get(&vec![b]) {
|
||||||
panic!("byte {b} (0x{b:02X}) not in vocab")
|
Some(id)
|
||||||
})
|
} else if self.byte_fallback {
|
||||||
|
let hex_token = format!("<0x{:02X}>", b);
|
||||||
|
if let Some(&id) = self.special_tokens.get(&hex_token) {
|
||||||
|
Some(id)
|
||||||
|
} else if let Some(&id) = self.encoder.get(hex_token.as_bytes()) {
|
||||||
|
Some(id)
|
||||||
|
} else if let Some(&unk_id) = self.special_tokens.get("<unk>") {
|
||||||
|
eprintln!("warning: byte 0x{b:02X} not in vocab, using <unk> token");
|
||||||
|
Some(unk_id)
|
||||||
|
} else {
|
||||||
|
eprintln!("warning: byte 0x{b:02X} not in vocab and no fallback token, using token 0");
|
||||||
|
Some(0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("warning: byte {b} (0x{b:02X}) not in vocab, skipping");
|
||||||
|
None
|
||||||
|
}
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
// BPE merges
|
// BPE merges
|
||||||
@@ -216,10 +300,29 @@ impl Tokenizer {
|
|||||||
String::from_utf8_lossy(&bytes).into_owned()
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decode_token_stream(&self, token_id: u32, pending: &mut Vec<u8>) -> String {
|
||||||
|
if let Some(bytes) = self.decoder.get(token_id as usize) {
|
||||||
|
pending.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
take_valid_utf8(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush_decode_stream(&self, pending: &mut Vec<u8>) -> String {
|
||||||
|
let text = String::from_utf8_lossy(pending).into_owned();
|
||||||
|
pending.clear();
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
pub fn eos_token_id(&self) -> Option<u32> {
|
pub fn eos_token_id(&self) -> Option<u32> {
|
||||||
self.eos_token_id
|
self.eos_token_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if `id` is any end-of-generation token (a model may have several;
|
||||||
|
/// gpt-oss/harmony ends on <|return|>, <|call|>, or <|endoftext|>).
|
||||||
|
pub fn is_eos(&self, id: u32) -> bool {
|
||||||
|
self.eos_token_ids.contains(&id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn vocab_size(&self) -> usize {
|
pub fn vocab_size(&self) -> usize {
|
||||||
self.decoder.len()
|
self.decoder.len()
|
||||||
}
|
}
|
||||||
@@ -236,6 +339,31 @@ fn token_str_to_bytes(s: &str) -> Vec<u8> {
|
|||||||
s.chars().map(|c| unicode_to_byte(c)).collect()
|
s.chars().map(|c| unicode_to_byte(c)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn take_valid_utf8(pending: &mut Vec<u8>) -> String {
|
||||||
|
match std::str::from_utf8(pending) {
|
||||||
|
Ok(text) => {
|
||||||
|
let text = text.to_string();
|
||||||
|
pending.clear();
|
||||||
|
text
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let valid_up_to = err.valid_up_to();
|
||||||
|
if valid_up_to == 0 {
|
||||||
|
if let Some(error_len) = err.error_len() {
|
||||||
|
let invalid_len = error_len.min(pending.len());
|
||||||
|
let text = String::from_utf8_lossy(&pending[..invalid_len]).into_owned();
|
||||||
|
pending.drain(..invalid_len);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&pending[..valid_up_to]).into_owned();
|
||||||
|
pending.drain(..valid_up_to);
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a Unicode char back to the byte it represents in GPT-2 encoding.
|
/// Convert a Unicode char back to the byte it represents in GPT-2 encoding.
|
||||||
fn unicode_to_byte(c: char) -> u8 {
|
fn unicode_to_byte(c: char) -> u8 {
|
||||||
// Build the inverse map on first use
|
// Build the inverse map on first use
|
||||||
@@ -265,3 +393,49 @@ fn unicode_to_byte(c: char) -> u8 {
|
|||||||
panic!("unmapped unicode char U+{:04X} in tokenizer", c as u32)
|
panic!("unmapped unicode char U+{:04X} in tokenizer", c as u32)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{take_valid_utf8, Tokenizer};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qwen_added_tokens_are_indivisible_and_im_end_is_eos() {
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("xserv-tokenizer-test-{}.json", std::process::id()));
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{
|
||||||
|
"model": {
|
||||||
|
"vocab": {},
|
||||||
|
"merges": [],
|
||||||
|
"byte_fallback": false
|
||||||
|
},
|
||||||
|
"added_tokens": [
|
||||||
|
{"id":151643,"content":"<|endoftext|>","special":true},
|
||||||
|
{"id":151644,"content":"<|im_start|>","special":true},
|
||||||
|
{"id":151645,"content":"<|im_end|>","special":true},
|
||||||
|
{"id":151667,"content":"<think>","special":false},
|
||||||
|
{"id":151668,"content":"</think>","special":false}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tokenizer = Tokenizer::from_file(&path);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
assert_eq!(tokenizer.eos_token_id(), Some(151645));
|
||||||
|
assert_eq!(tokenizer.encode("<think>"), vec![151667]);
|
||||||
|
assert_eq!(tokenizer.encode("</think>"), vec![151668]);
|
||||||
|
assert_eq!(tokenizer.decode(&[151645]), "<|im_end|>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_decode_buffers_incomplete_utf8() {
|
||||||
|
let mut pending = vec![0xF0, 0x9F];
|
||||||
|
assert_eq!(take_valid_utf8(&mut pending), "");
|
||||||
|
pending.extend_from_slice(&[0x98, 0x8A, b'!']);
|
||||||
|
assert_eq!(take_valid_utf8(&mut pending), "😊!");
|
||||||
|
assert!(pending.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include <cuda_bf16.h>
|
#include <cuda_bf16.h>
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
// GELU (tanh approximation):
|
// GELU (tanh approximation):
|
||||||
// gelu(x) = 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))
|
// gelu(x) = 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))
|
||||||
@@ -45,6 +46,37 @@ __global__ void scale_bf16_kernel(const __nv_bfloat16* x, __nv_bfloat16* out, fl
|
|||||||
if (idx < n) out[idx] = __float2bfloat16(__bfloat162float(x[idx]) * scale);
|
if (idx < n) out[idx] = __float2bfloat16(__bfloat162float(x[idx]) * scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fused SiLU×Mul: out = silu(gate) * up
|
||||||
|
__global__ void silu_mul_bf16_kernel(const __nv_bfloat16* gate, const __nv_bfloat16* up,
|
||||||
|
__nv_bfloat16* out, int n) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
if (idx < n) {
|
||||||
|
float g = __bfloat162float(gate[idx]);
|
||||||
|
float u = __bfloat162float(up[idx]);
|
||||||
|
float silu_g = g / (1.0f + expf(-g));
|
||||||
|
out[idx] = __float2bfloat16(silu_g * u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpt-oss GLU: gate_up is [N, 2*D] with interleaved columns (gate=even, up=odd).
|
||||||
|
// gate = gate_up[::2].clamp(max=limit)
|
||||||
|
// up = gate_up[1::2].clamp(-limit, limit)
|
||||||
|
// glu = gate * sigmoid(gate * alpha)
|
||||||
|
// out = (up + 1) * glu
|
||||||
|
// Output: [N, D]
|
||||||
|
__global__ void gpt_oss_glu_bf16_kernel(const __nv_bfloat16* gate_up, __nv_bfloat16* out,
|
||||||
|
int n_elements, float alpha, float limit) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
if (idx < n_elements) {
|
||||||
|
float g = __bfloat162float(gate_up[idx * 2]);
|
||||||
|
float u = __bfloat162float(gate_up[idx * 2 + 1]);
|
||||||
|
g = fminf(g, limit);
|
||||||
|
u = fmaxf(fminf(u, limit), -limit);
|
||||||
|
float glu = g / (1.0f + expf(-g * alpha));
|
||||||
|
out[idx] = __float2bfloat16((u + 1.0f) * glu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Element-wise add: out = a + b
|
// Element-wise add: out = a + b
|
||||||
__global__ void add_f32_kernel(const float* a, const float* b, float* out, int n) {
|
__global__ void add_f32_kernel(const float* a, const float* b, float* out, int n) {
|
||||||
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
@@ -71,6 +103,7 @@ void launch_gelu_f32(const void* x, void* out, int n, void* stream) {
|
|||||||
int block = 256;
|
int block = 256;
|
||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
gelu_f32<<<grid, block, 0, (cudaStream_t)stream>>>((const float*)x, (float*)out, n);
|
gelu_f32<<<grid, block, 0, (cudaStream_t)stream>>>((const float*)x, (float*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_gelu_bf16(const void* x, void* out, int n, void* stream) {
|
void launch_gelu_bf16(const void* x, void* out, int n, void* stream) {
|
||||||
@@ -78,12 +111,14 @@ void launch_gelu_bf16(const void* x, void* out, int n, void* stream) {
|
|||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
gelu_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
gelu_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)x, (__nv_bfloat16*)out, n);
|
(const __nv_bfloat16*)x, (__nv_bfloat16*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_silu_f32(const void* x, void* out, int n, void* stream) {
|
void launch_silu_f32(const void* x, void* out, int n, void* stream) {
|
||||||
int block = 256;
|
int block = 256;
|
||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
silu_f32<<<grid, block, 0, (cudaStream_t)stream>>>((const float*)x, (float*)out, n);
|
silu_f32<<<grid, block, 0, (cudaStream_t)stream>>>((const float*)x, (float*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_silu_bf16(const void* x, void* out, int n, void* stream) {
|
void launch_silu_bf16(const void* x, void* out, int n, void* stream) {
|
||||||
@@ -91,6 +126,7 @@ void launch_silu_bf16(const void* x, void* out, int n, void* stream) {
|
|||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
silu_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
silu_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)x, (__nv_bfloat16*)out, n);
|
(const __nv_bfloat16*)x, (__nv_bfloat16*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_scale_f32(const void* x, void* out, float scale, int n, void* stream) {
|
void launch_scale_f32(const void* x, void* out, float scale, int n, void* stream) {
|
||||||
@@ -98,6 +134,7 @@ void launch_scale_f32(const void* x, void* out, float scale, int n, void* stream
|
|||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
scale_f32_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
scale_f32_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)x, (float*)out, scale, n);
|
(const float*)x, (float*)out, scale, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_scale_bf16(const void* x, void* out, float scale, int n, void* stream) {
|
void launch_scale_bf16(const void* x, void* out, float scale, int n, void* stream) {
|
||||||
@@ -105,6 +142,7 @@ void launch_scale_bf16(const void* x, void* out, float scale, int n, void* strea
|
|||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
scale_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
scale_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)x, (__nv_bfloat16*)out, scale, n);
|
(const __nv_bfloat16*)x, (__nv_bfloat16*)out, scale, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_add_f32(const void* a, const void* b, void* out, int n, void* stream) {
|
void launch_add_f32(const void* a, const void* b, void* out, int n, void* stream) {
|
||||||
@@ -112,24 +150,45 @@ void launch_add_f32(const void* a, const void* b, void* out, int n, void* stream
|
|||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
add_f32_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
add_f32_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)a, (const float*)b, (float*)out, n);
|
(const float*)a, (const float*)b, (float*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
void launch_add_bf16(const void* a, const void* b, void* out, int n, void* stream) {
|
void launch_add_bf16(const void* a, const void* b, void* out, int n, void* stream) {
|
||||||
int block = 256;
|
int block = 256;
|
||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
add_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
add_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)a, (const __nv_bfloat16*)b, (__nv_bfloat16*)out, n);
|
(const __nv_bfloat16*)a, (const __nv_bfloat16*)b, (__nv_bfloat16*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
void launch_mul_f32(const void* a, const void* b, void* out, int n, void* stream) {
|
void launch_mul_f32(const void* a, const void* b, void* out, int n, void* stream) {
|
||||||
int block = 256;
|
int block = 256;
|
||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
mul_f32_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
mul_f32_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)a, (const float*)b, (float*)out, n);
|
(const float*)a, (const float*)b, (float*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
void launch_mul_bf16(const void* a, const void* b, void* out, int n, void* stream) {
|
void launch_mul_bf16(const void* a, const void* b, void* out, int n, void* stream) {
|
||||||
int block = 256;
|
int block = 256;
|
||||||
int grid = (n + block - 1) / block;
|
int grid = (n + block - 1) / block;
|
||||||
mul_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
mul_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)a, (const __nv_bfloat16*)b, (__nv_bfloat16*)out, n);
|
(const __nv_bfloat16*)a, (const __nv_bfloat16*)b, (__nv_bfloat16*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_silu_mul_bf16(const void* gate, const void* up, void* out, int n, void* stream) {
|
||||||
|
int block = 256;
|
||||||
|
int grid = (n + block - 1) / block;
|
||||||
|
silu_mul_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)gate, (const __nv_bfloat16*)up, (__nv_bfloat16*)out, n);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_gpt_oss_glu_bf16(const void* gate_up, void* out, int n_elements,
|
||||||
|
float alpha, float limit, void* stream) {
|
||||||
|
int block = 256;
|
||||||
|
int grid = (n_elements + block - 1) / block;
|
||||||
|
gpt_oss_glu_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)gate_up, (__nv_bfloat16*)out, n_elements, alpha, limit);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <cuda_bf16.h>
|
#include <cuda_bf16.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
// Apply causal mask: set scores[row][col] = -inf where col > row + offset.
|
// Apply causal mask: set scores[row][col] = -inf where col > row + offset.
|
||||||
// offset is used for KV cache: when query starts at position `offset`,
|
// offset is used for KV cache: when query starts at position `offset`,
|
||||||
@@ -27,8 +28,7 @@ __global__ void causal_mask_bf16(
|
|||||||
int col = blockIdx.x * blockDim.x + threadIdx.x;
|
int col = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
|
||||||
if (col < cols && col > row + offset) {
|
if (col < cols && col > row + offset) {
|
||||||
// BF16 doesn't have proper -inf literal, use a very large negative
|
scores[batch_idx * rows * cols + row * cols + col] = __float2bfloat16(-INFINITY);
|
||||||
scores[batch_idx * rows * cols + row * cols + col] = __float2bfloat16(-1e9f);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ void launch_causal_mask_f32(void* scores, int batch, int rows, int cols,
|
|||||||
dim3 grid((cols + block - 1) / block, rows, batch);
|
dim3 grid((cols + block - 1) / block, rows, batch);
|
||||||
causal_mask_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
causal_mask_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(float*)scores, rows, cols, offset);
|
(float*)scores, rows, cols, offset);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_causal_mask_bf16(void* scores, int batch, int rows, int cols,
|
void launch_causal_mask_bf16(void* scores, int batch, int rows, int cols,
|
||||||
@@ -48,6 +49,7 @@ void launch_causal_mask_bf16(void* scores, int batch, int rows, int cols,
|
|||||||
dim3 grid((cols + block - 1) / block, rows, batch);
|
dim3 grid((cols + block - 1) / block, rows, batch);
|
||||||
causal_mask_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
causal_mask_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(__nv_bfloat16*)scores, rows, cols, offset);
|
(__nv_bfloat16*)scores, rows, cols, offset);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
621
csrc/attention/flash_attention.cu
Normal file
621
csrc/attention/flash_attention.cu
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include <float.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// Flash Attention 2 forward kernel for BF16 with FP32 accumulation.
|
||||||
|
//
|
||||||
|
// Algorithm: outer loop over Q tiles (BR rows), inner loop over K/V tiles (BC rows).
|
||||||
|
// Uses online softmax — no O(S^2) memory.
|
||||||
|
//
|
||||||
|
// Layout: Q [batch, num_q_heads, q_len, head_dim]
|
||||||
|
// K [batch, num_kv_heads, kv_len, head_dim]
|
||||||
|
// V [batch, num_kv_heads, kv_len, head_dim]
|
||||||
|
// O [batch, num_q_heads, q_len, head_dim]
|
||||||
|
//
|
||||||
|
// Shared memory (BF16):
|
||||||
|
// smem_q[BR][head_dim] — 64 * 128 * 2 = 16 KB (loaded once per Q tile)
|
||||||
|
// smem_kv[BC][head_dim] — 64 * 128 * 2 = 16 KB (alternates K and V)
|
||||||
|
// Total: 32 KB (fits in default 48 KB shared memory)
|
||||||
|
|
||||||
|
#define BR 64
|
||||||
|
#define BC 64
|
||||||
|
#define THREADS_PER_BLOCK 128
|
||||||
|
|
||||||
|
__global__ void flash_attention_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ Q,
|
||||||
|
const __nv_bfloat16* __restrict__ K,
|
||||||
|
const __nv_bfloat16* __restrict__ V,
|
||||||
|
__nv_bfloat16* __restrict__ O,
|
||||||
|
int num_q_heads, int num_kv_heads,
|
||||||
|
int q_len, int kv_len, int head_dim,
|
||||||
|
float scale, int causal
|
||||||
|
) {
|
||||||
|
// Grid: (ceil(q_len / BR), batch * num_q_heads)
|
||||||
|
int q_tile_idx = blockIdx.x;
|
||||||
|
int bh = blockIdx.y;
|
||||||
|
int batch_idx = bh / num_q_heads;
|
||||||
|
int q_head = bh % num_q_heads;
|
||||||
|
|
||||||
|
// GQA: map Q head to KV head
|
||||||
|
int heads_per_group = num_q_heads / num_kv_heads;
|
||||||
|
int kv_head = q_head / heads_per_group;
|
||||||
|
|
||||||
|
int q_tile_start = q_tile_idx * BR;
|
||||||
|
if (q_tile_start >= q_len) return;
|
||||||
|
int q_tile_rows = min(BR, q_len - q_tile_start);
|
||||||
|
|
||||||
|
// Pointers to this batch/head's data
|
||||||
|
const __nv_bfloat16* Q_head = Q + ((long long)batch_idx * num_q_heads + q_head) * q_len * head_dim;
|
||||||
|
const __nv_bfloat16* K_head = K + ((long long)batch_idx * num_kv_heads + kv_head) * kv_len * head_dim;
|
||||||
|
const __nv_bfloat16* V_head = V + ((long long)batch_idx * num_kv_heads + kv_head) * kv_len * head_dim;
|
||||||
|
__nv_bfloat16* O_head = O + ((long long)batch_idx * num_q_heads + q_head) * q_len * head_dim;
|
||||||
|
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
|
||||||
|
// Dynamic shared memory
|
||||||
|
extern __shared__ __nv_bfloat16 smem[];
|
||||||
|
__nv_bfloat16* smem_q = smem; // BR * head_dim elements
|
||||||
|
__nv_bfloat16* smem_kv = smem + BR * head_dim; // BC * head_dim elements
|
||||||
|
|
||||||
|
// ---- Load Q tile into shared memory (cooperative) ----
|
||||||
|
int q_elems = q_tile_rows * head_dim;
|
||||||
|
for (int i = tid; i < q_elems; i += THREADS_PER_BLOCK) {
|
||||||
|
int row = i / head_dim;
|
||||||
|
int col = i % head_dim;
|
||||||
|
smem_q[row * head_dim + col] = Q_head[(q_tile_start + row) * head_dim + col];
|
||||||
|
}
|
||||||
|
// Zero-pad if q_tile_rows < BR
|
||||||
|
for (int i = q_elems + tid; i < BR * head_dim; i += THREADS_PER_BLOCK) {
|
||||||
|
smem_q[i] = __float2bfloat16(0.0f);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// Thread t (0 <= t < q_tile_rows) owns Q row t
|
||||||
|
bool owns_row = (tid < q_tile_rows);
|
||||||
|
|
||||||
|
// Per-thread FP32 accumulators (head_dim up to 128)
|
||||||
|
float O_acc[128];
|
||||||
|
float m_val = -INFINITY;
|
||||||
|
float l_val = 0.0f;
|
||||||
|
if (owns_row) {
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
O_acc[d] = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kv_offset handles cached KV longer than Q (decode step)
|
||||||
|
int kv_offset = kv_len - q_len;
|
||||||
|
int num_kv_tiles = (kv_len + BC - 1) / BC;
|
||||||
|
|
||||||
|
// ---- Inner loop over K/V tiles ----
|
||||||
|
for (int j = 0; j < num_kv_tiles; j++) {
|
||||||
|
int kv_tile_start = j * BC;
|
||||||
|
int kv_tile_cols = min(BC, kv_len - kv_tile_start);
|
||||||
|
|
||||||
|
// Causal: skip entire tile if all K positions are in the future
|
||||||
|
if (causal) {
|
||||||
|
int max_allowed_kv = (q_tile_start + q_tile_rows - 1) + kv_offset;
|
||||||
|
if (kv_tile_start > max_allowed_kv) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Load K tile into smem_kv ----
|
||||||
|
int kv_elems = kv_tile_cols * head_dim;
|
||||||
|
for (int i = tid; i < kv_elems; i += THREADS_PER_BLOCK) {
|
||||||
|
int row = i / head_dim;
|
||||||
|
int col = i % head_dim;
|
||||||
|
smem_kv[row * head_dim + col] = K_head[(kv_tile_start + row) * head_dim + col];
|
||||||
|
}
|
||||||
|
for (int i = kv_elems + tid; i < BC * head_dim; i += THREADS_PER_BLOCK) {
|
||||||
|
smem_kv[i] = __float2bfloat16(0.0f);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// ---- Compute S = Q @ K^T * scale, causal mask, online softmax ----
|
||||||
|
float P[BC];
|
||||||
|
|
||||||
|
if (owns_row) {
|
||||||
|
float row_max = -INFINITY;
|
||||||
|
for (int c = 0; c < kv_tile_cols; c++) {
|
||||||
|
float dot = 0.0f;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
dot += __bfloat162float(smem_q[tid * head_dim + d])
|
||||||
|
* __bfloat162float(smem_kv[c * head_dim + d]);
|
||||||
|
}
|
||||||
|
float s = dot * scale;
|
||||||
|
|
||||||
|
if (causal) {
|
||||||
|
int q_pos = q_tile_start + tid;
|
||||||
|
int kv_pos = kv_tile_start + c;
|
||||||
|
if (kv_pos > q_pos + kv_offset) {
|
||||||
|
s = -INFINITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P[c] = s; // store score temporarily in P
|
||||||
|
row_max = fmaxf(row_max, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online softmax: m_new, P = exp(S - m_new), l_new
|
||||||
|
float m_new = fmaxf(m_val, row_max);
|
||||||
|
|
||||||
|
float psum = 0.0f;
|
||||||
|
for (int c = 0; c < kv_tile_cols; c++) {
|
||||||
|
P[c] = expf(P[c] - m_new);
|
||||||
|
psum += P[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rescale previous accumulator
|
||||||
|
float correction = expf(m_val - m_new);
|
||||||
|
l_val = correction * l_val + psum;
|
||||||
|
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
O_acc[d] *= correction;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_val = m_new;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync before overwriting smem_kv with V tile
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// ---- Load V tile (reuse smem_kv) ----
|
||||||
|
int v_elems = kv_tile_cols * head_dim;
|
||||||
|
for (int i = tid; i < v_elems; i += THREADS_PER_BLOCK) {
|
||||||
|
int row = i / head_dim;
|
||||||
|
int col = i % head_dim;
|
||||||
|
smem_kv[row * head_dim + col] = V_head[(kv_tile_start + row) * head_dim + col];
|
||||||
|
}
|
||||||
|
for (int i = v_elems + tid; i < BC * head_dim; i += THREADS_PER_BLOCK) {
|
||||||
|
smem_kv[i] = __float2bfloat16(0.0f);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// ---- Accumulate O += P @ V_tile ----
|
||||||
|
if (owns_row) {
|
||||||
|
for (int c = 0; c < kv_tile_cols; c++) {
|
||||||
|
float p = P[c];
|
||||||
|
if (p != 0.0f) {
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
O_acc[d] += p * __bfloat162float(smem_kv[c * head_dim + d]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__syncthreads();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Final normalize and write output (convert FP32 → BF16) ----
|
||||||
|
if (owns_row) {
|
||||||
|
float inv_l = (l_val > 0.0f) ? (1.0f / l_val) : 0.0f;
|
||||||
|
int global_row = q_tile_start + tid;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
O_head[global_row * head_dim + d] = __float2bfloat16(O_acc[d] * inv_l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash Attention 2 forward with gpt-oss attention sinks + optional sliding window.
|
||||||
|
// Identical to flash_attention_bf16_kernel, plus:
|
||||||
|
// - sinks: [num_q_heads] BF16 — a per-head extra softmax logit (no value),
|
||||||
|
// folded into the denominator after the K/V tiles (exactly as the decode
|
||||||
|
// sink kernel does).
|
||||||
|
// - window_size > 0: sliding-window mask. Query at global position p attends
|
||||||
|
// to keys k with p - window_size < k <= p (matches HF gpt-oss).
|
||||||
|
__global__ void flash_attention_sinks_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ Q,
|
||||||
|
const __nv_bfloat16* __restrict__ K,
|
||||||
|
const __nv_bfloat16* __restrict__ V,
|
||||||
|
__nv_bfloat16* __restrict__ O,
|
||||||
|
const __nv_bfloat16* __restrict__ sinks, // [num_q_heads] or NULL
|
||||||
|
int num_q_heads, int num_kv_heads,
|
||||||
|
int q_len, int kv_len, int head_dim,
|
||||||
|
float scale, int causal, int window_size
|
||||||
|
) {
|
||||||
|
int q_tile_idx = blockIdx.x;
|
||||||
|
int bh = blockIdx.y;
|
||||||
|
int batch_idx = bh / num_q_heads;
|
||||||
|
int q_head = bh % num_q_heads;
|
||||||
|
|
||||||
|
int heads_per_group = num_q_heads / num_kv_heads;
|
||||||
|
int kv_head = q_head / heads_per_group;
|
||||||
|
|
||||||
|
int q_tile_start = q_tile_idx * BR;
|
||||||
|
if (q_tile_start >= q_len) return;
|
||||||
|
int q_tile_rows = min(BR, q_len - q_tile_start);
|
||||||
|
|
||||||
|
const __nv_bfloat16* Q_head = Q + ((long long)batch_idx * num_q_heads + q_head) * q_len * head_dim;
|
||||||
|
const __nv_bfloat16* K_head = K + ((long long)batch_idx * num_kv_heads + kv_head) * kv_len * head_dim;
|
||||||
|
const __nv_bfloat16* V_head = V + ((long long)batch_idx * num_kv_heads + kv_head) * kv_len * head_dim;
|
||||||
|
__nv_bfloat16* O_head = O + ((long long)batch_idx * num_q_heads + q_head) * q_len * head_dim;
|
||||||
|
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
|
||||||
|
extern __shared__ __nv_bfloat16 smem[];
|
||||||
|
__nv_bfloat16* smem_q = smem;
|
||||||
|
__nv_bfloat16* smem_kv = smem + BR * head_dim;
|
||||||
|
|
||||||
|
int q_elems = q_tile_rows * head_dim;
|
||||||
|
for (int i = tid; i < q_elems; i += THREADS_PER_BLOCK) {
|
||||||
|
int row = i / head_dim;
|
||||||
|
int col = i % head_dim;
|
||||||
|
smem_q[row * head_dim + col] = Q_head[(q_tile_start + row) * head_dim + col];
|
||||||
|
}
|
||||||
|
for (int i = q_elems + tid; i < BR * head_dim; i += THREADS_PER_BLOCK) {
|
||||||
|
smem_q[i] = __float2bfloat16(0.0f);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
bool owns_row = (tid < q_tile_rows);
|
||||||
|
|
||||||
|
float O_acc[128];
|
||||||
|
float m_val = -INFINITY;
|
||||||
|
float l_val = 0.0f;
|
||||||
|
if (owns_row) {
|
||||||
|
for (int d = 0; d < head_dim; d++) O_acc[d] = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
int kv_offset = kv_len - q_len;
|
||||||
|
int num_kv_tiles = (kv_len + BC - 1) / BC;
|
||||||
|
|
||||||
|
for (int j = 0; j < num_kv_tiles; j++) {
|
||||||
|
int kv_tile_start = j * BC;
|
||||||
|
int kv_tile_cols = min(BC, kv_len - kv_tile_start);
|
||||||
|
|
||||||
|
if (causal) {
|
||||||
|
int max_allowed_kv = (q_tile_start + q_tile_rows - 1) + kv_offset;
|
||||||
|
if (kv_tile_start > max_allowed_kv) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int kv_elems = kv_tile_cols * head_dim;
|
||||||
|
for (int i = tid; i < kv_elems; i += THREADS_PER_BLOCK) {
|
||||||
|
int row = i / head_dim;
|
||||||
|
int col = i % head_dim;
|
||||||
|
smem_kv[row * head_dim + col] = K_head[(kv_tile_start + row) * head_dim + col];
|
||||||
|
}
|
||||||
|
for (int i = kv_elems + tid; i < BC * head_dim; i += THREADS_PER_BLOCK) {
|
||||||
|
smem_kv[i] = __float2bfloat16(0.0f);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float P[BC];
|
||||||
|
|
||||||
|
if (owns_row) {
|
||||||
|
float row_max = -INFINITY;
|
||||||
|
int q_pos = q_tile_start + tid + kv_offset; // global query position
|
||||||
|
for (int c = 0; c < kv_tile_cols; c++) {
|
||||||
|
float dot = 0.0f;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
dot += __bfloat162float(smem_q[tid * head_dim + d])
|
||||||
|
* __bfloat162float(smem_kv[c * head_dim + d]);
|
||||||
|
}
|
||||||
|
float s = dot * scale;
|
||||||
|
|
||||||
|
int kv_pos = kv_tile_start + c;
|
||||||
|
if (causal && kv_pos > q_pos) {
|
||||||
|
s = -INFINITY;
|
||||||
|
}
|
||||||
|
// Sliding window: drop keys older than the window.
|
||||||
|
if (window_size > 0 && kv_pos <= q_pos - window_size) {
|
||||||
|
s = -INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
P[c] = s;
|
||||||
|
row_max = fmaxf(row_max, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fully-masked KV tile (every key causal- or window-masked) has
|
||||||
|
// row_max == -INFINITY. Folding it in computes expf(-inf - (-inf))
|
||||||
|
// = NaN, and a later valid tile's 0*NaN correction then poisons the
|
||||||
|
// whole row. This happens for sliding-window layers whenever a
|
||||||
|
// query's window starts past an early tile (the causal `continue`
|
||||||
|
// above only skips fully-future tiles, not out-of-window ones).
|
||||||
|
// A masked tile contributes nothing to the softmax — skip it.
|
||||||
|
if (row_max != -INFINITY) {
|
||||||
|
float m_new = fmaxf(m_val, row_max);
|
||||||
|
float psum = 0.0f;
|
||||||
|
for (int c = 0; c < kv_tile_cols; c++) {
|
||||||
|
P[c] = expf(P[c] - m_new);
|
||||||
|
psum += P[c];
|
||||||
|
}
|
||||||
|
float correction = expf(m_val - m_new);
|
||||||
|
l_val = correction * l_val + psum;
|
||||||
|
for (int d = 0; d < head_dim; d++) O_acc[d] *= correction;
|
||||||
|
m_val = m_new;
|
||||||
|
} else {
|
||||||
|
for (int c = 0; c < kv_tile_cols; c++) P[c] = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
int v_elems = kv_tile_cols * head_dim;
|
||||||
|
for (int i = tid; i < v_elems; i += THREADS_PER_BLOCK) {
|
||||||
|
int row = i / head_dim;
|
||||||
|
int col = i % head_dim;
|
||||||
|
smem_kv[row * head_dim + col] = V_head[(kv_tile_start + row) * head_dim + col];
|
||||||
|
}
|
||||||
|
for (int i = v_elems + tid; i < BC * head_dim; i += THREADS_PER_BLOCK) {
|
||||||
|
smem_kv[i] = __float2bfloat16(0.0f);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
if (owns_row) {
|
||||||
|
for (int c = 0; c < kv_tile_cols; c++) {
|
||||||
|
float p = P[c];
|
||||||
|
if (p != 0.0f) {
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
O_acc[d] += p * __bfloat162float(smem_kv[c * head_dim + d]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold in the per-head attention sink (extra logit, no value contribution).
|
||||||
|
if (owns_row && sinks != nullptr) {
|
||||||
|
float sink_logit = __bfloat162float(sinks[q_head]);
|
||||||
|
float m_new = fmaxf(m_val, sink_logit);
|
||||||
|
float correction = expf(m_val - m_new);
|
||||||
|
l_val = correction * l_val + expf(sink_logit - m_new);
|
||||||
|
for (int d = 0; d < head_dim; d++) O_acc[d] *= correction;
|
||||||
|
m_val = m_new;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owns_row) {
|
||||||
|
float inv_l = (l_val > 0.0f) ? (1.0f / l_val) : 0.0f;
|
||||||
|
int global_row = q_tile_start + tid;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
O_head[global_row * head_dim + d] = __float2bfloat16(O_acc[d] * inv_l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Decode Attention kernel: optimized for Q_len=1 (single-token decode).
|
||||||
|
// Parallelizes across KV sequence dimension instead of Q rows.
|
||||||
|
//
|
||||||
|
// Grid: (batch * num_q_heads, 1) — one block per Q head
|
||||||
|
// Block: 256 threads — each thread handles ceil(kv_len / 256) KV positions
|
||||||
|
// Uses online softmax reduction across threads.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#define DECODE_THREADS 256
|
||||||
|
#define HEAD_DIM_MAX 128
|
||||||
|
|
||||||
|
__global__ void decode_attention_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ Q,
|
||||||
|
const __nv_bfloat16* __restrict__ K,
|
||||||
|
const __nv_bfloat16* __restrict__ V,
|
||||||
|
__nv_bfloat16* __restrict__ O,
|
||||||
|
int num_q_heads, int num_kv_heads,
|
||||||
|
int kv_len, int head_dim,
|
||||||
|
float scale
|
||||||
|
) {
|
||||||
|
int bh = blockIdx.x;
|
||||||
|
int batch_idx = bh / num_q_heads;
|
||||||
|
int q_head = bh % num_q_heads;
|
||||||
|
|
||||||
|
// GQA mapping
|
||||||
|
int heads_per_group = num_q_heads / num_kv_heads;
|
||||||
|
int kv_head = q_head / heads_per_group;
|
||||||
|
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
|
||||||
|
// Pointers to this batch/head's data
|
||||||
|
// Q: [batch, num_q_heads, 1, head_dim]
|
||||||
|
const __nv_bfloat16* Q_ptr = Q + ((long long)batch_idx * num_q_heads + q_head) * head_dim;
|
||||||
|
// K/V: [batch, num_kv_heads, kv_len, head_dim]
|
||||||
|
const __nv_bfloat16* K_base = K + ((long long)batch_idx * num_kv_heads + kv_head) * kv_len * head_dim;
|
||||||
|
const __nv_bfloat16* V_base = V + ((long long)batch_idx * num_kv_heads + kv_head) * kv_len * head_dim;
|
||||||
|
__nv_bfloat16* O_ptr = O + ((long long)batch_idx * num_q_heads + q_head) * head_dim;
|
||||||
|
|
||||||
|
// Load Q vector into registers (head_dim <= 128)
|
||||||
|
float q_reg[HEAD_DIM_MAX];
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
q_reg[d] = __bfloat162float(Q_ptr[d]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each thread processes a chunk of KV positions
|
||||||
|
// Thread tid handles positions: tid, tid+DECODE_THREADS, tid+2*DECODE_THREADS, ...
|
||||||
|
float local_max = -INFINITY;
|
||||||
|
float local_sum = 0.0f;
|
||||||
|
float local_O[HEAD_DIM_MAX];
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
local_O[d] = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int pos = tid; pos < kv_len; pos += DECODE_THREADS) {
|
||||||
|
// Compute dot(Q, K[pos]) * scale
|
||||||
|
const __nv_bfloat16* K_pos = K_base + pos * head_dim;
|
||||||
|
float dot = 0.0f;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
dot += q_reg[d] * __bfloat162float(K_pos[d]);
|
||||||
|
}
|
||||||
|
float s = dot * scale;
|
||||||
|
|
||||||
|
// Online softmax update
|
||||||
|
float new_max = fmaxf(local_max, s);
|
||||||
|
float correction = expf(local_max - new_max);
|
||||||
|
float p = expf(s - new_max);
|
||||||
|
|
||||||
|
// Rescale running sum and O
|
||||||
|
local_sum = local_sum * correction + p;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
local_O[d] = local_O[d] * correction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate V[pos] weighted by p
|
||||||
|
const __nv_bfloat16* V_pos = V_base + pos * head_dim;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
local_O[d] += p * __bfloat162float(V_pos[d]);
|
||||||
|
}
|
||||||
|
|
||||||
|
local_max = new_max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Block-level online softmax reduction ---
|
||||||
|
// We need to combine (local_max, local_sum, local_O) across all threads.
|
||||||
|
// Strategy: reduce max, then each thread rescales, then reduce sum and O.
|
||||||
|
|
||||||
|
// Shared memory for reduction
|
||||||
|
__shared__ float smem_max[32]; // one per warp
|
||||||
|
__shared__ float smem_sum[32];
|
||||||
|
__shared__ float smem_O[HEAD_DIM_MAX]; // final output accumulator
|
||||||
|
|
||||||
|
// Step 1: Block-wide max reduction
|
||||||
|
int lane = tid & 31;
|
||||||
|
int warp_id = tid >> 5;
|
||||||
|
int num_warps = DECODE_THREADS >> 5; // 8 warps
|
||||||
|
|
||||||
|
float warp_max = local_max;
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
warp_max = fmaxf(warp_max, __shfl_down_sync(0xffffffff, warp_max, offset));
|
||||||
|
if (lane == 0) smem_max[warp_id] = warp_max;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float global_max;
|
||||||
|
if (tid == 0) {
|
||||||
|
global_max = smem_max[0];
|
||||||
|
for (int i = 1; i < num_warps; i++)
|
||||||
|
global_max = fmaxf(global_max, smem_max[i]);
|
||||||
|
smem_max[0] = global_max;
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
global_max = smem_max[0];
|
||||||
|
|
||||||
|
// Step 2: Each thread rescales its local_sum and local_O with global_max
|
||||||
|
float rescale = (local_max == -INFINITY) ? 0.0f : expf(local_max - global_max);
|
||||||
|
local_sum *= rescale;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
local_O[d] *= rescale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Reduce sum across block
|
||||||
|
float warp_sum = local_sum;
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
warp_sum += __shfl_down_sync(0xffffffff, warp_sum, offset);
|
||||||
|
if (lane == 0) smem_sum[warp_id] = warp_sum;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float global_sum;
|
||||||
|
if (tid == 0) {
|
||||||
|
global_sum = 0.0f;
|
||||||
|
for (int i = 0; i < num_warps; i++)
|
||||||
|
global_sum += smem_sum[i];
|
||||||
|
smem_sum[0] = global_sum;
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
global_sum = smem_sum[0];
|
||||||
|
|
||||||
|
// Step 4: Reduce O across block (dimension by dimension using shared mem)
|
||||||
|
float inv_sum = (global_sum > 0.0f) ? (1.0f / global_sum) : 0.0f;
|
||||||
|
|
||||||
|
// Process head_dim in chunks: each iteration reduces one dimension
|
||||||
|
// Use shared memory accumulator: each warp contributes via warp reduction + atomic
|
||||||
|
// Actually simpler: iterate over dimensions, warp reduce each, then lane0 atomicAdd to smem_O
|
||||||
|
|
||||||
|
// Initialize smem_O
|
||||||
|
for (int d = tid; d < head_dim; d += DECODE_THREADS) {
|
||||||
|
smem_O[d] = 0.0f;
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// Each thread adds its local_O contributions via warp reduction + atomicAdd
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
float val = local_O[d];
|
||||||
|
// Warp-level reduction
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
val += __shfl_down_sync(0xffffffff, val, offset);
|
||||||
|
if (lane == 0) {
|
||||||
|
atomicAdd(&smem_O[d], val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// Thread 0..head_dim-1 write final output
|
||||||
|
for (int d = tid; d < head_dim; d += DECODE_THREADS) {
|
||||||
|
O_ptr[d] = __float2bfloat16(smem_O[d] * inv_sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_flash_attention_bf16(
|
||||||
|
const void* Q, const void* K, const void* V, void* O,
|
||||||
|
int batch, int num_q_heads, int num_kv_heads,
|
||||||
|
int q_len, int kv_len, int head_dim,
|
||||||
|
float scale, int causal, void* stream
|
||||||
|
) {
|
||||||
|
int q_tiles = (q_len + BR - 1) / BR;
|
||||||
|
dim3 grid(q_tiles, batch * num_q_heads);
|
||||||
|
int block = THREADS_PER_BLOCK;
|
||||||
|
|
||||||
|
// Shared memory: smem_q[BR * head_dim] + smem_kv[BC * head_dim], all BF16
|
||||||
|
int smem_bytes = (BR + BC) * head_dim * (int)sizeof(__nv_bfloat16);
|
||||||
|
|
||||||
|
flash_attention_bf16_kernel<<<grid, block, smem_bytes, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)Q,
|
||||||
|
(const __nv_bfloat16*)K,
|
||||||
|
(const __nv_bfloat16*)V,
|
||||||
|
(__nv_bfloat16*)O,
|
||||||
|
num_q_heads, num_kv_heads,
|
||||||
|
q_len, kv_len, head_dim,
|
||||||
|
scale, causal
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_flash_attention_sinks_bf16(
|
||||||
|
const void* Q, const void* K, const void* V, void* O,
|
||||||
|
const void* sinks,
|
||||||
|
int batch, int num_q_heads, int num_kv_heads,
|
||||||
|
int q_len, int kv_len, int head_dim,
|
||||||
|
float scale, int causal, int window_size, void* stream
|
||||||
|
) {
|
||||||
|
int q_tiles = (q_len + BR - 1) / BR;
|
||||||
|
dim3 grid(q_tiles, batch * num_q_heads);
|
||||||
|
int block = THREADS_PER_BLOCK;
|
||||||
|
int smem_bytes = (BR + BC) * head_dim * (int)sizeof(__nv_bfloat16);
|
||||||
|
|
||||||
|
flash_attention_sinks_bf16_kernel<<<grid, block, smem_bytes, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)Q,
|
||||||
|
(const __nv_bfloat16*)K,
|
||||||
|
(const __nv_bfloat16*)V,
|
||||||
|
(__nv_bfloat16*)O,
|
||||||
|
(const __nv_bfloat16*)sinks,
|
||||||
|
num_q_heads, num_kv_heads,
|
||||||
|
q_len, kv_len, head_dim,
|
||||||
|
scale, causal, window_size
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_decode_attention_bf16(
|
||||||
|
const void* Q, const void* K, const void* V, void* O,
|
||||||
|
int batch, int num_q_heads, int num_kv_heads,
|
||||||
|
int kv_len, int head_dim,
|
||||||
|
float scale, int causal, void* stream
|
||||||
|
) {
|
||||||
|
int grid = batch * num_q_heads;
|
||||||
|
int block = DECODE_THREADS;
|
||||||
|
|
||||||
|
decode_attention_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)Q,
|
||||||
|
(const __nv_bfloat16*)K,
|
||||||
|
(const __nv_bfloat16*)V,
|
||||||
|
(__nv_bfloat16*)O,
|
||||||
|
num_q_heads, num_kv_heads,
|
||||||
|
kv_len, head_dim,
|
||||||
|
scale
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
411
csrc/attention/paged_attention.cu
Normal file
411
csrc/attention/paged_attention.cu
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include <float.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// Paged decode attention kernel for BF16 with FP32 accumulation.
|
||||||
|
//
|
||||||
|
// Reads K/V from a paged pool indexed by a per-sequence block table.
|
||||||
|
// One CUDA block per (sequence, q_head). Each block streams over the
|
||||||
|
// sequence's KV positions and accumulates attention output via online
|
||||||
|
// softmax.
|
||||||
|
//
|
||||||
|
// Layouts:
|
||||||
|
// Q [batch, num_q_heads, 1, head_dim] BF16
|
||||||
|
// K_cache [num_blocks, num_kv_heads, BLOCK_SIZE, head_dim] BF16
|
||||||
|
// V_cache same
|
||||||
|
// block_tables [max_seqs, max_blocks_per_seq] int32
|
||||||
|
// — the i-th sequence in this launch reads row
|
||||||
|
// block_tables[seq_slot[i] * stride + ...].
|
||||||
|
// For simplicity the launch passes a packed row table
|
||||||
|
// [batch, max_blocks_per_seq] (already gathered for the
|
||||||
|
// active batch) so we just index by blockIdx.x_seq.
|
||||||
|
// context_lens [batch] int32 — number of valid tokens per sequence.
|
||||||
|
//
|
||||||
|
// One CUDA block: 256 threads, head_dim <= 128.
|
||||||
|
|
||||||
|
#define PAGED_BLOCK_SIZE 16
|
||||||
|
#define PAGED_THREADS 256
|
||||||
|
#define PAGED_HEAD_DIM_MAX 128
|
||||||
|
|
||||||
|
__global__ void paged_decode_attention_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ Q,
|
||||||
|
const __nv_bfloat16* __restrict__ K_cache,
|
||||||
|
const __nv_bfloat16* __restrict__ V_cache,
|
||||||
|
__nv_bfloat16* __restrict__ O,
|
||||||
|
const int* __restrict__ block_tables, // [batch, max_blocks_per_seq]
|
||||||
|
const int* __restrict__ context_lens, // [batch]
|
||||||
|
int num_q_heads, int num_kv_heads,
|
||||||
|
int head_dim, int max_blocks_per_seq,
|
||||||
|
float scale
|
||||||
|
) {
|
||||||
|
int seq_idx = blockIdx.y; // batch dim
|
||||||
|
int q_head = blockIdx.x; // 0 .. num_q_heads-1
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
|
||||||
|
int kv_len = context_lens[seq_idx];
|
||||||
|
if (kv_len <= 0) {
|
||||||
|
// Nothing to attend over; zero output for safety.
|
||||||
|
if (tid < head_dim) {
|
||||||
|
O[((long long)seq_idx * num_q_heads + q_head) * head_dim + tid] =
|
||||||
|
__float2bfloat16(0.0f);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GQA mapping
|
||||||
|
int heads_per_group = num_q_heads / num_kv_heads;
|
||||||
|
int kv_head = q_head / heads_per_group;
|
||||||
|
|
||||||
|
// Pointers
|
||||||
|
const __nv_bfloat16* Q_ptr = Q +
|
||||||
|
((long long)seq_idx * num_q_heads + q_head) * head_dim;
|
||||||
|
__nv_bfloat16* O_ptr = O +
|
||||||
|
((long long)seq_idx * num_q_heads + q_head) * head_dim;
|
||||||
|
const int* bt = block_tables + (long long)seq_idx * max_blocks_per_seq;
|
||||||
|
|
||||||
|
// Load Q vector into registers.
|
||||||
|
float q_reg[PAGED_HEAD_DIM_MAX];
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
q_reg[d] = __bfloat162float(Q_ptr[d]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-thread online softmax state.
|
||||||
|
float local_max = -INFINITY;
|
||||||
|
float local_sum = 0.0f;
|
||||||
|
float local_O[PAGED_HEAD_DIM_MAX];
|
||||||
|
for (int d = 0; d < head_dim; d++) local_O[d] = 0.0f;
|
||||||
|
|
||||||
|
int kv_stride_block = num_kv_heads * PAGED_BLOCK_SIZE * head_dim;
|
||||||
|
int kv_stride_head = PAGED_BLOCK_SIZE * head_dim;
|
||||||
|
|
||||||
|
// Each thread handles positions tid, tid+PAGED_THREADS, ...
|
||||||
|
for (int pos = tid; pos < kv_len; pos += PAGED_THREADS) {
|
||||||
|
int logical_blk = pos / PAGED_BLOCK_SIZE;
|
||||||
|
int slot_in_blk = pos % PAGED_BLOCK_SIZE;
|
||||||
|
int phys_blk = bt[logical_blk];
|
||||||
|
|
||||||
|
const __nv_bfloat16* K_pos = K_cache
|
||||||
|
+ (long long)phys_blk * kv_stride_block
|
||||||
|
+ kv_head * kv_stride_head
|
||||||
|
+ slot_in_blk * head_dim;
|
||||||
|
const __nv_bfloat16* V_pos = V_cache
|
||||||
|
+ (long long)phys_blk * kv_stride_block
|
||||||
|
+ kv_head * kv_stride_head
|
||||||
|
+ slot_in_blk * head_dim;
|
||||||
|
|
||||||
|
// dot(Q, K[pos]) * scale
|
||||||
|
float dot = 0.0f;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
dot += q_reg[d] * __bfloat162float(K_pos[d]);
|
||||||
|
}
|
||||||
|
float s = dot * scale;
|
||||||
|
|
||||||
|
float new_max = fmaxf(local_max, s);
|
||||||
|
float correction = expf(local_max - new_max);
|
||||||
|
float p = expf(s - new_max);
|
||||||
|
|
||||||
|
local_sum = local_sum * correction + p;
|
||||||
|
for (int d = 0; d < head_dim; d++) local_O[d] *= correction;
|
||||||
|
|
||||||
|
// Accumulate weighted V.
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
local_O[d] += p * __bfloat162float(V_pos[d]);
|
||||||
|
}
|
||||||
|
|
||||||
|
local_max = new_max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Block-level online softmax reduction ----
|
||||||
|
__shared__ float smem_max[32];
|
||||||
|
__shared__ float smem_sum[32];
|
||||||
|
__shared__ float smem_O[PAGED_HEAD_DIM_MAX];
|
||||||
|
|
||||||
|
int lane = tid & 31;
|
||||||
|
int warp_id = tid >> 5;
|
||||||
|
int num_warps = PAGED_THREADS >> 5;
|
||||||
|
|
||||||
|
// Step 1: block-wide max
|
||||||
|
float warp_max = local_max;
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
warp_max = fmaxf(warp_max, __shfl_down_sync(0xffffffff, warp_max, offset));
|
||||||
|
if (lane == 0) smem_max[warp_id] = warp_max;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float global_max;
|
||||||
|
if (tid == 0) {
|
||||||
|
global_max = smem_max[0];
|
||||||
|
for (int i = 1; i < num_warps; i++)
|
||||||
|
global_max = fmaxf(global_max, smem_max[i]);
|
||||||
|
smem_max[0] = global_max;
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
global_max = smem_max[0];
|
||||||
|
|
||||||
|
// Step 2: rescale local state to global_max
|
||||||
|
float rescale = (local_max == -INFINITY) ? 0.0f : expf(local_max - global_max);
|
||||||
|
local_sum *= rescale;
|
||||||
|
for (int d = 0; d < head_dim; d++) local_O[d] *= rescale;
|
||||||
|
|
||||||
|
// Step 3: reduce sum
|
||||||
|
float warp_sum = local_sum;
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
warp_sum += __shfl_down_sync(0xffffffff, warp_sum, offset);
|
||||||
|
if (lane == 0) smem_sum[warp_id] = warp_sum;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float global_sum;
|
||||||
|
if (tid == 0) {
|
||||||
|
global_sum = 0.0f;
|
||||||
|
for (int i = 0; i < num_warps; i++) global_sum += smem_sum[i];
|
||||||
|
smem_sum[0] = global_sum;
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
global_sum = smem_sum[0];
|
||||||
|
|
||||||
|
// Step 4: reduce O across block, dim by dim
|
||||||
|
for (int d = tid; d < head_dim; d += PAGED_THREADS) smem_O[d] = 0.0f;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
float val = local_O[d];
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
val += __shfl_down_sync(0xffffffff, val, offset);
|
||||||
|
if (lane == 0) atomicAdd(&smem_O[d], val);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float inv_sum = (global_sum > 0.0f) ? (1.0f / global_sum) : 0.0f;
|
||||||
|
for (int d = tid; d < head_dim; d += PAGED_THREADS) {
|
||||||
|
O_ptr[d] = __float2bfloat16(smem_O[d] * inv_sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended paged decode attention with attention sinks and sliding window.
|
||||||
|
// sinks: [num_q_heads] BF16 — per-head extra logit appended before softmax.
|
||||||
|
// window_size: >0 = sliding window (only attend to last `window_size` positions), 0 = full.
|
||||||
|
__global__ void paged_decode_attention_sinks_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ Q,
|
||||||
|
const __nv_bfloat16* __restrict__ K_cache,
|
||||||
|
const __nv_bfloat16* __restrict__ V_cache,
|
||||||
|
__nv_bfloat16* __restrict__ O,
|
||||||
|
const int* __restrict__ block_tables,
|
||||||
|
const int* __restrict__ context_lens,
|
||||||
|
const __nv_bfloat16* __restrict__ sinks, // [num_q_heads] or NULL
|
||||||
|
int num_q_heads, int num_kv_heads,
|
||||||
|
int head_dim, int max_blocks_per_seq,
|
||||||
|
float scale, int window_size
|
||||||
|
) {
|
||||||
|
int seq_idx = blockIdx.y;
|
||||||
|
int q_head = blockIdx.x;
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
|
||||||
|
int kv_len = context_lens[seq_idx];
|
||||||
|
if (kv_len <= 0) {
|
||||||
|
if (tid < head_dim) {
|
||||||
|
O[((long long)seq_idx * num_q_heads + q_head) * head_dim + tid] =
|
||||||
|
__float2bfloat16(0.0f);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int heads_per_group = num_q_heads / num_kv_heads;
|
||||||
|
int kv_head = q_head / heads_per_group;
|
||||||
|
|
||||||
|
const __nv_bfloat16* Q_ptr = Q +
|
||||||
|
((long long)seq_idx * num_q_heads + q_head) * head_dim;
|
||||||
|
__nv_bfloat16* O_ptr = O +
|
||||||
|
((long long)seq_idx * num_q_heads + q_head) * head_dim;
|
||||||
|
const int* bt = block_tables + (long long)seq_idx * max_blocks_per_seq;
|
||||||
|
|
||||||
|
// Sliding window: only attend to positions [kv_len - window_size, kv_len)
|
||||||
|
int start_pos = 0;
|
||||||
|
if (window_size > 0 && kv_len > window_size) {
|
||||||
|
start_pos = kv_len - window_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
float q_reg[PAGED_HEAD_DIM_MAX];
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
q_reg[d] = __bfloat162float(Q_ptr[d]);
|
||||||
|
}
|
||||||
|
|
||||||
|
float local_max = -INFINITY;
|
||||||
|
float local_sum = 0.0f;
|
||||||
|
float local_O[PAGED_HEAD_DIM_MAX];
|
||||||
|
for (int d = 0; d < head_dim; d++) local_O[d] = 0.0f;
|
||||||
|
|
||||||
|
int kv_stride_block = num_kv_heads * PAGED_BLOCK_SIZE * head_dim;
|
||||||
|
int kv_stride_head = PAGED_BLOCK_SIZE * head_dim;
|
||||||
|
|
||||||
|
int attend_len = kv_len - start_pos;
|
||||||
|
for (int rel = tid; rel < attend_len; rel += PAGED_THREADS) {
|
||||||
|
int pos = start_pos + rel;
|
||||||
|
int logical_blk = pos / PAGED_BLOCK_SIZE;
|
||||||
|
int slot_in_blk = pos % PAGED_BLOCK_SIZE;
|
||||||
|
int phys_blk = bt[logical_blk];
|
||||||
|
|
||||||
|
const __nv_bfloat16* K_pos = K_cache
|
||||||
|
+ (long long)phys_blk * kv_stride_block
|
||||||
|
+ kv_head * kv_stride_head
|
||||||
|
+ slot_in_blk * head_dim;
|
||||||
|
const __nv_bfloat16* V_pos = V_cache
|
||||||
|
+ (long long)phys_blk * kv_stride_block
|
||||||
|
+ kv_head * kv_stride_head
|
||||||
|
+ slot_in_blk * head_dim;
|
||||||
|
|
||||||
|
float dot = 0.0f;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
dot += q_reg[d] * __bfloat162float(K_pos[d]);
|
||||||
|
}
|
||||||
|
float s = dot * scale;
|
||||||
|
|
||||||
|
float new_max = fmaxf(local_max, s);
|
||||||
|
float correction = expf(local_max - new_max);
|
||||||
|
float p = expf(s - new_max);
|
||||||
|
|
||||||
|
local_sum = local_sum * correction + p;
|
||||||
|
for (int d = 0; d < head_dim; d++) local_O[d] *= correction;
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
local_O[d] += p * __bfloat162float(V_pos[d]);
|
||||||
|
}
|
||||||
|
local_max = new_max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the sink logit (only thread 0 handles it to avoid double-counting)
|
||||||
|
float sink_logit = -INFINITY;
|
||||||
|
if (sinks != nullptr && tid == 0) {
|
||||||
|
sink_logit = __bfloat162float(sinks[q_head]);
|
||||||
|
float new_max = fmaxf(local_max, sink_logit);
|
||||||
|
float correction = expf(local_max - new_max);
|
||||||
|
float p = expf(sink_logit - new_max);
|
||||||
|
local_sum = local_sum * correction + p;
|
||||||
|
for (int d = 0; d < head_dim; d++) local_O[d] *= correction;
|
||||||
|
// Sink absorbs probability but produces no value output (p * 0)
|
||||||
|
local_max = new_max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Block-level online softmax reduction (same as base kernel) ----
|
||||||
|
__shared__ float smem_max[32];
|
||||||
|
__shared__ float smem_sum[32];
|
||||||
|
__shared__ float smem_O[PAGED_HEAD_DIM_MAX];
|
||||||
|
|
||||||
|
int lane = tid & 31;
|
||||||
|
int warp_id = tid >> 5;
|
||||||
|
int num_warps = PAGED_THREADS >> 5;
|
||||||
|
|
||||||
|
float warp_max = local_max;
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
warp_max = fmaxf(warp_max, __shfl_down_sync(0xffffffff, warp_max, offset));
|
||||||
|
if (lane == 0) smem_max[warp_id] = warp_max;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float global_max;
|
||||||
|
if (tid == 0) {
|
||||||
|
global_max = smem_max[0];
|
||||||
|
for (int i = 1; i < num_warps; i++)
|
||||||
|
global_max = fmaxf(global_max, smem_max[i]);
|
||||||
|
smem_max[0] = global_max;
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
global_max = smem_max[0];
|
||||||
|
|
||||||
|
float rescale = (local_max == -INFINITY) ? 0.0f : expf(local_max - global_max);
|
||||||
|
local_sum *= rescale;
|
||||||
|
for (int d = 0; d < head_dim; d++) local_O[d] *= rescale;
|
||||||
|
|
||||||
|
float warp_sum = local_sum;
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
warp_sum += __shfl_down_sync(0xffffffff, warp_sum, offset);
|
||||||
|
if (lane == 0) smem_sum[warp_id] = warp_sum;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float global_sum;
|
||||||
|
if (tid == 0) {
|
||||||
|
global_sum = 0.0f;
|
||||||
|
for (int i = 0; i < num_warps; i++) global_sum += smem_sum[i];
|
||||||
|
smem_sum[0] = global_sum;
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
global_sum = smem_sum[0];
|
||||||
|
|
||||||
|
for (int d = tid; d < head_dim; d += PAGED_THREADS) smem_O[d] = 0.0f;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
for (int d = 0; d < head_dim; d++) {
|
||||||
|
float val = local_O[d];
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1)
|
||||||
|
val += __shfl_down_sync(0xffffffff, val, offset);
|
||||||
|
if (lane == 0) atomicAdd(&smem_O[d], val);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
float inv_sum = (global_sum > 0.0f) ? (1.0f / global_sum) : 0.0f;
|
||||||
|
for (int d = tid; d < head_dim; d += PAGED_THREADS) {
|
||||||
|
O_ptr[d] = __float2bfloat16(smem_O[d] * inv_sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_paged_decode_attention_bf16(
|
||||||
|
const void* Q,
|
||||||
|
const void* K_cache,
|
||||||
|
const void* V_cache,
|
||||||
|
void* O,
|
||||||
|
const int* block_tables,
|
||||||
|
const int* context_lens,
|
||||||
|
int batch, int num_q_heads, int num_kv_heads,
|
||||||
|
int head_dim, int max_blocks_per_seq,
|
||||||
|
float scale, void* stream
|
||||||
|
) {
|
||||||
|
dim3 grid(num_q_heads, batch);
|
||||||
|
int block = PAGED_THREADS;
|
||||||
|
|
||||||
|
paged_decode_attention_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)Q,
|
||||||
|
(const __nv_bfloat16*)K_cache,
|
||||||
|
(const __nv_bfloat16*)V_cache,
|
||||||
|
(__nv_bfloat16*)O,
|
||||||
|
block_tables, context_lens,
|
||||||
|
num_q_heads, num_kv_heads,
|
||||||
|
head_dim, max_blocks_per_seq,
|
||||||
|
scale
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_paged_decode_attention_sinks_bf16(
|
||||||
|
const void* Q,
|
||||||
|
const void* K_cache,
|
||||||
|
const void* V_cache,
|
||||||
|
void* O,
|
||||||
|
const int* block_tables,
|
||||||
|
const int* context_lens,
|
||||||
|
const void* sinks,
|
||||||
|
int batch, int num_q_heads, int num_kv_heads,
|
||||||
|
int head_dim, int max_blocks_per_seq,
|
||||||
|
float scale, int window_size, void* stream
|
||||||
|
) {
|
||||||
|
dim3 grid(num_q_heads, batch);
|
||||||
|
int block = PAGED_THREADS;
|
||||||
|
|
||||||
|
paged_decode_attention_sinks_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)Q,
|
||||||
|
(const __nv_bfloat16*)K_cache,
|
||||||
|
(const __nv_bfloat16*)V_cache,
|
||||||
|
(__nv_bfloat16*)O,
|
||||||
|
block_tables, context_lens,
|
||||||
|
(const __nv_bfloat16*)sinks,
|
||||||
|
num_q_heads, num_kv_heads,
|
||||||
|
head_dim, max_blocks_per_seq,
|
||||||
|
scale, window_size
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
161
csrc/attention/reshape_and_cache.cu
Normal file
161
csrc/attention/reshape_and_cache.cu
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// Scatter [num_tokens] new K/V into a paged KV pool for ONE sequence.
|
||||||
|
//
|
||||||
|
// Source layouts (BF16, contiguous):
|
||||||
|
// k_src, v_src : [num_kv_heads, num_tokens, head_dim] (head-major)
|
||||||
|
//
|
||||||
|
// Pool layouts (BF16, contiguous):
|
||||||
|
// k_pool, v_pool : [num_blocks_total, num_kv_heads, BLOCK_SIZE, head_dim]
|
||||||
|
//
|
||||||
|
// For token t (0 <= t < num_tokens):
|
||||||
|
// p = start_pos + t
|
||||||
|
// logical_blk = p / BLOCK_SIZE
|
||||||
|
// slot_in_blk = p % BLOCK_SIZE
|
||||||
|
// phys = block_ids[logical_blk]
|
||||||
|
// pool[phys, h, slot_in_blk, :] := src[h, t, :]
|
||||||
|
//
|
||||||
|
// Replaces a Rust-side per-token, per-head cudaMemcpy loop. With Qwen3-8B
|
||||||
|
// (8 KV heads, 36 layers) and a 1024-token prefill, that loop fired
|
||||||
|
// ~290k device-side memcpys; one kernel launch per layer is dramatically
|
||||||
|
// less overhead.
|
||||||
|
//
|
||||||
|
// Grid : (num_tokens, num_kv_heads)
|
||||||
|
// Block: head_dim threads (≤128 in practice; head_dim is padded to a
|
||||||
|
// multiple of 32 by the model and all our shipping configs are
|
||||||
|
// 128, so a single warp's worth handles two slots in flight).
|
||||||
|
|
||||||
|
__global__ void reshape_and_cache_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ k_src,
|
||||||
|
const __nv_bfloat16* __restrict__ v_src,
|
||||||
|
__nv_bfloat16* __restrict__ k_pool,
|
||||||
|
__nv_bfloat16* __restrict__ v_pool,
|
||||||
|
const int* __restrict__ block_ids,
|
||||||
|
int num_tokens, int num_heads,
|
||||||
|
int head_dim, int start_pos, int block_size
|
||||||
|
) {
|
||||||
|
int t = blockIdx.x;
|
||||||
|
int h = blockIdx.y;
|
||||||
|
if (t >= num_tokens || h >= num_heads) return;
|
||||||
|
|
||||||
|
int p = start_pos + t;
|
||||||
|
int logical_blk = p / block_size;
|
||||||
|
int slot_in_blk = p - logical_blk * block_size;
|
||||||
|
int phys = block_ids[logical_blk];
|
||||||
|
|
||||||
|
long long src_off = ((long long)h * num_tokens + t) * head_dim;
|
||||||
|
long long dst_off = (((long long)phys * num_heads + h) * block_size + slot_in_blk) * head_dim;
|
||||||
|
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
int blockSize = blockDim.x;
|
||||||
|
|
||||||
|
// Per-thread strided copy. head_dim is typically 128 and blockSize is
|
||||||
|
// 128, so each thread copies exactly one element — but the loop keeps
|
||||||
|
// the kernel correct for non-128 head_dim configs (Phi-style 64, etc.).
|
||||||
|
for (int d = tid; d < head_dim; d += blockSize) {
|
||||||
|
k_pool[dst_off + d] = k_src[src_off + d];
|
||||||
|
v_pool[dst_off + d] = v_src[src_off + d];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched variant: writes one new K/V token per sequence into a paged
|
||||||
|
// pool, indexed by a per-batch block table that also drives the paged
|
||||||
|
// attention kernel. Used in the decode path where every seq advances
|
||||||
|
// by exactly one position per step.
|
||||||
|
//
|
||||||
|
// Source layouts (BF16, contiguous):
|
||||||
|
// k_src, v_src : [batch, num_kv_heads, head_dim]
|
||||||
|
//
|
||||||
|
// Pool layouts (BF16, contiguous):
|
||||||
|
// k_pool, v_pool : [num_blocks_total, num_kv_heads, BLOCK_SIZE, head_dim]
|
||||||
|
//
|
||||||
|
// block_tables : int32 [batch, max_blocks_per_seq]
|
||||||
|
// kv_lens : int32 [batch] (current seq_len BEFORE this step + 1
|
||||||
|
// — i.e. the same buffer paged attention
|
||||||
|
// reads. The new token's logical index
|
||||||
|
// is `kv_lens[b] - 1`.)
|
||||||
|
//
|
||||||
|
// Grid : (batch, num_kv_heads)
|
||||||
|
// Block: head_dim threads.
|
||||||
|
|
||||||
|
__global__ void reshape_and_cache_batched_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ k_src,
|
||||||
|
const __nv_bfloat16* __restrict__ v_src,
|
||||||
|
__nv_bfloat16* __restrict__ k_pool,
|
||||||
|
__nv_bfloat16* __restrict__ v_pool,
|
||||||
|
const int* __restrict__ block_tables,
|
||||||
|
const int* __restrict__ kv_lens,
|
||||||
|
int num_heads, int head_dim,
|
||||||
|
int block_size, int max_blocks_per_seq
|
||||||
|
) {
|
||||||
|
int b = blockIdx.x;
|
||||||
|
int h = blockIdx.y;
|
||||||
|
|
||||||
|
int new_pos = kv_lens[b] - 1;
|
||||||
|
int logical_blk = new_pos / block_size;
|
||||||
|
int slot_in_blk = new_pos - logical_blk * block_size;
|
||||||
|
int phys = block_tables[b * max_blocks_per_seq + logical_blk];
|
||||||
|
|
||||||
|
long long src_off = ((long long)b * num_heads + h) * head_dim;
|
||||||
|
long long dst_off = (((long long)phys * num_heads + h) * block_size + slot_in_blk) * head_dim;
|
||||||
|
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
int blockSize = blockDim.x;
|
||||||
|
for (int d = tid; d < head_dim; d += blockSize) {
|
||||||
|
k_pool[dst_off + d] = k_src[src_off + d];
|
||||||
|
v_pool[dst_off + d] = v_src[src_off + d];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_reshape_and_cache_bf16(
|
||||||
|
const void* k_src, const void* v_src,
|
||||||
|
void* k_pool, void* v_pool,
|
||||||
|
const void* block_ids,
|
||||||
|
int num_tokens, int num_heads,
|
||||||
|
int head_dim, int start_pos, int block_size,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
if (num_tokens <= 0) return;
|
||||||
|
int threads = head_dim < 32 ? 32 : head_dim;
|
||||||
|
if (threads > 1024) threads = 1024;
|
||||||
|
dim3 grid(num_tokens, num_heads);
|
||||||
|
reshape_and_cache_bf16_kernel<<<grid, threads, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)k_src,
|
||||||
|
(const __nv_bfloat16*)v_src,
|
||||||
|
(__nv_bfloat16*)k_pool,
|
||||||
|
(__nv_bfloat16*)v_pool,
|
||||||
|
(const int*)block_ids,
|
||||||
|
num_tokens, num_heads,
|
||||||
|
head_dim, start_pos, block_size
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_reshape_and_cache_batched_bf16(
|
||||||
|
const void* k_src, const void* v_src,
|
||||||
|
void* k_pool, void* v_pool,
|
||||||
|
const void* block_tables, const void* kv_lens,
|
||||||
|
int batch, int num_heads,
|
||||||
|
int head_dim, int block_size, int max_blocks_per_seq,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
if (batch <= 0 || num_heads <= 0) return;
|
||||||
|
int threads = head_dim < 32 ? 32 : head_dim;
|
||||||
|
if (threads > 1024) threads = 1024;
|
||||||
|
dim3 grid(batch, num_heads);
|
||||||
|
reshape_and_cache_batched_bf16_kernel<<<grid, threads, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)k_src,
|
||||||
|
(const __nv_bfloat16*)v_src,
|
||||||
|
(__nv_bfloat16*)k_pool,
|
||||||
|
(__nv_bfloat16*)v_pool,
|
||||||
|
(const int*)block_tables,
|
||||||
|
(const int*)kv_lens,
|
||||||
|
num_heads, head_dim, block_size, max_blocks_per_seq
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -48,3 +48,17 @@ __device__ __forceinline__ float block_reduce_max(float val) {
|
|||||||
if (warp_id == 0) val = warp_reduce_max(val);
|
if (warp_id == 0) val = warp_reduce_max(val);
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Launch error checking (debug builds only) ---
|
||||||
|
#ifdef NDEBUG
|
||||||
|
#define CUDA_CHECK_LAST_ERROR() ((void)0)
|
||||||
|
#else
|
||||||
|
#include <cstdio>
|
||||||
|
#define CUDA_CHECK_LAST_ERROR() do { \
|
||||||
|
cudaError_t err = cudaGetLastError(); \
|
||||||
|
if (err != cudaSuccess) { \
|
||||||
|
fprintf(stderr, "CUDA kernel launch error at %s:%d: %s\n", \
|
||||||
|
__FILE__, __LINE__, cudaGetErrorString(err)); \
|
||||||
|
} \
|
||||||
|
} while(0)
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <cuda_bf16.h>
|
#include <cuda_bf16.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
// Embedding lookup: out[seq_idx] = table[token_ids[seq_idx]]
|
// Embedding lookup: out[seq_idx] = table[token_ids[seq_idx]]
|
||||||
// Grid: num_tokens, Block: handles hidden_size elements per token.
|
// Grid: num_tokens, Block: handles hidden_size elements per token.
|
||||||
@@ -7,10 +8,12 @@ __global__ void embedding_f32(
|
|||||||
const float* __restrict__ table, // [vocab_size, hidden_size]
|
const float* __restrict__ table, // [vocab_size, hidden_size]
|
||||||
const int* __restrict__ token_ids, // [num_tokens]
|
const int* __restrict__ token_ids, // [num_tokens]
|
||||||
float* __restrict__ out, // [num_tokens, hidden_size]
|
float* __restrict__ out, // [num_tokens, hidden_size]
|
||||||
int hidden_size
|
int hidden_size,
|
||||||
|
int vocab_size
|
||||||
) {
|
) {
|
||||||
int token_idx = blockIdx.x;
|
int token_idx = blockIdx.x;
|
||||||
int tid = token_ids[token_idx];
|
int tid = token_ids[token_idx];
|
||||||
|
if (tid < 0 || tid >= vocab_size) return;
|
||||||
const float* row = table + tid * hidden_size;
|
const float* row = table + tid * hidden_size;
|
||||||
float* dst = out + token_idx * hidden_size;
|
float* dst = out + token_idx * hidden_size;
|
||||||
|
|
||||||
@@ -23,10 +26,12 @@ __global__ void embedding_bf16(
|
|||||||
const __nv_bfloat16* __restrict__ table,
|
const __nv_bfloat16* __restrict__ table,
|
||||||
const int* __restrict__ token_ids,
|
const int* __restrict__ token_ids,
|
||||||
__nv_bfloat16* __restrict__ out,
|
__nv_bfloat16* __restrict__ out,
|
||||||
int hidden_size
|
int hidden_size,
|
||||||
|
int vocab_size
|
||||||
) {
|
) {
|
||||||
int token_idx = blockIdx.x;
|
int token_idx = blockIdx.x;
|
||||||
int tid = token_ids[token_idx];
|
int tid = token_ids[token_idx];
|
||||||
|
if (tid < 0 || tid >= vocab_size) return;
|
||||||
const __nv_bfloat16* row = table + tid * hidden_size;
|
const __nv_bfloat16* row = table + tid * hidden_size;
|
||||||
__nv_bfloat16* dst = out + token_idx * hidden_size;
|
__nv_bfloat16* dst = out + token_idx * hidden_size;
|
||||||
|
|
||||||
@@ -38,18 +43,20 @@ __global__ void embedding_bf16(
|
|||||||
extern "C" {
|
extern "C" {
|
||||||
|
|
||||||
void launch_embedding_f32(const void* table, const void* token_ids, void* out,
|
void launch_embedding_f32(const void* table, const void* token_ids, void* out,
|
||||||
int num_tokens, int hidden_size, void* stream) {
|
int num_tokens, int hidden_size, int vocab_size, void* stream) {
|
||||||
int block = (hidden_size < 256) ? hidden_size : 256;
|
int block = (hidden_size < 256) ? hidden_size : 256;
|
||||||
embedding_f32<<<num_tokens, block, 0, (cudaStream_t)stream>>>(
|
embedding_f32<<<num_tokens, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)table, (const int*)token_ids, (float*)out, hidden_size);
|
(const float*)table, (const int*)token_ids, (float*)out, hidden_size, vocab_size);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_embedding_bf16(const void* table, const void* token_ids, void* out,
|
void launch_embedding_bf16(const void* table, const void* token_ids, void* out,
|
||||||
int num_tokens, int hidden_size, void* stream) {
|
int num_tokens, int hidden_size, int vocab_size, void* stream) {
|
||||||
int block = (hidden_size < 256) ? hidden_size : 256;
|
int block = (hidden_size < 256) ? hidden_size : 256;
|
||||||
embedding_bf16<<<num_tokens, block, 0, (cudaStream_t)stream>>>(
|
embedding_bf16<<<num_tokens, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)table, (const int*)token_ids,
|
(const __nv_bfloat16*)table, (const int*)token_ids,
|
||||||
(__nv_bfloat16*)out, hidden_size);
|
(__nv_bfloat16*)out, hidden_size, vocab_size);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
#include <cuda_bf16.h>
|
#include <cuda_bf16.h>
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
// RoPE: Rotary Position Embedding
|
// RoPE: Rotary Position Embedding, using the Qwen/Llama rotate_half layout.
|
||||||
// For each pair (x[2i], x[2i+1]) at position `pos`:
|
// For each dimension i in the first half at position `pos`:
|
||||||
// y[2i] = x[2i] * cos - x[2i+1] * sin
|
// y[i] = x[i] * cos - x[i + half_dim] * sin
|
||||||
// y[2i+1] = x[2i] * sin + x[2i+1] * cos
|
// y[i + half_dim] = x[i + half_dim] * cos + x[i] * sin
|
||||||
// where cos/sin come from precomputed cos_cache/sin_cache.
|
// where cos/sin come from precomputed cos_cache/sin_cache.
|
||||||
//
|
//
|
||||||
// cos_cache[pos][i] = cos(pos * freq[i])
|
// cos_cache[pos][i] = cos(pos * freq[i])
|
||||||
@@ -35,11 +36,11 @@ __global__ void rope_f32(
|
|||||||
float sin_val = sin_cache[pos * half_dim + pair_idx];
|
float sin_val = sin_cache[pos * half_dim + pair_idx];
|
||||||
|
|
||||||
int base = (token_idx * num_heads + head_idx) * head_dim;
|
int base = (token_idx * num_heads + head_idx) * head_dim;
|
||||||
float x0 = x[base + 2 * pair_idx];
|
float x0 = x[base + pair_idx];
|
||||||
float x1 = x[base + 2 * pair_idx + 1];
|
float x1 = x[base + pair_idx + half_dim];
|
||||||
|
|
||||||
x[base + 2 * pair_idx] = x0 * cos_val - x1 * sin_val;
|
x[base + pair_idx] = x0 * cos_val - x1 * sin_val;
|
||||||
x[base + 2 * pair_idx + 1] = x0 * sin_val + x1 * cos_val;
|
x[base + pair_idx + half_dim] = x1 * cos_val + x0 * sin_val;
|
||||||
}
|
}
|
||||||
|
|
||||||
__global__ void rope_bf16(
|
__global__ void rope_bf16(
|
||||||
@@ -61,11 +62,11 @@ __global__ void rope_bf16(
|
|||||||
float sin_val = sin_cache[pos * half_dim + pair_idx];
|
float sin_val = sin_cache[pos * half_dim + pair_idx];
|
||||||
|
|
||||||
int base = (token_idx * num_heads + head_idx) * head_dim;
|
int base = (token_idx * num_heads + head_idx) * head_dim;
|
||||||
float x0 = __bfloat162float(x[base + 2 * pair_idx]);
|
float x0 = __bfloat162float(x[base + pair_idx]);
|
||||||
float x1 = __bfloat162float(x[base + 2 * pair_idx + 1]);
|
float x1 = __bfloat162float(x[base + pair_idx + half_dim]);
|
||||||
|
|
||||||
x[base + 2 * pair_idx] = __float2bfloat16(x0 * cos_val - x1 * sin_val);
|
x[base + pair_idx] = __float2bfloat16(x0 * cos_val - x1 * sin_val);
|
||||||
x[base + 2 * pair_idx + 1] = __float2bfloat16(x0 * sin_val + x1 * cos_val);
|
x[base + pair_idx + half_dim] = __float2bfloat16(x1 * cos_val + x0 * sin_val);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Precompute cos/sin cache on GPU
|
// Precompute cos/sin cache on GPU
|
||||||
@@ -94,6 +95,7 @@ void launch_rope_f32(void* x, const void* cos_cache, const void* sin_cache,
|
|||||||
rope_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
rope_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(float*)x, (const float*)cos_cache, (const float*)sin_cache,
|
(float*)x, (const float*)cos_cache, (const float*)sin_cache,
|
||||||
(const int*)positions, num_heads, head_dim);
|
(const int*)positions, num_heads, head_dim);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_rope_bf16(void* x, const void* cos_cache, const void* sin_cache,
|
void launch_rope_bf16(void* x, const void* cos_cache, const void* sin_cache,
|
||||||
@@ -104,6 +106,7 @@ void launch_rope_bf16(void* x, const void* cos_cache, const void* sin_cache,
|
|||||||
rope_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
rope_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(__nv_bfloat16*)x, (const float*)cos_cache, (const float*)sin_cache,
|
(__nv_bfloat16*)x, (const float*)cos_cache, (const float*)sin_cache,
|
||||||
(const int*)positions, num_heads, head_dim);
|
(const int*)positions, num_heads, head_dim);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_compute_rope_cache(void* cos_cache, void* sin_cache,
|
void launch_compute_rope_cache(void* cos_cache, void* sin_cache,
|
||||||
@@ -111,6 +114,7 @@ void launch_compute_rope_cache(void* cos_cache, void* sin_cache,
|
|||||||
void* stream) {
|
void* stream) {
|
||||||
compute_rope_cache<<<max_seq_len, half_dim, 0, (cudaStream_t)stream>>>(
|
compute_rope_cache<<<max_seq_len, half_dim, 0, (cudaStream_t)stream>>>(
|
||||||
(float*)cos_cache, (float*)sin_cache, max_seq_len, half_dim, theta);
|
(float*)cos_cache, (float*)sin_cache, max_seq_len, half_dim, theta);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
242
csrc/embedding/transpose.cu
Normal file
242
csrc/embedding/transpose.cu
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// Transpose between [S, H, D] and [H, S, D] layouts (used for RoPE and attention).
|
||||||
|
// Also handles [S, H*D] → [H, S, D] (reshape_heads) and reverse (merge_heads).
|
||||||
|
|
||||||
|
// reshape_heads: [S, H*D] → [1, H, S, D]
|
||||||
|
// Input layout: element at [s, h*D + d] = flat[s * H*D + h*D + d]
|
||||||
|
// Output layout: element at [0, h, s, d] = flat[h * S*D + s*D + d]
|
||||||
|
__global__ void reshape_heads_bf16(
|
||||||
|
const __nv_bfloat16* __restrict__ in,
|
||||||
|
__nv_bfloat16* __restrict__ out,
|
||||||
|
int seq_len, int num_heads, int head_dim
|
||||||
|
) {
|
||||||
|
int hidden = num_heads * head_dim;
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int total = seq_len * hidden;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
int s = idx / hidden;
|
||||||
|
int rem = idx % hidden;
|
||||||
|
int h = rem / head_dim;
|
||||||
|
int d = rem % head_dim;
|
||||||
|
|
||||||
|
int out_idx = h * seq_len * head_dim + s * head_dim + d;
|
||||||
|
out[out_idx] = in[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge_heads: [1, H, S, D] → [S, H*D]
|
||||||
|
// Input layout: element at [0, h, s, d] = flat[h * S*D + s*D + d]
|
||||||
|
// Output layout: element at [s, h*D + d] = flat[s * H*D + h*D + d]
|
||||||
|
__global__ void merge_heads_bf16(
|
||||||
|
const __nv_bfloat16* __restrict__ in,
|
||||||
|
__nv_bfloat16* __restrict__ out,
|
||||||
|
int seq_len, int num_heads, int head_dim
|
||||||
|
) {
|
||||||
|
int hidden = num_heads * head_dim;
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int total = seq_len * hidden;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
// idx is output index: [s, h*D + d]
|
||||||
|
int s = idx / hidden;
|
||||||
|
int rem = idx % hidden;
|
||||||
|
int h = rem / head_dim;
|
||||||
|
int d = rem % head_dim;
|
||||||
|
|
||||||
|
int in_idx = h * seq_len * head_dim + s * head_dim + d;
|
||||||
|
out[idx] = in[in_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// transpose_for_rope: [1, H, S, D] → [S, H, D]
|
||||||
|
// Input: [h, s, d] at h*S*D + s*D + d
|
||||||
|
// Output: [s, h, d] at s*H*D + h*D + d
|
||||||
|
__global__ void transpose_hsd_to_shd_bf16(
|
||||||
|
const __nv_bfloat16* __restrict__ in,
|
||||||
|
__nv_bfloat16* __restrict__ out,
|
||||||
|
int seq_len, int num_heads, int head_dim
|
||||||
|
) {
|
||||||
|
int total = seq_len * num_heads * head_dim;
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
// idx = output flat index: s*H*D + h*D + d
|
||||||
|
int s = idx / (num_heads * head_dim);
|
||||||
|
int rem = idx % (num_heads * head_dim);
|
||||||
|
int h = rem / head_dim;
|
||||||
|
int d = rem % head_dim;
|
||||||
|
|
||||||
|
int in_idx = h * seq_len * head_dim + s * head_dim + d;
|
||||||
|
out[idx] = in[in_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// transpose_from_rope: [S, H, D] → [1, H, S, D]
|
||||||
|
// Input: [s, h, d] at s*H*D + h*D + d
|
||||||
|
// Output: [h, s, d] at h*S*D + s*D + d
|
||||||
|
__global__ void transpose_shd_to_hsd_bf16(
|
||||||
|
const __nv_bfloat16* __restrict__ in,
|
||||||
|
__nv_bfloat16* __restrict__ out,
|
||||||
|
int seq_len, int num_heads, int head_dim
|
||||||
|
) {
|
||||||
|
int total = seq_len * num_heads * head_dim;
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
// idx = output flat index: h*S*D + s*D + d
|
||||||
|
int h = idx / (seq_len * head_dim);
|
||||||
|
int rem = idx % (seq_len * head_dim);
|
||||||
|
int s = rem / head_dim;
|
||||||
|
int d = rem % head_dim;
|
||||||
|
|
||||||
|
int in_idx = s * num_heads * head_dim + h * head_dim + d;
|
||||||
|
out[idx] = in[in_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat_kv: [1, KV_H, S, D] → [1, KV_H * n_rep, S, D]
|
||||||
|
__global__ void repeat_kv_bf16(
|
||||||
|
const __nv_bfloat16* __restrict__ in,
|
||||||
|
__nv_bfloat16* __restrict__ out,
|
||||||
|
int kv_heads, int n_rep, int seq_len, int head_dim
|
||||||
|
) {
|
||||||
|
int total_heads = kv_heads * n_rep;
|
||||||
|
int total = total_heads * seq_len * head_dim;
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
int out_h = idx / (seq_len * head_dim);
|
||||||
|
int rem = idx % (seq_len * head_dim);
|
||||||
|
int kv_h = out_h / n_rep;
|
||||||
|
|
||||||
|
int in_idx = kv_h * seq_len * head_dim + rem;
|
||||||
|
out[idx] = in[in_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Generic strided copy (up to 4D) ----
|
||||||
|
// Each thread copies one element. Maps flat contiguous output index to strided input index.
|
||||||
|
// Unused dimensions are padded with shape=1, stride=0.
|
||||||
|
|
||||||
|
__global__ void strided_copy_bf16(
|
||||||
|
const __nv_bfloat16* __restrict__ in,
|
||||||
|
__nv_bfloat16* __restrict__ out,
|
||||||
|
int numel,
|
||||||
|
int ndim,
|
||||||
|
int shape0, int shape1, int shape2, int shape3,
|
||||||
|
int in_stride0, int in_stride1, int in_stride2, int in_stride3,
|
||||||
|
int in_offset
|
||||||
|
) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
if (idx >= numel) return;
|
||||||
|
|
||||||
|
// Decompose flat output index into multi-dim indices (rightmost = fastest)
|
||||||
|
int remaining = idx;
|
||||||
|
int i3 = remaining % shape3; remaining /= shape3;
|
||||||
|
int i2 = remaining % shape2; remaining /= shape2;
|
||||||
|
int i1 = remaining % shape1; remaining /= shape1;
|
||||||
|
int i0 = remaining;
|
||||||
|
|
||||||
|
int in_idx = in_offset + i0 * in_stride0 + i1 * in_stride1 + i2 * in_stride2 + i3 * in_stride3;
|
||||||
|
out[idx] = in[in_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
__global__ void strided_copy_f32(
|
||||||
|
const float* __restrict__ in,
|
||||||
|
float* __restrict__ out,
|
||||||
|
int numel,
|
||||||
|
int ndim,
|
||||||
|
int shape0, int shape1, int shape2, int shape3,
|
||||||
|
int in_stride0, int in_stride1, int in_stride2, int in_stride3,
|
||||||
|
int in_offset
|
||||||
|
) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
if (idx >= numel) return;
|
||||||
|
|
||||||
|
int remaining = idx;
|
||||||
|
int i3 = remaining % shape3; remaining /= shape3;
|
||||||
|
int i2 = remaining % shape2; remaining /= shape2;
|
||||||
|
int i1 = remaining % shape1; remaining /= shape1;
|
||||||
|
int i0 = remaining;
|
||||||
|
|
||||||
|
int in_idx = in_offset + i0 * in_stride0 + i1 * in_stride1 + i2 * in_stride2 + i3 * in_stride3;
|
||||||
|
out[idx] = in[in_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_reshape_heads_bf16(const void* in, void* out,
|
||||||
|
int seq_len, int num_heads, int head_dim, void* stream) {
|
||||||
|
int total = seq_len * num_heads * head_dim;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
reshape_heads_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)in, (__nv_bfloat16*)out, seq_len, num_heads, head_dim);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_merge_heads_bf16(const void* in, void* out,
|
||||||
|
int seq_len, int num_heads, int head_dim, void* stream) {
|
||||||
|
int total = seq_len * num_heads * head_dim;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
merge_heads_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)in, (__nv_bfloat16*)out, seq_len, num_heads, head_dim);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_transpose_hsd_to_shd_bf16(const void* in, void* out,
|
||||||
|
int seq_len, int num_heads, int head_dim, void* stream) {
|
||||||
|
int total = seq_len * num_heads * head_dim;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
transpose_hsd_to_shd_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)in, (__nv_bfloat16*)out, seq_len, num_heads, head_dim);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_transpose_shd_to_hsd_bf16(const void* in, void* out,
|
||||||
|
int seq_len, int num_heads, int head_dim, void* stream) {
|
||||||
|
int total = seq_len * num_heads * head_dim;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
transpose_shd_to_hsd_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)in, (__nv_bfloat16*)out, seq_len, num_heads, head_dim);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_repeat_kv_bf16(const void* in, void* out,
|
||||||
|
int kv_heads, int n_rep, int seq_len, int head_dim, void* stream) {
|
||||||
|
int total = kv_heads * n_rep * seq_len * head_dim;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
repeat_kv_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)in, (__nv_bfloat16*)out, kv_heads, n_rep, seq_len, head_dim);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_strided_copy_bf16(const void* in, void* out, int numel, int ndim,
|
||||||
|
int shape0, int shape1, int shape2, int shape3,
|
||||||
|
int in_stride0, int in_stride1, int in_stride2, int in_stride3,
|
||||||
|
int in_offset, void* stream) {
|
||||||
|
int block = 256;
|
||||||
|
int grid = (numel + block - 1) / block;
|
||||||
|
strided_copy_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)in, (__nv_bfloat16*)out, numel, ndim,
|
||||||
|
shape0, shape1, shape2, shape3,
|
||||||
|
in_stride0, in_stride1, in_stride2, in_stride3, in_offset);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_strided_copy_f32(const void* in, void* out, int numel, int ndim,
|
||||||
|
int shape0, int shape1, int shape2, int shape3,
|
||||||
|
int in_stride0, int in_stride1, int in_stride2, int in_stride3,
|
||||||
|
int in_offset, void* stream) {
|
||||||
|
int block = 256;
|
||||||
|
int grid = (numel + block - 1) / block;
|
||||||
|
strided_copy_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const float*)in, (float*)out, numel, ndim,
|
||||||
|
shape0, shape1, shape2, shape3,
|
||||||
|
in_stride0, in_stride1, in_stride2, in_stride3, in_offset);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
110
csrc/gemm/gemv.cu
Normal file
110
csrc/gemm/gemv.cu
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include <cuda_runtime.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// K-split GEMV for M=1 BF16 decode.
|
||||||
|
//
|
||||||
|
// y[n] = sum_k x[k] * W[k * N + n]
|
||||||
|
//
|
||||||
|
// Grid: (N / TILE_N, K / TILE_K).
|
||||||
|
// All blocks atomicAdd their partial sums into a pre-zeroed FP32 buffer.
|
||||||
|
// A separate conversion kernel writes the final BF16 output.
|
||||||
|
// Launch sequence: cudaMemsetAsync(fp32) → accumulation kernel → convert kernel.
|
||||||
|
|
||||||
|
#define GEMV_TILE_N 128
|
||||||
|
#define GEMV_TILE_K 256
|
||||||
|
#define GEMV_BLOCK 128
|
||||||
|
|
||||||
|
__global__ void gemv_bf16_fused_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ x,
|
||||||
|
const __nv_bfloat16* __restrict__ W,
|
||||||
|
__nv_bfloat16* __restrict__ y_bf16,
|
||||||
|
float* __restrict__ y_fp32,
|
||||||
|
int K, int N,
|
||||||
|
int num_k_blocks
|
||||||
|
) {
|
||||||
|
const int block_n = blockIdx.x;
|
||||||
|
const int block_k = blockIdx.y;
|
||||||
|
const int t = threadIdx.x;
|
||||||
|
const int col = block_n * GEMV_TILE_N + t;
|
||||||
|
|
||||||
|
const int k_start = block_k * GEMV_TILE_K;
|
||||||
|
const int k_end = min(k_start + GEMV_TILE_K, K);
|
||||||
|
const int k_len = k_end - k_start;
|
||||||
|
|
||||||
|
// Cooperative load of x into shared memory uses ALL threads in the block
|
||||||
|
// (indexed by t, independent of col). Threads whose column is out of range
|
||||||
|
// must still help load and reach the barrier — returning early here would
|
||||||
|
// leave part of x_shared uninitialized AND make __syncthreads divergent
|
||||||
|
// (UB). So the col>=N check happens only AFTER the load + barrier. This bug
|
||||||
|
// produced intermittent huge/garbage outputs whenever N % GEMV_TILE_N != 0
|
||||||
|
// (e.g. gpt-oss decode o_proj with N=2880), collapsing the forward pass.
|
||||||
|
__shared__ float x_shared[GEMV_TILE_K];
|
||||||
|
for (int i = t; i < k_len; i += GEMV_BLOCK) {
|
||||||
|
x_shared[i] = __bfloat162float(x[k_start + i]);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
if (col >= N) return;
|
||||||
|
|
||||||
|
float sum = 0.0f;
|
||||||
|
for (int ki = 0; ki < k_len; ki++) {
|
||||||
|
sum += x_shared[ki] * __bfloat162float(W[(long long)(k_start + ki) * N + col]);
|
||||||
|
}
|
||||||
|
|
||||||
|
atomicAdd(&y_fp32[col], sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversion kernel: FP32 accumulator -> BF16 output
|
||||||
|
__global__ void gemv_fp32_to_bf16_kernel(
|
||||||
|
const float* __restrict__ src,
|
||||||
|
__nv_bfloat16* __restrict__ dst,
|
||||||
|
int n
|
||||||
|
) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
if (idx < n) {
|
||||||
|
dst[idx] = __float2bfloat16(src[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_gemv_bf16(
|
||||||
|
const void* x,
|
||||||
|
const void* W,
|
||||||
|
void* y_bf16,
|
||||||
|
void* y_fp32_buf,
|
||||||
|
int K, int N,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
cudaStream_t s = (cudaStream_t)stream;
|
||||||
|
|
||||||
|
// Zero the FP32 accumulator BEFORE the kernel — the kernel uses atomicAdd
|
||||||
|
// across K-blocks with no inter-block ordering, so the buffer must be
|
||||||
|
// pre-zeroed to avoid accumulating on stale data.
|
||||||
|
cudaMemsetAsync(y_fp32_buf, 0, (size_t)N * sizeof(float), s);
|
||||||
|
|
||||||
|
int num_k_blocks = (K + GEMV_TILE_K - 1) / GEMV_TILE_K;
|
||||||
|
dim3 grid((N + GEMV_TILE_N - 1) / GEMV_TILE_N, num_k_blocks);
|
||||||
|
|
||||||
|
gemv_bf16_fused_kernel<<<grid, GEMV_BLOCK, 0, s>>>(
|
||||||
|
(const __nv_bfloat16*)x,
|
||||||
|
(const __nv_bfloat16*)W,
|
||||||
|
(__nv_bfloat16*)y_bf16,
|
||||||
|
(float*)y_fp32_buf,
|
||||||
|
K, N, num_k_blocks
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
|
||||||
|
// FP32 → BF16 conversion (must wait for all K-blocks to finish)
|
||||||
|
int conv_block = 256;
|
||||||
|
int conv_grid = (N + conv_block - 1) / conv_block;
|
||||||
|
gemv_fp32_to_bf16_kernel<<<conv_grid, conv_block, 0, s>>>(
|
||||||
|
(const float*)y_fp32_buf,
|
||||||
|
(__nv_bfloat16*)y_bf16,
|
||||||
|
N
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern "C"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <cuda_bf16.h>
|
#include <cuda_bf16.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
// Naive GEMM: each thread computes one element of C.
|
// Naive GEMM: each thread computes one element of C.
|
||||||
// C[i][j] = sum_k A[i][k] * B[k][j]
|
// C[i][j] = sum_k A[i][k] * B[k][j]
|
||||||
@@ -46,6 +47,7 @@ void launch_gemm_naive_bf16(
|
|||||||
gemm_naive_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
gemm_naive_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)A, (const __nv_bfloat16*)B, (__nv_bfloat16*)C, M, N, K
|
(const __nv_bfloat16*)A, (const __nv_bfloat16*)B, (__nv_bfloat16*)C, M, N, K
|
||||||
);
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_gemm_naive_f32(
|
void launch_gemm_naive_f32(
|
||||||
@@ -57,6 +59,7 @@ void launch_gemm_naive_f32(
|
|||||||
gemm_naive_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
gemm_naive_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)A, (const float*)B, (float*)C, M, N, K
|
(const float*)A, (const float*)B, (float*)C, M, N, K
|
||||||
);
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <cuda_bf16.h>
|
#include <cuda_bf16.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
// Tiled GEMM using shared memory.
|
// Tiled GEMM using shared memory.
|
||||||
// Each thread block loads TILE_SIZE x TILE_SIZE tiles of A and B
|
// Each thread block loads TILE_SIZE x TILE_SIZE tiles of A and B
|
||||||
@@ -100,6 +101,7 @@ void launch_gemm_tiled_f32(
|
|||||||
gemm_tiled_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
gemm_tiled_f32<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)A, (const float*)B, (float*)C, M, N, K
|
(const float*)A, (const float*)B, (float*)C, M, N, K
|
||||||
);
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_gemm_tiled_bf16(
|
void launch_gemm_tiled_bf16(
|
||||||
@@ -111,6 +113,7 @@ void launch_gemm_tiled_bf16(
|
|||||||
gemm_tiled_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
gemm_tiled_bf16<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)A, (const __nv_bfloat16*)B, (__nv_bfloat16*)C, M, N, K
|
(const __nv_bfloat16*)A, (const __nv_bfloat16*)B, (__nv_bfloat16*)C, M, N, K
|
||||||
);
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|||||||
247
csrc/moe/moe_kernels.cu
Normal file
247
csrc/moe/moe_kernels.cu
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include <float.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MoE Top-K + Softmax kernel
|
||||||
|
//
|
||||||
|
// Input: router_logits [num_tokens, num_experts] BF16
|
||||||
|
// Output: topk_ids [num_tokens, top_k] int32
|
||||||
|
// topk_weights [num_tokens, top_k] float32
|
||||||
|
//
|
||||||
|
// One block per token. Threads cooperatively find top-k indices
|
||||||
|
// via repeated argmax, then compute softmax over the k winners.
|
||||||
|
// num_experts <= 256 (fits in registers / shared memory).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#define MOE_MAX_EXPERTS 256
|
||||||
|
#define MOE_MAX_TOPK 8
|
||||||
|
|
||||||
|
__global__ void moe_topk_softmax_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ router_logits,
|
||||||
|
int* __restrict__ topk_ids,
|
||||||
|
float* __restrict__ topk_weights,
|
||||||
|
int num_experts, int top_k
|
||||||
|
) {
|
||||||
|
int token = blockIdx.x;
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
const __nv_bfloat16* row = router_logits + token * num_experts;
|
||||||
|
|
||||||
|
// Load logits into shared memory
|
||||||
|
__shared__ float smem_logits[MOE_MAX_EXPERTS];
|
||||||
|
__shared__ int smem_ids[MOE_MAX_TOPK];
|
||||||
|
__shared__ float smem_vals[MOE_MAX_TOPK];
|
||||||
|
|
||||||
|
for (int i = tid; i < num_experts; i += blockDim.x) {
|
||||||
|
smem_logits[i] = __bfloat162float(row[i]);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// Find top-k via repeated argmax (k is small, typically 4)
|
||||||
|
if (tid == 0) {
|
||||||
|
for (int k = 0; k < top_k; k++) {
|
||||||
|
float best_val = -INFINITY;
|
||||||
|
int best_idx = 0;
|
||||||
|
for (int e = 0; e < num_experts; e++) {
|
||||||
|
if (smem_logits[e] > best_val) {
|
||||||
|
best_val = smem_logits[e];
|
||||||
|
best_idx = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smem_ids[k] = best_idx;
|
||||||
|
smem_vals[k] = best_val;
|
||||||
|
smem_logits[best_idx] = -INFINITY; // mask out selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Softmax over top-k values (in FP32)
|
||||||
|
float max_val = smem_vals[0];
|
||||||
|
for (int k = 1; k < top_k; k++)
|
||||||
|
max_val = fmaxf(max_val, smem_vals[k]);
|
||||||
|
|
||||||
|
float exp_sum = 0.0f;
|
||||||
|
for (int k = 0; k < top_k; k++) {
|
||||||
|
smem_vals[k] = expf(smem_vals[k] - max_val);
|
||||||
|
exp_sum += smem_vals[k];
|
||||||
|
}
|
||||||
|
float inv_sum = 1.0f / exp_sum;
|
||||||
|
for (int k = 0; k < top_k; k++)
|
||||||
|
smem_vals[k] *= inv_sum;
|
||||||
|
|
||||||
|
// Write outputs
|
||||||
|
for (int k = 0; k < top_k; k++) {
|
||||||
|
topk_ids[token * top_k + k] = smem_ids[k];
|
||||||
|
topk_weights[token * top_k + k] = smem_vals[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MoE Replicate kernel
|
||||||
|
//
|
||||||
|
// Input: x [num_tokens, hidden] BF16
|
||||||
|
// Output: x_rep [local_experts, num_tokens, hidden] BF16
|
||||||
|
//
|
||||||
|
// Copies x into each expert's batch slot.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
__global__ void moe_replicate_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ x,
|
||||||
|
__nv_bfloat16* __restrict__ x_rep,
|
||||||
|
int num_tokens, int hidden, int local_experts
|
||||||
|
) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int total = local_experts * num_tokens * hidden;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
int expert = idx / (num_tokens * hidden);
|
||||||
|
int remainder = idx % (num_tokens * hidden);
|
||||||
|
// x_rep[expert, token, dim] = x[token, dim]
|
||||||
|
x_rep[idx] = x[remainder];
|
||||||
|
(void)expert; // suppress unused warning
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MoE Bias Add 3D kernel
|
||||||
|
//
|
||||||
|
// Input: x [batch, num_tokens, dim] BF16 (in-place output)
|
||||||
|
// bias [batch, dim] BF16
|
||||||
|
//
|
||||||
|
// x[b, t, d] += bias[b, d]
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
__global__ void moe_bias_add_3d_bf16_kernel(
|
||||||
|
__nv_bfloat16* __restrict__ x,
|
||||||
|
const __nv_bfloat16* __restrict__ bias,
|
||||||
|
int batch, int num_tokens, int dim
|
||||||
|
) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int total = batch * num_tokens * dim;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
int b = idx / (num_tokens * dim);
|
||||||
|
int d = idx % dim;
|
||||||
|
float v = __bfloat162float(x[idx]) + __bfloat162float(bias[b * dim + d]);
|
||||||
|
x[idx] = __float2bfloat16(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MoE Weighted Sum kernel
|
||||||
|
//
|
||||||
|
// Input: expert_out [local_experts, num_tokens, hidden] BF16
|
||||||
|
// topk_ids [num_tokens, top_k] int32 (global expert ids)
|
||||||
|
// topk_weights[num_tokens, top_k] float32
|
||||||
|
// expert_start: first global expert id this rank owns
|
||||||
|
// local_experts: number of experts this rank owns
|
||||||
|
//
|
||||||
|
// Output: out [num_tokens, hidden] BF16
|
||||||
|
//
|
||||||
|
// For each (token, dim): accumulate in FP32:
|
||||||
|
// sum = 0
|
||||||
|
// for k in 0..top_k:
|
||||||
|
// global_id = topk_ids[token, k]
|
||||||
|
// if global_id in [expert_start, expert_start + local_experts):
|
||||||
|
// local_id = global_id - expert_start
|
||||||
|
// sum += topk_weights[token, k] * expert_out[local_id, token, dim]
|
||||||
|
// out[token, dim] = bf16(sum)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
__global__ void moe_weighted_sum_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ expert_out,
|
||||||
|
const int* __restrict__ topk_ids,
|
||||||
|
const float* __restrict__ topk_weights,
|
||||||
|
__nv_bfloat16* __restrict__ out,
|
||||||
|
int num_tokens, int hidden, int top_k,
|
||||||
|
int expert_start, int local_experts
|
||||||
|
) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int total = num_tokens * hidden;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
int token = idx / hidden;
|
||||||
|
int dim = idx % hidden;
|
||||||
|
|
||||||
|
int expert_stride = num_tokens * hidden; // stride between experts in expert_out
|
||||||
|
|
||||||
|
float sum = 0.0f;
|
||||||
|
for (int k = 0; k < top_k; k++) {
|
||||||
|
int global_id = topk_ids[token * top_k + k];
|
||||||
|
int local_id = global_id - expert_start;
|
||||||
|
if (local_id >= 0 && local_id < local_experts) {
|
||||||
|
float w = topk_weights[token * top_k + k];
|
||||||
|
float v = __bfloat162float(expert_out[local_id * expert_stride + token * hidden + dim]);
|
||||||
|
sum += w * v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[idx] = __float2bfloat16(sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_moe_topk_softmax_bf16(
|
||||||
|
const void* router_logits,
|
||||||
|
void* topk_ids, void* topk_weights,
|
||||||
|
int num_tokens, int num_experts, int top_k,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
int block = 128;
|
||||||
|
moe_topk_softmax_bf16_kernel<<<num_tokens, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)router_logits,
|
||||||
|
(int*)topk_ids, (float*)topk_weights,
|
||||||
|
num_experts, top_k
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_moe_replicate_bf16(
|
||||||
|
const void* x, void* x_rep,
|
||||||
|
int num_tokens, int hidden, int local_experts,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
int total = local_experts * num_tokens * hidden;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
moe_replicate_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)x, (__nv_bfloat16*)x_rep,
|
||||||
|
num_tokens, hidden, local_experts
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_moe_bias_add_3d_bf16(
|
||||||
|
void* x, const void* bias,
|
||||||
|
int batch, int num_tokens, int dim,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
int total = batch * num_tokens * dim;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
moe_bias_add_3d_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(__nv_bfloat16*)x, (const __nv_bfloat16*)bias,
|
||||||
|
batch, num_tokens, dim
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_moe_weighted_sum_bf16(
|
||||||
|
const void* expert_out,
|
||||||
|
const void* topk_ids, const void* topk_weights,
|
||||||
|
void* out,
|
||||||
|
int num_tokens, int hidden, int top_k,
|
||||||
|
int expert_start, int local_experts,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
int total = num_tokens * hidden;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
moe_weighted_sum_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)expert_out,
|
||||||
|
(const int*)topk_ids, (const float*)topk_weights,
|
||||||
|
(__nv_bfloat16*)out,
|
||||||
|
num_tokens, hidden, top_k,
|
||||||
|
expert_start, local_experts
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -14,27 +14,34 @@ __global__ void layernorm_f32(
|
|||||||
const float* x_row = x + row * hidden_size;
|
const float* x_row = x + row * hidden_size;
|
||||||
float* out_row = out + row * hidden_size;
|
float* out_row = out + row * hidden_size;
|
||||||
|
|
||||||
// Welford online: compute mean and variance in one pass
|
// Pass 1: compute mean
|
||||||
float local_sum = 0.0f;
|
float local_sum = 0.0f;
|
||||||
float local_sum_sq = 0.0f;
|
|
||||||
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
||||||
float v = x_row[i];
|
local_sum += x_row[i];
|
||||||
local_sum += v;
|
|
||||||
local_sum_sq += v * v;
|
|
||||||
}
|
}
|
||||||
local_sum = block_reduce_sum(local_sum);
|
local_sum = block_reduce_sum(local_sum);
|
||||||
local_sum_sq = block_reduce_sum(local_sum_sq);
|
|
||||||
|
|
||||||
__shared__ float s_mean, s_inv_std;
|
__shared__ float s_mean, s_inv_std;
|
||||||
if (threadIdx.x == 0) {
|
if (threadIdx.x == 0) {
|
||||||
float mean = local_sum / hidden_size;
|
s_mean = local_sum / hidden_size;
|
||||||
float var = local_sum_sq / hidden_size - mean * mean;
|
|
||||||
s_mean = mean;
|
|
||||||
s_inv_std = rsqrtf(var + eps);
|
|
||||||
}
|
}
|
||||||
__syncthreads();
|
__syncthreads();
|
||||||
|
|
||||||
float mean = s_mean;
|
float mean = s_mean;
|
||||||
|
|
||||||
|
// Pass 2: compute variance = sum((x - mean)^2) / N
|
||||||
|
float local_var = 0.0f;
|
||||||
|
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
||||||
|
float d = x_row[i] - mean;
|
||||||
|
local_var += d * d;
|
||||||
|
}
|
||||||
|
local_var = block_reduce_sum(local_var);
|
||||||
|
|
||||||
|
if (threadIdx.x == 0) {
|
||||||
|
s_inv_std = rsqrtf(local_var / hidden_size + eps);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
float inv_std = s_inv_std;
|
float inv_std = s_inv_std;
|
||||||
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
||||||
out_row[i] = gamma[i] * (x_row[i] - mean) * inv_std + beta[i];
|
out_row[i] = gamma[i] * (x_row[i] - mean) * inv_std + beta[i];
|
||||||
@@ -52,26 +59,34 @@ __global__ void layernorm_bf16(
|
|||||||
const __nv_bfloat16* x_row = x + row * hidden_size;
|
const __nv_bfloat16* x_row = x + row * hidden_size;
|
||||||
__nv_bfloat16* out_row = out + row * hidden_size;
|
__nv_bfloat16* out_row = out + row * hidden_size;
|
||||||
|
|
||||||
|
// Pass 1: compute mean
|
||||||
float local_sum = 0.0f;
|
float local_sum = 0.0f;
|
||||||
float local_sum_sq = 0.0f;
|
|
||||||
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
||||||
float v = __bfloat162float(x_row[i]);
|
local_sum += __bfloat162float(x_row[i]);
|
||||||
local_sum += v;
|
|
||||||
local_sum_sq += v * v;
|
|
||||||
}
|
}
|
||||||
local_sum = block_reduce_sum(local_sum);
|
local_sum = block_reduce_sum(local_sum);
|
||||||
local_sum_sq = block_reduce_sum(local_sum_sq);
|
|
||||||
|
|
||||||
__shared__ float s_mean, s_inv_std;
|
__shared__ float s_mean, s_inv_std;
|
||||||
if (threadIdx.x == 0) {
|
if (threadIdx.x == 0) {
|
||||||
float mean = local_sum / hidden_size;
|
s_mean = local_sum / hidden_size;
|
||||||
float var = local_sum_sq / hidden_size - mean * mean;
|
|
||||||
s_mean = mean;
|
|
||||||
s_inv_std = rsqrtf(var + eps);
|
|
||||||
}
|
}
|
||||||
__syncthreads();
|
__syncthreads();
|
||||||
|
|
||||||
float mean = s_mean;
|
float mean = s_mean;
|
||||||
|
|
||||||
|
// Pass 2: compute variance = sum((x - mean)^2) / N
|
||||||
|
float local_var = 0.0f;
|
||||||
|
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
||||||
|
float d = __bfloat162float(x_row[i]) - mean;
|
||||||
|
local_var += d * d;
|
||||||
|
}
|
||||||
|
local_var = block_reduce_sum(local_var);
|
||||||
|
|
||||||
|
if (threadIdx.x == 0) {
|
||||||
|
s_inv_std = rsqrtf(local_var / hidden_size + eps);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
float inv_std = s_inv_std;
|
float inv_std = s_inv_std;
|
||||||
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
||||||
float v = __bfloat162float(x_row[i]);
|
float v = __bfloat162float(x_row[i]);
|
||||||
@@ -86,17 +101,21 @@ extern "C" {
|
|||||||
void launch_layernorm_f32(const void* x, const void* gamma, const void* beta,
|
void launch_layernorm_f32(const void* x, const void* gamma, const void* beta,
|
||||||
void* out, int rows, int hidden_size, float eps, void* stream) {
|
void* out, int rows, int hidden_size, float eps, void* stream) {
|
||||||
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
||||||
|
if (block < 32) block = 32;
|
||||||
layernorm_f32<<<rows, block, 0, (cudaStream_t)stream>>>(
|
layernorm_f32<<<rows, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)x, (const float*)gamma, (const float*)beta,
|
(const float*)x, (const float*)gamma, (const float*)beta,
|
||||||
(float*)out, hidden_size, eps);
|
(float*)out, hidden_size, eps);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_layernorm_bf16(const void* x, const void* gamma, const void* beta,
|
void launch_layernorm_bf16(const void* x, const void* gamma, const void* beta,
|
||||||
void* out, int rows, int hidden_size, float eps, void* stream) {
|
void* out, int rows, int hidden_size, float eps, void* stream) {
|
||||||
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
||||||
|
if (block < 32) block = 32;
|
||||||
layernorm_bf16<<<rows, block, 0, (cudaStream_t)stream>>>(
|
layernorm_bf16<<<rows, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)x, (const __nv_bfloat16*)gamma, (const __nv_bfloat16*)beta,
|
(const __nv_bfloat16*)x, (const __nv_bfloat16*)gamma, (const __nv_bfloat16*)beta,
|
||||||
(__nv_bfloat16*)out, hidden_size, eps);
|
(__nv_bfloat16*)out, hidden_size, eps);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,21 +63,78 @@ __global__ void rmsnorm_bf16(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fused Add + RMSNorm: sum_out = x + residual, normed_out = rmsnorm(sum_out, gamma, eps)
|
||||||
|
// Each block handles one row of [hidden_size].
|
||||||
|
__global__ void add_rmsnorm_bf16(
|
||||||
|
const __nv_bfloat16* __restrict__ x,
|
||||||
|
const __nv_bfloat16* __restrict__ residual,
|
||||||
|
const __nv_bfloat16* __restrict__ gamma,
|
||||||
|
__nv_bfloat16* __restrict__ normed_out,
|
||||||
|
__nv_bfloat16* __restrict__ sum_out,
|
||||||
|
int hidden_size, float eps
|
||||||
|
) {
|
||||||
|
int row = blockIdx.x;
|
||||||
|
const __nv_bfloat16* x_row = x + row * hidden_size;
|
||||||
|
const __nv_bfloat16* res_row = residual + row * hidden_size;
|
||||||
|
__nv_bfloat16* sum_row = sum_out + row * hidden_size;
|
||||||
|
__nv_bfloat16* norm_row = normed_out + row * hidden_size;
|
||||||
|
|
||||||
|
// Pass 1: compute sum = x + residual, and accumulate sum_sq
|
||||||
|
float sum_sq = 0.0f;
|
||||||
|
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
||||||
|
float s = __bfloat162float(x_row[i]) + __bfloat162float(res_row[i]);
|
||||||
|
sum_row[i] = __float2bfloat16(s);
|
||||||
|
sum_sq += s * s;
|
||||||
|
}
|
||||||
|
sum_sq = block_reduce_sum(sum_sq);
|
||||||
|
|
||||||
|
__shared__ float s_rms_inv;
|
||||||
|
if (threadIdx.x == 0) {
|
||||||
|
s_rms_inv = rsqrtf(sum_sq / hidden_size + eps);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// Pass 2: normed_out = sum * rms_inv * gamma
|
||||||
|
float rms_inv = s_rms_inv;
|
||||||
|
for (int i = threadIdx.x; i < hidden_size; i += blockDim.x) {
|
||||||
|
float s = __bfloat162float(sum_row[i]);
|
||||||
|
float g = __bfloat162float(gamma[i]);
|
||||||
|
norm_row[i] = __float2bfloat16(s * rms_inv * g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
|
|
||||||
void launch_rmsnorm_f32(const void* x, const void* gamma, void* out,
|
void launch_rmsnorm_f32(const void* x, const void* gamma, void* out,
|
||||||
int rows, int hidden_size, float eps, void* stream) {
|
int rows, int hidden_size, float eps, void* stream) {
|
||||||
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
||||||
|
if (block < 32) block = 32;
|
||||||
rmsnorm_f32<<<rows, block, 0, (cudaStream_t)stream>>>(
|
rmsnorm_f32<<<rows, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)x, (const float*)gamma, (float*)out, hidden_size, eps);
|
(const float*)x, (const float*)gamma, (float*)out, hidden_size, eps);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_rmsnorm_bf16(const void* x, const void* gamma, void* out,
|
void launch_rmsnorm_bf16(const void* x, const void* gamma, void* out,
|
||||||
int rows, int hidden_size, float eps, void* stream) {
|
int rows, int hidden_size, float eps, void* stream) {
|
||||||
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
||||||
|
if (block < 32) block = 32;
|
||||||
rmsnorm_bf16<<<rows, block, 0, (cudaStream_t)stream>>>(
|
rmsnorm_bf16<<<rows, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)x, (const __nv_bfloat16*)gamma,
|
(const __nv_bfloat16*)x, (const __nv_bfloat16*)gamma,
|
||||||
(__nv_bfloat16*)out, hidden_size, eps);
|
(__nv_bfloat16*)out, hidden_size, eps);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_add_rmsnorm_bf16(const void* x, const void* residual, const void* gamma,
|
||||||
|
void* normed_out, void* sum_out,
|
||||||
|
int rows, int hidden_size, float eps, void* stream) {
|
||||||
|
int block = (hidden_size < 1024) ? hidden_size : 1024;
|
||||||
|
if (block < 32) block = 32;
|
||||||
|
add_rmsnorm_bf16<<<rows, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)x, (const __nv_bfloat16*)residual,
|
||||||
|
(const __nv_bfloat16*)gamma,
|
||||||
|
(__nv_bfloat16*)normed_out, (__nv_bfloat16*)sum_out,
|
||||||
|
hidden_size, eps);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
51
csrc/quantization/dequant_fp8.cu
Normal file
51
csrc/quantization/dequant_fp8.cu
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include <cuda_fp8.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// Dequantize FP8 E4M3 → BF16 with per-expert (per-batch-slice) FP32 scale.
|
||||||
|
//
|
||||||
|
// Input: src [num_experts, rows, cols] FP8 E4M3 (1 byte each)
|
||||||
|
// scales [num_experts] FP32
|
||||||
|
// Output: dst [num_experts, rows, cols] BF16
|
||||||
|
//
|
||||||
|
// Each element: dst[e, r, c] = bf16( float(src[e, r, c]) * scales[e] )
|
||||||
|
|
||||||
|
__global__ void dequant_fp8e4m3_to_bf16_kernel(
|
||||||
|
const __nv_fp8_e4m3* __restrict__ src,
|
||||||
|
const float* __restrict__ scales,
|
||||||
|
__nv_bfloat16* __restrict__ dst,
|
||||||
|
int num_experts, int rows, int cols
|
||||||
|
) {
|
||||||
|
int idx = blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
int total = num_experts * rows * cols;
|
||||||
|
if (idx >= total) return;
|
||||||
|
|
||||||
|
int expert_stride = rows * cols;
|
||||||
|
int expert = idx / expert_stride;
|
||||||
|
float scale = scales[expert];
|
||||||
|
float val = float(src[idx]) * scale;
|
||||||
|
dst[idx] = __float2bfloat16(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_dequant_fp8e4m3_to_bf16(
|
||||||
|
const void* src,
|
||||||
|
const void* scales,
|
||||||
|
void* dst,
|
||||||
|
int num_experts, int rows, int cols,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
int total = num_experts * rows * cols;
|
||||||
|
int block = 256;
|
||||||
|
int grid = (total + block - 1) / block;
|
||||||
|
dequant_fp8e4m3_to_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_fp8_e4m3*)src,
|
||||||
|
(const float*)scales,
|
||||||
|
(__nv_bfloat16*)dst,
|
||||||
|
num_experts, rows, cols
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
135
csrc/quantization/mxfp4_gemm.cu
Normal file
135
csrc/quantization/mxfp4_gemm.cu
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include <cstdint>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// MXFP4 W4A16 for MoE experts. Weights stored [E, N, K] with K (reduction)
|
||||||
|
// contiguous, blocked by 32: packed 4-bit E2M1 (two nibbles/byte, lo = even k)
|
||||||
|
// + one UE8M0 scale byte per 32 elements. The decode win is reading 4-bit
|
||||||
|
// weights from HBM (half of FP8) and dequantizing on-chip to BF16.
|
||||||
|
|
||||||
|
#define MXFP4_BLOCK 32
|
||||||
|
|
||||||
|
// E2M1 magnitude by 3-bit code; bit 3 is the sign.
|
||||||
|
__device__ __constant__ float kFp4Levels[8] = {0.f, 0.5f, 1.f, 1.5f, 2.f, 3.f, 4.f, 6.f};
|
||||||
|
|
||||||
|
__device__ __forceinline__ float fp4_to_float(uint8_t code) {
|
||||||
|
float mag = kFp4Levels[code & 0x7];
|
||||||
|
return (code & 0x8) ? -mag : mag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode (M=1) fused GEMV, batched over experts.
|
||||||
|
// y[e, n] = sum_k x[e, k] * dequant(W[e, n, k])
|
||||||
|
// Grid: (N/TILE_N, E). Each block loads the activation x[e, :] into shared
|
||||||
|
// memory ONCE and computes TILE_N output columns from it (one warp per column),
|
||||||
|
// so the activation is read from HBM once per TILE_N outputs instead of once
|
||||||
|
// per output. Weights are unique per output and read coalesced as uint4; the
|
||||||
|
// UE8M0 block scale is hoisted to once per 32-element block.
|
||||||
|
#define MXFP4_TILE_N 8 // output columns per block (= warps per block)
|
||||||
|
|
||||||
|
__global__ void batched_gemv_mxfp4_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ x, // [E, K]
|
||||||
|
const uint8_t* __restrict__ w_packed, // [E, N, K/2]
|
||||||
|
const uint8_t* __restrict__ w_scales, // [E, N, K/32]
|
||||||
|
__nv_bfloat16* __restrict__ y, // [E, N]
|
||||||
|
int E, int N, int K
|
||||||
|
) {
|
||||||
|
extern __shared__ float xs[]; // [K] activation for this expert
|
||||||
|
int e = blockIdx.y;
|
||||||
|
int n_base = blockIdx.x * MXFP4_TILE_N;
|
||||||
|
int warp = threadIdx.x >> 5; // 0..TILE_N-1
|
||||||
|
int lane = threadIdx.x & 31;
|
||||||
|
int nthreads = blockDim.x; // TILE_N * 32
|
||||||
|
int nblk = K / MXFP4_BLOCK;
|
||||||
|
|
||||||
|
// Cooperatively stage x[e, :] into shared memory (converted to float).
|
||||||
|
const __nv_bfloat16* xe = x + (long long)e * K;
|
||||||
|
for (int k = threadIdx.x; k < K; k += nthreads) {
|
||||||
|
xs[k] = __bfloat162float(xe[k]);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
int n = n_base + warp;
|
||||||
|
if (n >= N) return;
|
||||||
|
|
||||||
|
const uint8_t* wp = w_packed + ((long long)e * N + n) * (K >> 1);
|
||||||
|
const uint8_t* ws = w_scales + ((long long)e * N + n) * nblk;
|
||||||
|
|
||||||
|
float acc = 0.0f;
|
||||||
|
for (int blk = lane; blk < nblk; blk += 32) {
|
||||||
|
float scale = exp2f((float)((int)ws[blk] - 127));
|
||||||
|
uint4 packed = *(const uint4*)(wp + (long long)blk * 16); // 16 bytes = 32 nibbles
|
||||||
|
const uint8_t* pb = (const uint8_t*)&packed;
|
||||||
|
const float* xk = xs + blk * MXFP4_BLOCK;
|
||||||
|
#pragma unroll
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
uint8_t b = pb[i];
|
||||||
|
acc += xk[2 * i] * (fp4_to_float(b & 0xF) * scale);
|
||||||
|
acc += xk[2 * i + 1] * (fp4_to_float(b >> 4) * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warp reduction.
|
||||||
|
#pragma unroll
|
||||||
|
for (int o = 16; o > 0; o >>= 1) {
|
||||||
|
acc += __shfl_down_sync(0xffffffffu, acc, o);
|
||||||
|
}
|
||||||
|
if (lane == 0) y[(long long)e * N + n] = __float2bfloat16(acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefill fallback: dequant MXFP4 [E, N, K] -> BF16 [E, K, N] (transposed back
|
||||||
|
// to the [E, K, N] layout the BF16 batched GEMM expects). Not bandwidth-optimal,
|
||||||
|
// but prefill is compute-bound so it is not the decode hot path.
|
||||||
|
__global__ void dequant_mxfp4_to_bf16_t_kernel(
|
||||||
|
const uint8_t* __restrict__ w_packed, // [E, N, K/2]
|
||||||
|
const uint8_t* __restrict__ w_scales, // [E, N, K/32]
|
||||||
|
__nv_bfloat16* __restrict__ out, // [E, K, N]
|
||||||
|
int E, int N, int K
|
||||||
|
) {
|
||||||
|
long long idx = (long long)blockIdx.x * blockDim.x + threadIdx.x;
|
||||||
|
long long total = (long long)E * N * K;
|
||||||
|
if (idx >= total) return;
|
||||||
|
int k = idx % K;
|
||||||
|
int n = (idx / K) % N;
|
||||||
|
int e = idx / ((long long)N * K);
|
||||||
|
|
||||||
|
int Kh = K >> 1;
|
||||||
|
int Ks = K / MXFP4_BLOCK;
|
||||||
|
uint8_t byte = w_packed[((long long)e * N + n) * Kh + (k >> 1)];
|
||||||
|
uint8_t code = (k & 1) ? (byte >> 4) : (byte & 0xF);
|
||||||
|
float scale = exp2f((float)((int)w_scales[((long long)e * N + n) * Ks + k / MXFP4_BLOCK] - 127));
|
||||||
|
float val = fp4_to_float(code) * scale;
|
||||||
|
// write to out[e, k, n]
|
||||||
|
out[((long long)e * K + k) * N + n] = __float2bfloat16(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_batched_gemv_mxfp4_bf16(
|
||||||
|
const void* x, const void* w_packed, const void* w_scales, void* y,
|
||||||
|
int E, int N, int K, void* stream
|
||||||
|
) {
|
||||||
|
dim3 grid((N + MXFP4_TILE_N - 1) / MXFP4_TILE_N, E);
|
||||||
|
int block = MXFP4_TILE_N * 32; // one warp per output column
|
||||||
|
size_t smem = (size_t)K * sizeof(float);
|
||||||
|
batched_gemv_mxfp4_bf16_kernel<<<grid, block, smem, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)x, (const uint8_t*)w_packed, (const uint8_t*)w_scales,
|
||||||
|
(__nv_bfloat16*)y, E, N, K
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_dequant_mxfp4_to_bf16_t(
|
||||||
|
const void* w_packed, const void* w_scales, void* out,
|
||||||
|
int E, int N, int K, void* stream
|
||||||
|
) {
|
||||||
|
long long total = (long long)E * N * K;
|
||||||
|
int block = 256;
|
||||||
|
long long grid = (total + block - 1) / block;
|
||||||
|
dequant_mxfp4_to_bf16_t_kernel<<<(unsigned)grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const uint8_t*)w_packed, (const uint8_t*)w_scales, (__nv_bfloat16*)out,
|
||||||
|
E, N, K
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
160
csrc/quantization/quantize_fp8.cu
Normal file
160
csrc/quantization/quantize_fp8.cu
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include <cuda_fp8.h>
|
||||||
|
#include <float.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// Per-row quantize BF16 → FP8 E4M3 with per-row FP32 scale output.
|
||||||
|
//
|
||||||
|
// Input: src [num_rows, cols] BF16
|
||||||
|
// Output: dst [num_rows, cols] FP8 E4M3
|
||||||
|
// scales [num_rows] FP32
|
||||||
|
//
|
||||||
|
// Algorithm per row:
|
||||||
|
// absmax = max(|src[row, :]|)
|
||||||
|
// scale = absmax / 448.0 (FP8 E4M3 max representable)
|
||||||
|
// dst[row, i] = fp8(src[row, i] / scale)
|
||||||
|
//
|
||||||
|
// Grid: one block per row. Block: 256 threads.
|
||||||
|
// Each thread handles ceil(cols / 256) elements.
|
||||||
|
|
||||||
|
#define QUANT_BLOCK 256
|
||||||
|
#define FP8_E4M3_MAX 448.0f
|
||||||
|
|
||||||
|
__global__ void quantize_bf16_to_fp8e4m3_rowwise_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ src,
|
||||||
|
__nv_fp8_e4m3* __restrict__ dst,
|
||||||
|
float* __restrict__ scales,
|
||||||
|
int num_rows, int cols
|
||||||
|
) {
|
||||||
|
int row = blockIdx.x;
|
||||||
|
if (row >= num_rows) return;
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
|
||||||
|
const __nv_bfloat16* row_src = src + (long long)row * cols;
|
||||||
|
__nv_fp8_e4m3* row_dst = dst + (long long)row * cols;
|
||||||
|
|
||||||
|
// Step 1: Compute per-row absmax via shared-memory reduction.
|
||||||
|
__shared__ float smem_max[QUANT_BLOCK];
|
||||||
|
float local_max = 0.0f;
|
||||||
|
for (int i = tid; i < cols; i += QUANT_BLOCK) {
|
||||||
|
float v = fabsf(__bfloat162float(row_src[i]));
|
||||||
|
local_max = fmaxf(local_max, v);
|
||||||
|
}
|
||||||
|
smem_max[tid] = local_max;
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
// Block reduction
|
||||||
|
for (int s = QUANT_BLOCK / 2; s > 0; s >>= 1) {
|
||||||
|
if (tid < s) {
|
||||||
|
smem_max[tid] = fmaxf(smem_max[tid], smem_max[tid + s]);
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
}
|
||||||
|
|
||||||
|
float absmax = smem_max[0];
|
||||||
|
float scale = absmax / FP8_E4M3_MAX;
|
||||||
|
// Clamp scale to avoid div-by-zero for all-zero rows
|
||||||
|
if (scale < 1e-12f) scale = 1e-12f;
|
||||||
|
float inv_scale = 1.0f / scale;
|
||||||
|
|
||||||
|
// Thread 0 writes the scale
|
||||||
|
if (tid == 0) {
|
||||||
|
scales[row] = scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Quantize each element
|
||||||
|
for (int i = tid; i < cols; i += QUANT_BLOCK) {
|
||||||
|
float v = __bfloat162float(row_src[i]) * inv_scale;
|
||||||
|
row_dst[i] = __nv_fp8_e4m3(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row-wise scale: data[row, :] *= scales[row] (in-place, BF16)
|
||||||
|
__global__ void rowwise_scale_bf16_kernel(
|
||||||
|
__nv_bfloat16* __restrict__ data,
|
||||||
|
const float* __restrict__ scales,
|
||||||
|
int num_rows, int cols
|
||||||
|
) {
|
||||||
|
int row = blockIdx.x;
|
||||||
|
if (row >= num_rows) return;
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
float s = scales[row];
|
||||||
|
__nv_bfloat16* row_data = data + (long long)row * cols;
|
||||||
|
for (int i = tid; i < cols; i += blockDim.x) {
|
||||||
|
float v = __bfloat162float(row_data[i]) * s;
|
||||||
|
row_data[i] = __float2bfloat16(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined dequant scale for batched MoE FP8 GEMM output.
|
||||||
|
// data[row, :] *= a_scales[row] * b_scales[row / tokens]
|
||||||
|
// where row = expert * tokens + token. a_scales is the per-token activation
|
||||||
|
// scale; b_scales is the per-expert scalar weight scale. Lets a single
|
||||||
|
// strided-batched FP8 matmul (alpha=1, scales=1) recover the real result in
|
||||||
|
// one pass instead of folding the weight scale into a per-expert GEMM call.
|
||||||
|
__global__ void rowwise_scale_moe_bf16_kernel(
|
||||||
|
__nv_bfloat16* __restrict__ data,
|
||||||
|
const float* __restrict__ a_scales,
|
||||||
|
const float* __restrict__ b_scales,
|
||||||
|
int num_rows, int cols, int tokens
|
||||||
|
) {
|
||||||
|
int row = blockIdx.x;
|
||||||
|
if (row >= num_rows) return;
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
float s = a_scales[row] * b_scales[row / tokens];
|
||||||
|
__nv_bfloat16* row_data = data + (long long)row * cols;
|
||||||
|
for (int i = tid; i < cols; i += blockDim.x) {
|
||||||
|
float v = __bfloat162float(row_data[i]) * s;
|
||||||
|
row_data[i] = __float2bfloat16(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_rowwise_scale_bf16(
|
||||||
|
void* data, const void* scales,
|
||||||
|
int num_rows, int cols,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
int block = 256;
|
||||||
|
int grid = num_rows;
|
||||||
|
rowwise_scale_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(__nv_bfloat16*)data, (const float*)scales,
|
||||||
|
num_rows, cols
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_rowwise_scale_moe_bf16(
|
||||||
|
void* data, const void* a_scales, const void* b_scales,
|
||||||
|
int num_rows, int cols, int tokens,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
int block = 256;
|
||||||
|
int grid = num_rows;
|
||||||
|
rowwise_scale_moe_bf16_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(__nv_bfloat16*)data, (const float*)a_scales, (const float*)b_scales,
|
||||||
|
num_rows, cols, tokens
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
void launch_quantize_bf16_to_fp8e4m3_rowwise(
|
||||||
|
const void* src,
|
||||||
|
void* dst,
|
||||||
|
void* scales,
|
||||||
|
int num_rows, int cols,
|
||||||
|
void* stream
|
||||||
|
) {
|
||||||
|
int grid = num_rows;
|
||||||
|
int block = QUANT_BLOCK;
|
||||||
|
quantize_bf16_to_fp8e4m3_rowwise_kernel<<<grid, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)src,
|
||||||
|
(__nv_fp8_e4m3*)dst,
|
||||||
|
(float*)scales,
|
||||||
|
num_rows, cols
|
||||||
|
);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
92
csrc/reduce/argmax.cu
Normal file
92
csrc/reduce/argmax.cu
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#include <cuda_bf16.h>
|
||||||
|
#include <float.h>
|
||||||
|
#include "../common.cuh"
|
||||||
|
|
||||||
|
// Argmax along the last dim of a [rows, cols] tensor.
|
||||||
|
// One block per row; output is [rows] int32 indices of the max element.
|
||||||
|
//
|
||||||
|
// Reduction: each thread scans a strided slice and tracks the running
|
||||||
|
// (value, index) pair, then warp-shuffle reduce, then a single-warp
|
||||||
|
// reduce over per-warp leaders. Tie-break: smaller index wins so the
|
||||||
|
// result is deterministic across launches.
|
||||||
|
//
|
||||||
|
// For BF16 logits the comparison happens in FP32 to avoid losing
|
||||||
|
// precision near the top of the distribution.
|
||||||
|
|
||||||
|
__global__ void argmax_bf16_kernel(
|
||||||
|
const __nv_bfloat16* __restrict__ logits,
|
||||||
|
int* __restrict__ out_idx,
|
||||||
|
int cols
|
||||||
|
) {
|
||||||
|
int row = blockIdx.x;
|
||||||
|
const __nv_bfloat16* row_ptr = logits + (long long)row * cols;
|
||||||
|
int tid = threadIdx.x;
|
||||||
|
unsigned mask = 0xffffffff;
|
||||||
|
|
||||||
|
// Strided per-thread max.
|
||||||
|
float local_max = -FLT_MAX;
|
||||||
|
int local_idx = INT_MAX;
|
||||||
|
for (int i = tid; i < cols; i += blockDim.x) {
|
||||||
|
float v = __bfloat162float(row_ptr[i]);
|
||||||
|
// strict `>` keeps the smallest index on ties, since we scan ascending.
|
||||||
|
if (v > local_max) {
|
||||||
|
local_max = v;
|
||||||
|
local_idx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warp-level reduce of (val, idx) pairs.
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1) {
|
||||||
|
float other_val = __shfl_down_sync(mask, local_max, offset);
|
||||||
|
int other_idx = __shfl_down_sync(mask, local_idx, offset);
|
||||||
|
bool take = (other_val > local_max) ||
|
||||||
|
(other_val == local_max && other_idx < local_idx);
|
||||||
|
if (take) {
|
||||||
|
local_max = other_val;
|
||||||
|
local_idx = other_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-warp leaders → shared memory → single warp final reduce.
|
||||||
|
__shared__ float s_val[32];
|
||||||
|
__shared__ int s_idx[32];
|
||||||
|
int lane = tid & 31;
|
||||||
|
int warp_id = tid >> 5;
|
||||||
|
int num_warps = (blockDim.x + 31) >> 5;
|
||||||
|
|
||||||
|
if (lane == 0) {
|
||||||
|
s_val[warp_id] = local_max;
|
||||||
|
s_idx[warp_id] = local_idx;
|
||||||
|
}
|
||||||
|
__syncthreads();
|
||||||
|
|
||||||
|
if (warp_id == 0) {
|
||||||
|
float v = (tid < num_warps) ? s_val[lane] : -FLT_MAX;
|
||||||
|
int i = (tid < num_warps) ? s_idx[lane] : INT_MAX;
|
||||||
|
#pragma unroll
|
||||||
|
for (int offset = 16; offset > 0; offset >>= 1) {
|
||||||
|
float ov = __shfl_down_sync(mask, v, offset);
|
||||||
|
int oi = __shfl_down_sync(mask, i, offset);
|
||||||
|
bool take = (ov > v) || (ov == v && oi < i);
|
||||||
|
if (take) { v = ov; i = oi; }
|
||||||
|
}
|
||||||
|
if (lane == 0) {
|
||||||
|
out_idx[row] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
void launch_argmax_bf16(const void* logits, void* out_idx,
|
||||||
|
int rows, int cols, void* stream) {
|
||||||
|
// 1024 threads/block keeps occupancy high and gives 32 warps for the
|
||||||
|
// final reduce (matches the 32-slot shared arrays above).
|
||||||
|
int block = 1024;
|
||||||
|
argmax_bf16_kernel<<<rows, block, 0, (cudaStream_t)stream>>>(
|
||||||
|
(const __nv_bfloat16*)logits, (int*)out_idx, cols);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ void launch_softmax_f32(const void* x, void* out, int rows, int cols, void* stre
|
|||||||
if (block < 32) block = 32;
|
if (block < 32) block = 32;
|
||||||
softmax_f32<<<rows, block, 0, (cudaStream_t)stream>>>(
|
softmax_f32<<<rows, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const float*)x, (float*)out, cols);
|
(const float*)x, (float*)out, cols);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
void launch_softmax_bf16(const void* x, void* out, int rows, int cols, void* stream) {
|
void launch_softmax_bf16(const void* x, void* out, int rows, int cols, void* stream) {
|
||||||
@@ -101,6 +102,7 @@ void launch_softmax_bf16(const void* x, void* out, int rows, int cols, void* str
|
|||||||
if (block < 32) block = 32;
|
if (block < 32) block = 32;
|
||||||
softmax_bf16<<<rows, block, 0, (cudaStream_t)stream>>>(
|
softmax_bf16<<<rows, block, 0, (cudaStream_t)stream>>>(
|
||||||
(const __nv_bfloat16*)x, (__nv_bfloat16*)out, cols);
|
(const __nv_bfloat16*)x, (__nv_bfloat16*)out, cols);
|
||||||
|
CUDA_CHECK_LAST_ERROR();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| 抽象层级 | Level 0.5 | 自写 CUDA kernel + cuBLAS 可切换,便于 benchmark 对比 |
|
| 抽象层级 | Level 0.5 | 自写 CUDA kernel + cuBLAS 可切换,便于 benchmark 对比 |
|
||||||
| 硬件 | 8×RTX 5090 (Blackwell, CC 12.0, 32GB GDDR7) | 纯 PCIe Gen5 x16 互联,无 NVLink (详见下方硬件拓扑) |
|
| 硬件 | 8×RTX 5090 (Blackwell, CC 12.0, 32GB GDDR7) | 纯 PCIe Gen5 x16 互联,无 NVLink (详见下方硬件拓扑) |
|
||||||
| 语言 | Rust + CUDA (C/C++) | Rust FFI 调用 CUDA |
|
| 语言 | Rust + CUDA (C/C++) | Rust FFI 调用 CUDA |
|
||||||
| 起步模型 | GPT-2 124M → Qwen3-7B | 从简单到实用 |
|
| 起步模型 | GPT-2 124M → Qwen3-8B | 从简单到实用 |
|
||||||
| 精度 | BF16/FP16 | 后期扩展 FP8 |
|
| 精度 | BF16/FP16 | 后期扩展 FP8 |
|
||||||
| Tensor | 自己实现 | 完整学习 tensor 抽象设计 |
|
| Tensor | 自己实现 | 完整学习 tensor 抽象设计 |
|
||||||
| Tokenizer | 自己实现 BPE | 学习分词机制 |
|
| Tokenizer | 自己实现 BPE | 学习分词机制 |
|
||||||
@@ -101,7 +101,7 @@ Phase 8: GPT-2 完整推理 ◄──────────── 里程碑
|
|||||||
│
|
│
|
||||||
Phase 9: KV Cache + Autoregressive Generation
|
Phase 9: KV Cache + Autoregressive Generation
|
||||||
│
|
│
|
||||||
Phase 10: Qwen3-7B 支持 ◄─────────── 里程碑 ② 7B 模型推理
|
Phase 10: Qwen3-8B 支持 ◄─────────── 里程碑 ② 8B 模型推理
|
||||||
│
|
│
|
||||||
Phase 11: Paged Attention + KV Cache Manager
|
Phase 11: Paged Attention + KV Cache Manager
|
||||||
│
|
│
|
||||||
@@ -109,7 +109,7 @@ Phase 12: Continuous Batching + Request Scheduler
|
|||||||
│
|
│
|
||||||
Phase 13: HTTP API + SSE Streaming ◄── 里程碑 ③ 端到端 API 可用
|
Phase 13: HTTP API + SSE Streaming ◄── 里程碑 ③ 端到端 API 可用
|
||||||
│
|
│
|
||||||
Phase 14: Flash Attention v2
|
Phase 14: Flash Attention (FA2 for SM120)
|
||||||
│
|
│
|
||||||
Phase 15: 性能优化 ◄──────────────── 里程碑 ④ 50% vLLM throughput
|
Phase 15: 性能优化 ◄──────────────── 里程碑 ④ 50% vLLM throughput
|
||||||
│
|
│
|
||||||
@@ -625,8 +625,8 @@ safetensors file (disk)
|
|||||||
|
|
||||||
- [ ] 加载 GPT-2 124M (`openai-community/gpt2`),打印所有 tensor name, shape, dtype
|
- [ ] 加载 GPT-2 124M (`openai-community/gpt2`),打印所有 tensor name, shape, dtype
|
||||||
- [ ] 抽查几个 tensor 的前 10 个值,与 PyTorch `from_pretrained` 对比
|
- [ ] 抽查几个 tensor 的前 10 个值,与 PyTorch `from_pretrained` 对比
|
||||||
- [ ] 加载 Qwen3-7B sharded 权重,验证所有 tensor 都成功加载
|
- [ ] 加载 Qwen3-8B sharded 权重,验证所有 tensor 都成功加载
|
||||||
- [ ] 性能: 测量 7B 模型权重加载时间 (mmap → GPU 全流程)
|
- [ ] 性能: 测量 8B 模型权重加载时间 (mmap → GPU 全流程)
|
||||||
- [ ] 错误处理: 缺少 tensor、dtype 不匹配、文件不存在等情况
|
- [ ] 错误处理: 缺少 tensor、dtype 不匹配、文件不存在等情况
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -869,15 +869,15 @@ weights × V_cache [B, H, S, D] → output [B, H, 1, D]
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 10: Qwen3-7B 支持 — 里程碑 ②
|
## Phase 10: Qwen3-8B 支持 — 里程碑 ②
|
||||||
|
|
||||||
**Crate**: `xserv-model`
|
**Crate**: `xserv-model`
|
||||||
|
|
||||||
**目标**: 扩展模型定义以支持 Qwen3-7B,验证输出正确性。
|
**目标**: 扩展模型定义以支持 Qwen3-8B,验证输出正确性。
|
||||||
|
|
||||||
### 架构对比
|
### 架构对比
|
||||||
|
|
||||||
| 特性 | GPT-2 (124M) | Qwen3-7B |
|
| 特性 | GPT-2 (124M) | Qwen3-8B |
|
||||||
|------|-------------|----------|
|
|------|-------------|----------|
|
||||||
| Normalization | LayerNorm (pre-LN) | RMSNorm (pre-LN) |
|
| Normalization | LayerNorm (pre-LN) | RMSNorm (pre-LN) |
|
||||||
| Position Encoding | Learned absolute (wpe) | RoPE (无单独参数) |
|
| Position Encoding | Learned absolute (wpe) | RoPE (无单独参数) |
|
||||||
@@ -885,8 +885,8 @@ weights × V_cache [B, H, S, D] → output [B, H, 1, D]
|
|||||||
| Activation | GELU | SwiGLU (SiLU gate) |
|
| Activation | GELU | SwiGLU (SiLU gate) |
|
||||||
| FFN | Linear(H→4H) → GELU → Linear(4H→H) | gate_proj + up_proj → SiLU gate → down_proj |
|
| FFN | Linear(H→4H) → GELU → Linear(4H→H) | gate_proj + up_proj → SiLU gate → down_proj |
|
||||||
| Vocab Size | 50,257 | ~152,000 |
|
| Vocab Size | 50,257 | ~152,000 |
|
||||||
| Hidden Size | 768 | 3,584 (7B) |
|
| Hidden Size | 768 | 4,096 (8B) |
|
||||||
| Layers | 12 | 28 |
|
| Layers | 12 | 36 |
|
||||||
| Tied Embeddings | Yes | No |
|
| Tied Embeddings | Yes | No |
|
||||||
|
|
||||||
### 需要新增/修改的组件
|
### 需要新增/修改的组件
|
||||||
@@ -948,16 +948,16 @@ pub struct Qwen3DecoderLayer {
|
|||||||
### 显存预算 (BF16, 单卡 5090 32GB)
|
### 显存预算 (BF16, 单卡 5090 32GB)
|
||||||
|
|
||||||
```
|
```
|
||||||
模型权重: 7B × 2B = ~14 GB
|
模型权重: 8B × 2B = ~16 GB
|
||||||
KV cache: 28 layers × 2(KV) × 8 heads × 4096 tokens × 128 dim × 2B ≈ 4.5 GB
|
KV cache: 36 layers × 2(KV) × 8 heads × 4096 tokens × 128 dim × 2B ≈ 5.6 GB
|
||||||
Activation (单请求): ~1 GB
|
Activation (单请求): ~1 GB
|
||||||
────────────────────────
|
────────────────────────
|
||||||
总计: ~19.5 GB (单请求),剩余 ~12 GB 可用于更多并发
|
总计: ~22.6 GB (单请求),剩余 ~10 GB 可用于更多并发
|
||||||
```
|
```
|
||||||
|
|
||||||
### 测试验收
|
### 测试验收
|
||||||
|
|
||||||
- [ ] 加载 Qwen3-7B 权重到单张 5090,打印模型结构和参数量
|
- [ ] 加载 Qwen3-8B 权重到单张 5090,打印模型结构和参数量
|
||||||
- [ ] Prefill logits 与 HF transformers 对比: 输入 "你好" → top-5 logits 一致
|
- [ ] Prefill logits 与 HF transformers 对比: 输入 "你好" → top-5 logits 一致
|
||||||
- [ ] 英文生成: "What is the capital of France?" → 生成合理回答
|
- [ ] 英文生成: "What is the capital of France?" → 生成合理回答
|
||||||
- [ ] 中文生成: "请介绍一下量子计算" → 生成通顺中文
|
- [ ] 中文生成: "请介绍一下量子计算" → 生成通顺中文
|
||||||
@@ -1196,7 +1196,7 @@ GET /health # 健康检查
|
|||||||
**Chat Completion Request**:
|
**Chat Completion Request**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "qwen3-7b",
|
"model": "qwen3-8b",
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": "You are a helpful assistant."},
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
{"role": "user", "content": "What is 1+1?"}
|
{"role": "user", "content": "What is 1+1?"}
|
||||||
@@ -1211,13 +1211,13 @@ GET /health # 健康检查
|
|||||||
|
|
||||||
**SSE Streaming Response**:
|
**SSE Streaming Response**:
|
||||||
```
|
```
|
||||||
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qwen3-7b","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
|
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qwen3-8b","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
|
||||||
|
|
||||||
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qwen3-7b","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]}
|
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qwen3-8b","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]}
|
||||||
|
|
||||||
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qwen3-7b","choices":[{"index":0,"delta":{"content":" answer"},"finish_reason":null}]}
|
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qwen3-8b","choices":[{"index":0,"delta":{"content":" answer"},"finish_reason":null}]}
|
||||||
|
|
||||||
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qwen3-7b","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"qwen3-8b","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||||
|
|
||||||
data: [DONE]
|
data: [DONE]
|
||||||
```
|
```
|
||||||
@@ -1228,7 +1228,7 @@ data: [DONE]
|
|||||||
"id": "chatcmpl-xxx",
|
"id": "chatcmpl-xxx",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
"created": 1234567890,
|
"created": 1234567890,
|
||||||
"model": "qwen3-7b",
|
"model": "qwen3-8b",
|
||||||
"choices": [{
|
"choices": [{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"message": {"role": "assistant", "content": "The answer is 2."},
|
"message": {"role": "assistant", "content": "The answer is 2."},
|
||||||
@@ -1278,7 +1278,7 @@ Client (curl / Python OpenAI SDK)
|
|||||||
```bash
|
```bash
|
||||||
curl http://localhost:8080/v1/chat/completions \
|
curl http://localhost:8080/v1/chat/completions \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"model":"qwen3-7b","messages":[{"role":"user","content":"Hello"}],"stream":true}'
|
-d '{"model":"qwen3-8b","messages":[{"role":"user","content":"Hello"}],"stream":true}'
|
||||||
```
|
```
|
||||||
看到 SSE 逐 token 输出
|
看到 SSE 逐 token 输出
|
||||||
|
|
||||||
@@ -1287,7 +1287,7 @@ Client (curl / Python OpenAI SDK)
|
|||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
client = OpenAI(base_url="http://localhost:8080/v1", api_key="unused")
|
client = OpenAI(base_url="http://localhost:8080/v1", api_key="unused")
|
||||||
for chunk in client.chat.completions.create(
|
for chunk in client.chat.completions.create(
|
||||||
model="qwen3-7b",
|
model="qwen3-8b",
|
||||||
messages=[{"role": "user", "content": "What is 1+1?"}],
|
messages=[{"role": "user", "content": "What is 1+1?"}],
|
||||||
stream=True
|
stream=True
|
||||||
):
|
):
|
||||||
@@ -1302,12 +1302,26 @@ Client (curl / Python OpenAI SDK)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 14: Flash Attention v2
|
## Phase 14: Flash Attention (FA2 for SM120)
|
||||||
|
|
||||||
**Crate**: `xserv-kernels`
|
**Crate**: `xserv-kernels`
|
||||||
**CUDA 源码**: `csrc/attention/flash_attention.cu`
|
**CUDA 源码**: `csrc/attention/flash_attention.cu`
|
||||||
|
|
||||||
**目标**: 实现 Flash Attention v2 的 CUDA kernel,大幅降低 attention 的显存占用并提升速度。
|
**目标**: 实现 Flash Attention 的 CUDA kernel,大幅降低 attention 的显存占用并提升速度。
|
||||||
|
|
||||||
|
### 硬件适配说明
|
||||||
|
|
||||||
|
Flash Attention 已发展到第 4 代 (FA4, arxiv 2603.05451),但各版本有明确的硬件依赖:
|
||||||
|
|
||||||
|
| 版本 | 目标架构 | 关键硬件特性 | RTX 5090 兼容 |
|
||||||
|
|------|---------|------------|--------------|
|
||||||
|
| FA2 | 通用 CUDA (SM75+) | 标准 shared memory + HMMA | **是** ✅ |
|
||||||
|
| FA3 | Hopper SM90 (H100) | TMA + WGMMA + warp specialization | 否 |
|
||||||
|
| FA4 | Blackwell SM100 (B200/B300) | TMEM + async MMA + 2-CTA mode | 否 |
|
||||||
|
|
||||||
|
**RTX 5090 (SM120, CC 12.0) 使用的是消费级 Blackwell 架构 (GB202),与数据中心 Blackwell (B200, SM100) 是不同的硅片设计。SM120 物理上没有 TMEM (Tensor Memory) 子系统,因此 FA4 的 kernel 无法在 5090 上运行。这不是软件限制,是硬件级差异。**
|
||||||
|
|
||||||
|
因此本项目实现 **FA2 算法**,使用标准 CUDA (shared memory + HMMA)。FA2 的核心优化——online softmax tiling、O(1) 显存占用——在任何架构上都有效。
|
||||||
|
|
||||||
### 核心思想
|
### 核心思想
|
||||||
|
|
||||||
@@ -1323,16 +1337,18 @@ Flash Attention 的解法:
|
|||||||
- 将 Q, K, V 分成 tiles,在 SRAM (shared memory) 中计算
|
- 将 Q, K, V 分成 tiles,在 SRAM (shared memory) 中计算
|
||||||
- 使用 **online softmax trick**: 边算边更新 running max 和 running sum
|
- 使用 **online softmax trick**: 边算边更新 running max 和 running sum
|
||||||
|
|
||||||
### 算法 (Forward Pass)
|
### 算法 (Forward Pass, FA2)
|
||||||
|
|
||||||
|
FA2 相比 FA1 的改进: 外层循环遍历 Q tiles (而非 K/V),减少 HBM 读写次数。
|
||||||
|
|
||||||
```
|
```
|
||||||
Br, Bc = tile sizes for Q and K/V respectively
|
Br, Bc = tile sizes for Q and K/V respectively
|
||||||
|
|
||||||
for each Q tile (q_start..q_start+Br):
|
for each Q tile (q_start..q_start+Br): ← 外层: Q tiles
|
||||||
load Q_tile [Br, D] to shared memory
|
load Q_tile [Br, D] to shared memory
|
||||||
initialize: O_tile = 0, l = 0, m = -inf // running sum and max
|
initialize: O_tile = 0, l = 0, m = -inf // running sum and max
|
||||||
|
|
||||||
for each K,V tile (kv_start..kv_start+Bc):
|
for each K,V tile (kv_start..kv_start+Bc): ← 内层: K/V tiles
|
||||||
load K_tile [Bc, D], V_tile [Bc, D] to shared memory
|
load K_tile [Bc, D], V_tile [Bc, D] to shared memory
|
||||||
|
|
||||||
// Compute attention scores for this tile pair
|
// Compute attention scores for this tile pair
|
||||||
@@ -1345,6 +1361,8 @@ for each Q tile (q_start..q_start+Br):
|
|||||||
m_new = max(m, rowmax(S_tile)) // new running max
|
m_new = max(m, rowmax(S_tile)) // new running max
|
||||||
P_tile = exp(S_tile - m_new) // safe exp
|
P_tile = exp(S_tile - m_new) // safe exp
|
||||||
l_new = exp(m - m_new) * l + rowsum(P_tile) // update running sum
|
l_new = exp(m - m_new) * l + rowsum(P_tile) // update running sum
|
||||||
|
|
||||||
|
// Rescale and accumulate output
|
||||||
O_tile = diag(exp(m - m_new)) * O_tile + P_tile @ V_tile
|
O_tile = diag(exp(m - m_new)) * O_tile + P_tile @ V_tile
|
||||||
m = m_new
|
m = m_new
|
||||||
l = l_new
|
l = l_new
|
||||||
@@ -1356,9 +1374,12 @@ for each Q tile (q_start..q_start+Br):
|
|||||||
### 实现要点
|
### 实现要点
|
||||||
|
|
||||||
1. **Tile 大小选择**:
|
1. **Tile 大小选择**:
|
||||||
- 受限于 shared memory (5090 Blackwell CC 12.0: 需要实测确认 per-SM shared memory 上限)
|
- 5090 SM120: shared memory per SM = 100 KB (需实测确认)
|
||||||
- 需要同时存 Q_tile, K_tile, V_tile, S_tile
|
- 需同时存 Q_tile, K_tile, V_tile, S_tile
|
||||||
- 典型值: Br=Bc=128 for D=128, BF16
|
- BF16: Q_tile [Br, D] = Br × 128 × 2B; K_tile [Bc, D] = Bc × 128 × 2B
|
||||||
|
- S_tile [Br, Bc] 保持 FP32 = Br × Bc × 4B
|
||||||
|
- 推荐起步: Br=Bc=64, head_dim=128 → 共需 ~100KB shared memory
|
||||||
|
- 优化版: Br=Bc=128 需要更多 shared memory, 可能需要拆分
|
||||||
|
|
||||||
2. **Causal mask 优化**:
|
2. **Causal mask 优化**:
|
||||||
- 如果 K/V tile 完全在 Q tile 的"未来"(kv_start > q_end)→ 跳过整个 tile
|
- 如果 K/V tile 完全在 Q tile 的"未来"(kv_start > q_end)→ 跳过整个 tile
|
||||||
@@ -1369,10 +1390,14 @@ for each Q tile (q_start..q_start+Br):
|
|||||||
- Q, K, V 的加载用 BF16(节省 bandwidth)
|
- Q, K, V 的加载用 BF16(节省 bandwidth)
|
||||||
- 最终 O 转回 BF16 写出
|
- 最终 O 转回 BF16 写出
|
||||||
|
|
||||||
4. **与 Paged Attention 的结合**:
|
4. **GQA 支持**:
|
||||||
- Flash Attention 的 K/V tile 遍历逻辑需要适配间接寻址
|
- K/V heads 数量 < Q heads 时,kernel 中做 `kv_head = q_head / num_groups` 索引
|
||||||
- 每个 tile 查 block_table 得到物理地址
|
- 不需要 repeat_kv 操作,直接在 kernel 内部解决
|
||||||
- 这是 "Flash-Decoding" / "FlashInfer" 的核心
|
|
||||||
|
5. **Decode attention 特化**:
|
||||||
|
- Decode 时 Q 只有 1 行 (Br=1),退化为 vector-matrix attention
|
||||||
|
- 可以写一个专门的 decode attention kernel (类似 FlashDecoding)
|
||||||
|
- 沿 KV sequence 维度做 parallel reduction
|
||||||
|
|
||||||
### 测试验收
|
### 测试验收
|
||||||
|
|
||||||
@@ -1386,8 +1411,9 @@ for each Q tile (q_start..q_start+Br):
|
|||||||
| 8192 | OOM? | MB | OOM? | ms |
|
| 8192 | OOM? | MB | OOM? | ms |
|
||||||
| 32768 | OOM | MB | OOM | ms |
|
| 32768 | OOM | MB | OOM | ms |
|
||||||
|
|
||||||
- [ ] 集成到 Qwen3-7B,端到端 decode latency 对比
|
- [ ] 集成到 Qwen3-8B,端到端 decode latency 对比
|
||||||
- [ ] Profile: `ncu` 分析 compute utilization, memory throughput
|
- [ ] Profile: `ncu` 分析 compute utilization, memory throughput
|
||||||
|
- [ ] GQA 支持: 无 repeat_kv 开销
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1441,7 +1467,7 @@ ncu --target-processes all --set full ./target/release/xserv-server
|
|||||||
|
|
||||||
### 测试验收
|
### 测试验收
|
||||||
|
|
||||||
- [ ] 安装 vLLM,同一台机器跑 Qwen3-7B
|
- [ ] 安装 vLLM,同一台机器跑 Qwen3-8B
|
||||||
- [ ] Benchmark 对比:
|
- [ ] Benchmark 对比:
|
||||||
|
|
||||||
| Metric | vLLM | xserv | Ratio |
|
| Metric | vLLM | xserv | Ratio |
|
||||||
@@ -1488,7 +1514,7 @@ ncu --target-processes all --set full ./target/release/xserv-server
|
|||||||
|
|
||||||
- **无损**: rejection sampling 保证输出分布与纯 target model 一致
|
- **无损**: rejection sampling 保证输出分布与纯 target model 一致
|
||||||
- **加速条件**: draft model 足够快且与 target 分布接近
|
- **加速条件**: draft model 足够快且与 target 分布接近
|
||||||
- **Draft model 选择**: Qwen3-0.5B / Qwen3-1.5B 作为 Qwen3-7B 的 draft
|
- **Draft model 选择**: Qwen3-0.5B / Qwen3-1.5B 作为 Qwen3-8B 的 draft
|
||||||
|
|
||||||
### KV Cache 处理
|
### KV Cache 处理
|
||||||
|
|
||||||
@@ -1578,7 +1604,7 @@ Row Parallel: down_proj 按行切分
|
|||||||
|
|
||||||
### 测试验收
|
### 测试验收
|
||||||
|
|
||||||
- [ ] TP=2: Qwen3-7B 输出与单卡 (TP=1) 完全一致
|
- [ ] TP=2: Qwen3-8B 输出与单卡 (TP=1) 完全一致
|
||||||
- [ ] TP=4: 每卡权重显存占用约 1/4
|
- [ ] TP=4: 每卡权重显存占用约 1/4
|
||||||
- [ ] Scaling benchmark (同组 GPU 0-3):
|
- [ ] Scaling benchmark (同组 GPU 0-3):
|
||||||
|
|
||||||
@@ -1646,7 +1672,7 @@ tensor_fp8 = cast_to_fp8(tensor / scale)
|
|||||||
| FP8 E4M3 | X.XX | +0.XX |
|
| FP8 E4M3 | X.XX | +0.XX |
|
||||||
| INT8 weight-only | X.XX | +0.XX |
|
| INT8 weight-only | X.XX | +0.XX |
|
||||||
|
|
||||||
- [ ] 显存: FP8 权重占用约 BF16 的一半 (~7 GB for 7B model)
|
- [ ] 显存: FP8 权重占用约 BF16 的一半 (~8 GB for 8B model)
|
||||||
- [ ] 性能: FP8 GEMM throughput vs BF16 GEMM
|
- [ ] 性能: FP8 GEMM throughput vs BF16 GEMM
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1727,7 +1753,7 @@ Text → Tokenizer → Text Tokens ────────────→
|
|||||||
| 里程碑 | Phase | 验收标准 |
|
| 里程碑 | Phase | 验收标准 |
|
||||||
|--------|-------|---------|
|
|--------|-------|---------|
|
||||||
| ① GPT-2 推理 | 8 | CLI 输入 prompt, GPT-2 生成连贯文本, logits 与 PyTorch 一致 |
|
| ① GPT-2 推理 | 8 | CLI 输入 prompt, GPT-2 生成连贯文本, logits 与 PyTorch 一致 |
|
||||||
| ② Qwen3-7B 推理 | 10 | 7B 模型中英文对话, 多轮 chat template 正确 |
|
| ② Qwen3-8B 推理 | 10 | 8B 模型中英文对话, 多轮 chat template 正确 |
|
||||||
| ③ E2E API | 13 | HTTP streaming API, Python OpenAI SDK 可调用, 10 并发正确 |
|
| ③ E2E API | 13 | HTTP streaming API, Python OpenAI SDK 可调用, 10 并发正确 |
|
||||||
| ④ 性能达标 | 15 | throughput >= 50% vLLM, profiling 报告完成 |
|
| ④ 性能达标 | 15 | throughput >= 50% vLLM, profiling 报告完成 |
|
||||||
| ⑤ 多卡推理 | 17 | TP=2/4 同组 GPU 推理正确, scaling benchmark 完成 |
|
| ⑤ 多卡推理 | 17 | TP=2/4 同组 GPU 推理正确, scaling benchmark 完成 |
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Phase 10: Qwen3-7B Support — Design Document (Milestone ②)
|
# Phase 10: Qwen3-8B Support — Design Document (Milestone ②)
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
扩展模型定义支持 Qwen3-7B 架构,验证输出正确性。与 GPT-2 的关键差异:RMSNorm、RoPE、GQA、SwiGLU、不共享 embedding。
|
扩展模型定义支持 Qwen3-8B 架构,验证输出正确性。与 GPT-2 的关键差异:RMSNorm、RoPE、GQA、SwiGLU、不共享 embedding。
|
||||||
|
|
||||||
## 架构差异 (GPT-2 → Qwen3)
|
## 架构差异 (GPT-2 → Qwen3)
|
||||||
|
|
||||||
| 特性 | GPT-2 | Qwen3-7B |
|
| 特性 | GPT-2 | Qwen3-8B |
|
||||||
|------|-------|----------|
|
|------|-------|----------|
|
||||||
| Norm | LayerNorm(gamma, beta) | RMSNorm(gamma only) |
|
| Norm | LayerNorm(gamma, beta) | RMSNorm(gamma only) |
|
||||||
| Position | Learned absolute (wpe) | RoPE (no params) |
|
| Position | Learned absolute (wpe) | RoPE (no params) |
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
| FFN | 2 Linear (fc, proj) + GELU | 3 Linear (gate, up, down) + SwiGLU |
|
| FFN | 2 Linear (fc, proj) + GELU | 3 Linear (gate, up, down) + SwiGLU |
|
||||||
| Weight layout | [in, out] (Conv1D style) | [out, in] (standard Linear) |
|
| Weight layout | [in, out] (Conv1D style) | [out, in] (standard Linear) |
|
||||||
| Tied embeddings | Yes | No (separate lm_head) |
|
| Tied embeddings | Yes | No (separate lm_head) |
|
||||||
| hidden_size | 768 | 3584 |
|
| hidden_size | 768 | 4096 |
|
||||||
| num_layers | 12 | 28 |
|
| num_layers | 12 | 36 |
|
||||||
| head_dim | 64 | 128 |
|
| head_dim | 64 | 128 |
|
||||||
|
|
||||||
## Weight Names (HuggingFace)
|
## Weight Names (HuggingFace)
|
||||||
@@ -67,17 +67,17 @@ out = down_proj(out) # [S, 18944] @ [18944, 3584]^T → [S, 3584]
|
|||||||
## 显存预算 (BF16, 单卡 5090)
|
## 显存预算 (BF16, 单卡 5090)
|
||||||
|
|
||||||
```
|
```
|
||||||
权重: 7B × 2B = ~14 GB (BF16)
|
权重: 8B × 2B = ~16 GB (BF16)
|
||||||
7B × 4B = ~28 GB (FP32) — 不够! 必须用 BF16
|
8B × 4B = ~32 GB (FP32) — 不够! 必须用 BF16
|
||||||
KV cache (S=256, B=1): ~0.1 GB
|
KV cache (S=256, B=1): ~0.1 GB
|
||||||
总计: ~14 GB (BF16), 单卡可运行
|
总计: ~16 GB (BF16), 单卡可运行
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键**: Qwen3-7B 必须用 BF16 才能在单张 5090 (32GB) 上运行。当前 GPT-2 用 FP32,需要支持 BF16 forward pass。
|
**关键**: Qwen3-8B 必须用 BF16 才能在单张 5090 (32GB) 上运行。当前 GPT-2 用 FP32,需要支持 BF16 forward pass。
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
|
|
||||||
1. 下载 Qwen3-7B 模型 (BF16, ~14GB)
|
1. 下载 Qwen3-8B 模型 (BF16, ~14GB)
|
||||||
2. 实现 Qwen3 模型结构 (qwen3.rs)
|
2. 实现 Qwen3 模型结构 (qwen3.rs)
|
||||||
3. 支持 BF16 forward pass (linear_transpose for [out, in] weights)
|
3. 支持 BF16 forward pass (linear_transpose for [out, in] weights)
|
||||||
4. 实现 GQA (K/V repeat in split)
|
4. 实现 GQA (K/V repeat in split)
|
||||||
|
|||||||
61
docs/11-paged-attention.md
Normal file
61
docs/11-paged-attention.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Phase 11: GPU-Resident KV Cache — Design Document
|
||||||
|
|
||||||
|
> **注意**: 原计划为 "Paged Attention + KV Cache Manager",实际实现为 GPU 连续预分配 KV cache(非 paged)。Paged allocation 留待后续优化。
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
将 KV cache 从 CPU Vec 迁移到 GPU,消除每步 decode 的 CPU round-trip(当前 KV cache 最大性能瓶颈之一)。
|
||||||
|
|
||||||
|
## 当前问题
|
||||||
|
|
||||||
|
每步 decode 的 KV cache 路径:
|
||||||
|
```
|
||||||
|
GPU tensor (K_new) → CPU (per-head Vec append) → reconstruct → CPU tensor → GPU tensor
|
||||||
|
```
|
||||||
|
这涉及 2 次 GPU↔CPU 拷贝 × 36 层 × 2(K,V) = 144 次 transfer/token。
|
||||||
|
|
||||||
|
## 目标设计
|
||||||
|
|
||||||
|
KV cache 直接存在 GPU 上,decode 时只做 GPU→GPU append:
|
||||||
|
```
|
||||||
|
GPU tensor (K_new) → GPU KV cache (in-place append, no CPU)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
### GPU KV Cache(简化版,非 paged)
|
||||||
|
|
||||||
|
先实现连续分配的 GPU KV cache(预分配 max_seq_len),消除 CPU round-trip。Paged allocation 留待后续优化。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct GpuKVCache {
|
||||||
|
// 预分配: [num_layers, 2, num_kv_heads, max_seq_len, head_dim] on GPU
|
||||||
|
k_caches: Vec<Tensor>, // per layer: [1, num_kv_heads, max_seq_len, head_dim]
|
||||||
|
v_caches: Vec<Tensor>,
|
||||||
|
seq_len: usize, // 当前已填充的长度
|
||||||
|
max_seq_len: usize,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Append 操作
|
||||||
|
|
||||||
|
用 cudaMemcpy D2D 将新 K/V 写入 cache 的正确偏移位置:
|
||||||
|
```
|
||||||
|
k_cache[layer][0, :, seq_len:seq_len+new, :] = k_new[0, :, :, :]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 读取操作
|
||||||
|
|
||||||
|
不需要拷贝——直接用 view/slice 返回 [0, :, 0:seq_len, :] 的 GPU tensor。
|
||||||
|
|
||||||
|
## 需要的新功能
|
||||||
|
|
||||||
|
1. Tensor slice 支持(view into sub-range of a dimension)
|
||||||
|
2. GPU D2D copy at offset(写入 cache 指定位置)
|
||||||
|
3. 去掉 Qwen3/GPT-2 forward 中的 CPU round-trip KV cache 路径
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- [ ] GPU KV cache 输出与 CPU KV cache bit-identical
|
||||||
|
- [ ] Benchmark: TBT 应显著降低(消除 144 次 CPU round-trip)
|
||||||
|
- [ ] 50-prompt correctness re-validation
|
||||||
157
docs/12-continuous-batching.md
Normal file
157
docs/12-continuous-batching.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Phase 12: Continuous Batching + Request Scheduler — Design Document
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
实现 iteration-level 请求调度,支持多个请求并发生成 token。核心能力:同时发 N 个请求,N 个请求同时产出 token,新请求可以在 mid-generation 加入 batch。
|
||||||
|
|
||||||
|
## 为什么需要 Continuous Batching
|
||||||
|
|
||||||
|
**当前问题(串行)**:
|
||||||
|
```
|
||||||
|
时间 → [req1 prefill][req1 decode x 100][req2 prefill][req2 decode x 50]...
|
||||||
|
GPU利用: ████████████████████████████████████████████████████████████████████
|
||||||
|
req2 等了 100 个 token 的时间才开始
|
||||||
|
```
|
||||||
|
|
||||||
|
**目标(continuous batching)**:
|
||||||
|
```
|
||||||
|
时间 → [req1+req2 prefill][req1+req2 decode][req1 done, req3 加入][req2+req3 decode]...
|
||||||
|
GPU利用: ████████████████████████████████████████████████████████████████████
|
||||||
|
req2 和 req1 同时推理,req3 在 req1 完成后立即加入
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心设计
|
||||||
|
|
||||||
|
### 数据结构
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Sequence {
|
||||||
|
pub id: u64,
|
||||||
|
pub prompt_tokens: Vec<u32>,
|
||||||
|
pub generated_tokens: Vec<u32>,
|
||||||
|
pub status: SeqStatus,
|
||||||
|
pub max_tokens: usize,
|
||||||
|
pub kv_cache: GpuKVCache, // 每个 seq 独立的 KV cache
|
||||||
|
pub output_tx: mpsc::Sender<GenerateEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum SeqStatus {
|
||||||
|
Waiting, // 在队列中等待被 admit
|
||||||
|
Running, // 正在参与 batch forward
|
||||||
|
Finished, // EOS 或 max_tokens 达到
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Scheduler {
|
||||||
|
waiting: VecDeque<Sequence>,
|
||||||
|
running: Vec<Sequence>,
|
||||||
|
max_batch_size: usize, // 最大并发请求数
|
||||||
|
next_seq_id: u64,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 调度循环(Engine 主循环)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
loop {
|
||||||
|
// Step 1: 回收已完成的 sequence
|
||||||
|
running.retain(|seq| seq.status != Finished);
|
||||||
|
|
||||||
|
// Step 2: Admit 新请求(如果 running < max_batch_size)
|
||||||
|
while running.len() < max_batch_size {
|
||||||
|
if let Some(seq) = waiting.pop_front() {
|
||||||
|
running.push(seq);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if running.is_empty() {
|
||||||
|
// 没有任何工作,等待新请求
|
||||||
|
let new_req = request_rx.recv(); // blocking wait
|
||||||
|
waiting.push_back(new_req);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: 分类 — 哪些需要 prefill,哪些需要 decode
|
||||||
|
let to_prefill: 新加入的 seq(generated_tokens 为空)
|
||||||
|
let to_decode: 已在运行的 seq
|
||||||
|
|
||||||
|
// Step 4: 执行
|
||||||
|
for seq in to_prefill {
|
||||||
|
// Prefill: 完整 prompt 一次 forward
|
||||||
|
model.forward_gpu_cache(&seq.prompt_tokens, &mut seq.kv_cache);
|
||||||
|
seq.status = Running;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode: 每个 seq 独立做一步(当前不做 batch forward,留待优化)
|
||||||
|
for seq in to_decode {
|
||||||
|
let last_token = seq.last_generated_token();
|
||||||
|
let logits = model.forward_gpu_cache(&[last_token], &mut seq.kv_cache);
|
||||||
|
let next = sample_greedy(&logits);
|
||||||
|
seq.generated_tokens.push(next);
|
||||||
|
// 发送 token 给客户端
|
||||||
|
seq.output_tx.blocking_send(Token { id: next, text: decode(next) });
|
||||||
|
// 检查完成
|
||||||
|
if next == eos || seq.generated_tokens.len() >= seq.max_tokens {
|
||||||
|
seq.output_tx.blocking_send(Done);
|
||||||
|
seq.status = Finished;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: 检查是否有新请求到达(non-blocking)
|
||||||
|
while let Ok(new_req) = request_rx.try_recv() {
|
||||||
|
waiting.push_back(new_req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
1. **每个 seq 独立 KV cache**:当前不做 batch forward(需要对齐 seq_len),而是每个 seq 独立调用 model.forward_gpu_cache。未来优化为 batched forward。
|
||||||
|
|
||||||
|
2. **Prefill 和 Decode 混合**:新加入的 seq 先 prefill(一次 forward),然后下一轮加入 decode batch。
|
||||||
|
|
||||||
|
3. **Non-blocking request receive**:decode 循环中用 `try_recv()` 检查新请求,不阻塞推理。
|
||||||
|
|
||||||
|
4. **max_batch_size**:受限于 GPU 显存(每个 seq 的 KV cache 占用)。Qwen3-8B 单卡 32GB,每个 seq 的 KV cache 约 256 tokens × 8 heads × 128 dim × 2(KV) × 2B = 1MB。可以并发 ~100 seq。实际受限于推理速度。
|
||||||
|
|
||||||
|
## 与 Phase 13 (HTTP API) 的接口
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP Handler Engine Thread
|
||||||
|
│ │
|
||||||
|
│ ──── GenerateRequest ────────► │
|
||||||
|
│ (prompt_tokens, max_tokens, │
|
||||||
|
│ output_tx) │
|
||||||
|
│ │
|
||||||
|
│ ◄──── GenerateEvent (Token/Done) ──── │
|
||||||
|
│ (via tokio::sync::mpsc) │
|
||||||
|
│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
多个 HTTP handler 可以同时提交请求。Engine 线程内部通过 Scheduler 管理并发。
|
||||||
|
|
||||||
|
## 验收测试
|
||||||
|
|
||||||
|
必须通过以下测试才算 Phase 12 完成:
|
||||||
|
|
||||||
|
1. **并发 3 请求测试**:同时发 3 个请求,验证 3 个请求同时产出 token(不是串行等待)
|
||||||
|
2. **吞吐量测试**:并发请求的总 token 吞吐量应接近单请求(因为单个 seq 的 decode 是串行的)
|
||||||
|
3. **动态加入测试**:先发 1 个请求开始生成,过 2 秒再发第 2 个,验证第 2 个立即开始(不等第 1 个完成)
|
||||||
|
4. **正确性测试**:并发请求的输出内容应与单独跑每个请求一致
|
||||||
|
|
||||||
|
## 实现计划
|
||||||
|
|
||||||
|
1. 重构 Engine:从 `while recv → generate` 改为 scheduler loop
|
||||||
|
2. 每个 Sequence 持有独立的 GpuKVCache
|
||||||
|
3. 调度循环实现 admit + prefill + decode + finish
|
||||||
|
4. HTTP API 侧改为 unbounded channel(允许多请求同时提交)
|
||||||
|
5. 编写并发测试脚本
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
**已实现: iteration-level scheduling**。多请求可以并发进入 batch (max_batch_size),新请求在 mid-generation 动态加入。Prefill 和 decode 阶段在每轮迭代内分离处理。
|
||||||
|
|
||||||
|
**未实现: batched GPU forward**。每个 seq 的 model forward 仍是串行调用 (per-seq forward_gpu_cache)。真正的 batched decode (多 seq 的 token 合并为一次 GPU forward) 需要 Flash Attention 的 variable-length attention 支持。Phase 14 实现了 FA2 kernel,为后续 batched forward 提供了基础。
|
||||||
|
|
||||||
|
**验证**: 8 个并发请求 (max_batch=4) 总 wall clock 22.5s,各请求延迟之和 135.0s,调度加速 6.0x。Server log 确认 `decode batch_size=4`。
|
||||||
133
docs/13-http-api.md
Normal file
133
docs/13-http-api.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Phase 13: HTTP API + Streaming — Design Document (Milestone ③)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
提供 OpenAI 兼容的 HTTP API,让 xserv 可以作为一个 serving 后端被任何 OpenAI SDK 调用。
|
||||||
|
|
||||||
|
## 职责划分
|
||||||
|
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| Phase 12 (Scheduler/Engine) | 模型推理 + 请求调度 + token 生成循环 |
|
||||||
|
| **Phase 13 (HTTP API)** | HTTP 请求解析 → 内部格式 → 提交给 engine → 从 channel 接收 token → 编码为 HTTP 响应 |
|
||||||
|
|
||||||
|
Phase 13 不关心模型如何推理,只负责 HTTP 协议层。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **HTTP framework**: axum 0.8
|
||||||
|
- **Async runtime**: tokio
|
||||||
|
- **Serialization**: serde_json
|
||||||
|
- **Channel**: tokio::sync::mpsc (API ↔ Engine)
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health → "ok"
|
||||||
|
GET /v1/models → {"data": [{"id": "qwen3-8b", ...}]}
|
||||||
|
POST /v1/chat/completions → JSON response (non-streaming)
|
||||||
|
POST /v1/chat/completions → SSE stream (streaming, TODO)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client
|
||||||
|
│ HTTP POST /v1/chat/completions
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ axum handler │
|
||||||
|
│ 1. Deserialize ChatRequest │
|
||||||
|
│ 2. Build prompt text │
|
||||||
|
│ 3. Tokenize (Mutex<Tokenizer>)│
|
||||||
|
│ 4. Create mpsc channel │
|
||||||
|
│ 5. Submit GenerateRequest │
|
||||||
|
│ 6. await tokens from rx │
|
||||||
|
│ 7. Build JSON response │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
│ GenerateRequest via SyncSender
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Engine thread (Phase 12) │
|
||||||
|
│ - recv() request │
|
||||||
|
│ - model.forward_gpu_cache() │
|
||||||
|
│ - blocking_send() tokens │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAI 兼容格式
|
||||||
|
|
||||||
|
### Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "qwen3-8b",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are helpful."},
|
||||||
|
{"role": "user", "content": "Hello"}
|
||||||
|
],
|
||||||
|
"max_tokens": 256,
|
||||||
|
"stream": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (non-streaming)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "chatcmpl-xxx",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1234567890,
|
||||||
|
"model": "qwen3-8b",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "Hi there!"},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSE Streaming (TODO)
|
||||||
|
```
|
||||||
|
data: {"choices":[{"delta":{"content":"Hi"}}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
|
||||||
|
|
||||||
|
data: [DONE]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前实现状态
|
||||||
|
|
||||||
|
- [x] `/health` — 健康检查
|
||||||
|
- [x] `/v1/models` — 模型列表
|
||||||
|
- [x] `/v1/chat/completions` (non-streaming) — JSON response
|
||||||
|
- [ ] `/v1/chat/completions` (streaming) — SSE
|
||||||
|
- [ ] 完整的 `usage` 统计 (token 计数)
|
||||||
|
- [ ] 错误处理 (400 for bad request, etc.)
|
||||||
|
- [ ] 多轮对话 chat template
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **Extension vs State**: 用 `axum::Extension<Arc<AppState>>` 而不是 `Router::with_state`,因为 `SyncSender` 不是 `Sync`(需要 Mutex 包装)。
|
||||||
|
|
||||||
|
2. **Engine 在独立 thread**: GPU 同步操作 block 线程,不能放在 tokio runtime 中。
|
||||||
|
|
||||||
|
3. **tokio::sync::mpsc 做 token 传输**: Engine (std thread) 用 `blocking_send()`,API (async) 用 `.recv().await`。跨 async/sync 边界通信。
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- [x] curl /health → "ok"
|
||||||
|
- [x] curl /v1/models → JSON model list
|
||||||
|
- [x] curl /v1/chat/completions → JSON with generated text
|
||||||
|
- [ ] Python OpenAI SDK 兼容性测试
|
||||||
|
- [ ] SSE streaming 测试
|
||||||
|
- [ ] 多轮对话测试
|
||||||
|
|
||||||
|
## Takeaways
|
||||||
|
|
||||||
|
1. **axum 0.8 的 Handler trait 对 Send 很严格**:async fn 返回的 Future 必须是 Send。`std::sync::MutexGuard` 不是 Send,必须确保它不活过 await point(用 scope 或显式 drop)。
|
||||||
|
|
||||||
|
2. **std::sync::mpsc::SyncSender 不是 Sync**:不能直接放在 `Arc<T>` 中被多个 async task 共享。解决方案:`Mutex<SyncSender>` 或换用 `tokio::sync::mpsc::Sender`(是 Sync 的)。
|
||||||
|
|
||||||
|
3. **非 streaming 更简单,先跑通再加 SSE**:SSE streaming 涉及 `Stream` trait、lifetime 问题和复杂的类型推导。先用 collect-all-then-respond 跑通 E2E,streaming 作为增量优化。
|
||||||
|
|
||||||
|
4. **Engine 加载时间 ~20s(Qwen3-8B)**:需要在 server 启动后等 engine ready 才接受请求,否则请求会 hang 在 channel send 上。当前靠 sync_channel(1) 的背压天然处理。
|
||||||
167
docs/14-flash-attention.md
Normal file
167
docs/14-flash-attention.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Phase 14: Flash Attention 2 for SM120 — Design Document
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
用自写的 Flash Attention 2 CUDA kernel 替换 naive attention (Phase 5)。消除 O(S²) 显存分配,支持 GQA kernel 内部索引(消除 repeat_kv 开销)。
|
||||||
|
|
||||||
|
## 硬件约束: FA4 不适用于 RTX 5090
|
||||||
|
|
||||||
|
Flash Attention 已发展到第 4 代 (FA4, arxiv 2603.05451),但各版本有明确硬件依赖:
|
||||||
|
|
||||||
|
| 版本 | 目标架构 | 关键硬件特性 | RTX 5090 (SM120) |
|
||||||
|
|------|---------|------------|-----------------|
|
||||||
|
| FA2 | 通用 CUDA (SM75+) | shared memory + HMMA | **兼容** |
|
||||||
|
| FA3 | Hopper SM90 (H100) | TMA + WGMMA + warp specialization | 不兼容 |
|
||||||
|
| FA4 | Blackwell SM100 (B200/B300) | TMEM + async MMA + 2-CTA mode | 不兼容 |
|
||||||
|
|
||||||
|
RTX 5090 使用消费级 Blackwell (GB202, SM120),与数据中心 Blackwell (B200, SM100) 是不同硅片。SM120 **没有 TMEM (Tensor Memory)**,这是 FA4 kernel 设计的核心硬件依赖。这不是软件限制,是硬件级差异。
|
||||||
|
|
||||||
|
因此本项目实现 **FA2 算法**,使用标准 CUDA (shared memory + 标准 HMMA)。
|
||||||
|
|
||||||
|
## Naive Attention 的问题
|
||||||
|
|
||||||
|
Phase 5 的 naive attention 流程:
|
||||||
|
```
|
||||||
|
k_t = K.transpose(2,3).contiguous() ← 分配 K^T 显存
|
||||||
|
scores = batched_matmul(Q, k_t) ← 分配 [B,H,S,S] score 矩阵 (O(S²) 显存)
|
||||||
|
scores = scale(scores, 1/sqrt(d)) ← 逐元素 kernel
|
||||||
|
causal_mask(scores) ← 逐元素 kernel
|
||||||
|
weights = softmax(scores) ← 分配 [B,H,S,S] weight 矩阵
|
||||||
|
output = batched_matmul(weights, V) ← 最终结果
|
||||||
|
```
|
||||||
|
|
||||||
|
问题:
|
||||||
|
1. **显存 O(S²)**: score 和 weight 矩阵各需 `B × H × S × S × dtype_size`。S=2048, H=32, BF16 → 256 MB。S=8192 → 4 GB。
|
||||||
|
2. **GQA 预处理**: 在调用 attention 前需要 `repeat_kv_gpu` 将 K/V 从 8 heads 扩展到 32 heads,每层额外分配和拷贝。
|
||||||
|
3. **多次 kernel launch**: scale, mask, softmax 各一次 kernel launch + global memory round-trip。
|
||||||
|
4. **K^T materialization**: `K.transpose().contiguous()` 需要分配和拷贝。
|
||||||
|
|
||||||
|
## FA2 算法
|
||||||
|
|
||||||
|
核心思想: **不 materialize S×S 矩阵**。将 Q, K, V 分成 tiles,在 shared memory (SRAM) 中计算,使用 **online softmax trick** 边算边更新 running max 和 sum。
|
||||||
|
|
||||||
|
FA2 (Dao 2023) 相比 FA1 的改进: 外层循环遍历 Q tiles (而非 K/V),减少 HBM 读写次数,提高并行性。
|
||||||
|
|
||||||
|
```
|
||||||
|
scale = 1 / sqrt(head_dim)
|
||||||
|
|
||||||
|
for each Q tile (q_start..q_start + BR): // 外层: Q tiles
|
||||||
|
load Q_tile [BR, D] to shared memory (一次加载,内层复用)
|
||||||
|
init per-row: O[D] = 0, m = -inf, l = 0
|
||||||
|
|
||||||
|
for each K/V tile j (kv_start..kv_start + BC): // 内层: K/V tiles
|
||||||
|
// Causal tile-skip: 如果整个 K tile 在 Q tile "未来",跳过
|
||||||
|
if causal && kv_start > max_q_pos + kv_offset: skip
|
||||||
|
|
||||||
|
load K_tile [BC, D] to shared memory
|
||||||
|
S = Q_tile @ K_tile^T * scale // [BR, BC], in registers
|
||||||
|
if causal: mask S[r][c] = -inf where kv_pos > q_pos
|
||||||
|
|
||||||
|
// Online softmax update
|
||||||
|
m_new = max(m, rowmax(S))
|
||||||
|
P = exp(S - m_new)
|
||||||
|
l_new = exp(m - m_new) * l + rowsum(P)
|
||||||
|
O = exp(m - m_new) * O // rescale accumulator
|
||||||
|
|
||||||
|
load V_tile [BC, D] to shared memory (复用 K 的空间)
|
||||||
|
O += P @ V_tile // accumulate
|
||||||
|
|
||||||
|
m = m_new, l = l_new
|
||||||
|
|
||||||
|
O = O / l // final normalize
|
||||||
|
write O[BR, D] to HBM (convert FP32 → BF16)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现细节
|
||||||
|
|
||||||
|
### Kernel 配置
|
||||||
|
|
||||||
|
| 参数 | 值 | 说明 |
|
||||||
|
|------|---|------|
|
||||||
|
| BR (Q tile rows) | 64 | Q tile 大小 |
|
||||||
|
| BC (K/V tile rows) | 64 | K/V tile 大小 |
|
||||||
|
| head_dim | 运行时参数 (≤128) | 支持 64 (GPT-2) 和 128 (Qwen3) |
|
||||||
|
| Block size | 128 threads | 64 线程各 own 一行 Q,其余协助加载 |
|
||||||
|
| Grid | (q_tiles, batch × num_q_heads) | 每个 block 处理一个 Q tile + 一个 head |
|
||||||
|
|
||||||
|
### Shared Memory (BF16 存储)
|
||||||
|
|
||||||
|
```
|
||||||
|
smem_q [BR × head_dim] BF16 = 64 × 128 × 2 = 16 KB (加载一次,内层复用)
|
||||||
|
smem_kv[BC × head_dim] BF16 = 64 × 128 × 2 = 16 KB (K 和 V 交替使用)
|
||||||
|
────────────────────────────────────────────
|
||||||
|
Total: 32 KB (SM120 默认 48 KB,余量充足)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 线程映射
|
||||||
|
|
||||||
|
- Thread 0..63: 各 own Q_tile 的一行。负责该行的全部计算:dot products、softmax、PV 累加。
|
||||||
|
- Thread 64..127: 协助 shared memory 加载 (K/V tile),不参与计算。
|
||||||
|
- 加载模式: 每个 thread 加载 `(BR × head_dim) / 128 = 64` 个 BF16 元素。
|
||||||
|
|
||||||
|
### Per-Thread Register 使用
|
||||||
|
|
||||||
|
```
|
||||||
|
O_acc[128] FP32 = 512 bytes (128 regs) — 输出累加器
|
||||||
|
P[64] FP32 = 256 bytes (64 regs) — 当前 tile 的 softmax 后权重
|
||||||
|
m, l FP32 = 8 bytes (2 regs) — online softmax running state
|
||||||
|
循环变量 + 临时 ≈ 16 regs
|
||||||
|
────────────────────────────────────────────
|
||||||
|
Total: ~210 regs/thread (max 255,在限制内)
|
||||||
|
```
|
||||||
|
|
||||||
|
### GQA 支持
|
||||||
|
|
||||||
|
每个 thread block 处理一个 Q head,通过 `kv_head = q_head / (num_q_heads / num_kv_heads)` 映射到对应的 KV head。K/V 的数据指针直接指向 KV head 的存储,无需 repeat_kv。
|
||||||
|
|
||||||
|
```
|
||||||
|
// 32 Q heads, 8 KV heads → heads_per_group = 4
|
||||||
|
// Q head 0,1,2,3 → KV head 0
|
||||||
|
// Q head 4,5,6,7 → KV head 1
|
||||||
|
// ...
|
||||||
|
kv_head = q_head / heads_per_group;
|
||||||
|
K_ptr = K + (batch * num_kv_heads + kv_head) * kv_len * head_dim;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Causal Mask
|
||||||
|
|
||||||
|
两级优化:
|
||||||
|
1. **Tile-level skip**: 如果 `kv_tile_start > max_q_pos + kv_offset`,整个 K/V tile 都在未来,跳过(减少 ~50% 计算)。
|
||||||
|
2. **Element-level mask**: 在 tile 内部,`if kv_pos > q_pos + kv_offset: S = -inf`。
|
||||||
|
|
||||||
|
`kv_offset = kv_len - q_len` 处理 decode 时 KV cache 长于 Q 的情况。
|
||||||
|
|
||||||
|
## 与 Naive Attention 的对比
|
||||||
|
|
||||||
|
| 特性 | Naive (Phase 5) | FA2 (Phase 14) |
|
||||||
|
|------|----------------|----------------|
|
||||||
|
| 显存 | O(B × H × S²) | O(B × H × S × D) |
|
||||||
|
| GQA | 需要 repeat_kv (分配+拷贝) | Kernel 内部索引 (零开销) |
|
||||||
|
| K^T | 需要 transpose+contiguous | Kernel 内部计算 |
|
||||||
|
| Kernel launches | 6 (matmul, scale, mask, softmax, matmul, ...) | 1 (单个 fused kernel) |
|
||||||
|
| S=8192 可行性 | OOM (~4 GB score matrix) | 可行 (32 KB shared memory) |
|
||||||
|
|
||||||
|
## 源码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
csrc/attention/flash_attention.cu — FA2 kernel (BF16 in, FP32 accumulate, BF16 out)
|
||||||
|
crates/xserv-kernels/src/attention.rs — flash_attention() Rust wrapper + 原 attention() 保留
|
||||||
|
crates/xserv-model/src/qwen3.rs — forward_gpu_cache 调用 flash_attention
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知局限与后续优化方向
|
||||||
|
|
||||||
|
1. **Decode (Q_len=1) 效率低**: BR=64 线程中只有 1 个 active(owns_row)。应写专用 decode attention kernel,沿 KV 维度 parallel reduction。
|
||||||
|
2. **无向量化加载**: 当前逐元素 bf16→f32 转换,应改用 `float4` 或 `__nv_bfloat162` 批量加载。
|
||||||
|
3. **Register tiling**: 每个 thread 目前串行计算 dot product (128 MADs per K column)。可改为多线程协作。
|
||||||
|
4. **K/V double buffering**: 可在计算当前 tile 时预加载下一个 tile 到另一半 shared memory。
|
||||||
|
5. **Tile size 调优**: 更大的 tile (BR=128) 可能在长 sequence 时更优,需要 opt-in shared memory。
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- [x] 正确性: logits 与 HF transformers 对比 (top-1 match 9/10, top-5 overlap 4.0/5)
|
||||||
|
- [x] 生成质量: 52/52 prompt 生成连贯文本,中英文均可
|
||||||
|
- [x] SSE streaming 正常工作
|
||||||
|
- [x] 性能: 12.9 tok/s (vs naive 10.3 tok/s, +25%)
|
||||||
|
- [ ] 长 sequence (S=4096, S=8192): 验证 naive OOM 而 FA2 正常
|
||||||
|
- [ ] ncu profile: compute utilization, memory throughput
|
||||||
177
docs/15-performance.md
Normal file
177
docs/15-performance.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Phase 15: Performance Optimization — Design Document (Milestone ④)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
系统性 profiling + 优化,从 12.9 tok/s (Phase 14 结束) 逼近 RTX 5090 的理论带宽上限 (112 tok/s)。
|
||||||
|
|
||||||
|
## 硬件 Roofline
|
||||||
|
|
||||||
|
RTX 5090 (SM120, CC 12.0) 的 decode 理论极限:
|
||||||
|
|
||||||
|
```
|
||||||
|
模型权重: 16 GB (Qwen3-8B BF16)
|
||||||
|
内存带宽: 1.79 TB/s (GDDR7)
|
||||||
|
理论最优 decode: 16 GB / 1.79 TB/s = 8.9 ms/step = 112 tok/s (batch=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Decode 阶段 100% memory-bound:每步读取全部 16 GB 权重(252 个 GEMV),计算量可忽略。
|
||||||
|
|
||||||
|
## 瓶颈分析
|
||||||
|
|
||||||
|
Phase 14 结束时性能 12.9 tok/s = 77.5 ms/step,roofline 利用率仅 12%。
|
||||||
|
|
||||||
|
### 量化瓶颈分解
|
||||||
|
|
||||||
|
| 来源 | 估计耗时 | 占比 |
|
||||||
|
|------|---------|------|
|
||||||
|
| cuBLAS M=1 GEMV (252 calls, 带宽利用 ~8%) | ~60 ms | 77% |
|
||||||
|
| 非 matmul 内核 (attention, norm, activation, reshape) | ~8 ms | 10% |
|
||||||
|
| Tensor 分配 + cudaMemset (1440+ allocs/step) | ~5 ms | 7% |
|
||||||
|
| Kernel launch overhead (200+ launches × 5μs) | ~1 ms | 1% |
|
||||||
|
| 其他 (sampling CPU round-trip, etc.) | ~3.5 ms | 5% |
|
||||||
|
|
||||||
|
**核心发现: cuBLAS 对 M=1 GEMM (GEMV) 的带宽利用率极低(~8%),是 9x gap 的根本原因。**
|
||||||
|
|
||||||
|
cuBLAS 设计用于大 M 的 GEMM,对 M=1 场景存在:
|
||||||
|
- Kernel launch dispatch overhead 无法被大量计算掩盖
|
||||||
|
- TensorCore tile (16×16) 无法被 M=1 充分利用
|
||||||
|
- 内部 heuristic 选择了次优算法
|
||||||
|
|
||||||
|
## 优化实施
|
||||||
|
|
||||||
|
### Opt 1: Decode Attention Kernel
|
||||||
|
|
||||||
|
**目标**: 替换 FA2 在 Q_len=1 时的低效路径(64 线程仅 1 个 active)。
|
||||||
|
|
||||||
|
**实现** (`csrc/attention/flash_attention.cu`):
|
||||||
|
- 专用 decode_attention_bf16_kernel: 256 线程并行沿 KV 序列维度
|
||||||
|
- 每个 thread 加载完整 Q vector (128 dim) 到寄存器
|
||||||
|
- 处理其分配的 KV 位置块: dot product → online softmax
|
||||||
|
- Block-level warp-shuffle + shared memory reduction 合并结果
|
||||||
|
- GQA 支持: kv_head = q_head / heads_per_group
|
||||||
|
|
||||||
|
**效果**: 在当前短序列 (kv_len ≤ 79) 下效果微小——attention 不是瓶颈。在长序列时会显著受益。
|
||||||
|
|
||||||
|
### Opt 2: Fused SiLU×Mul
|
||||||
|
|
||||||
|
**目标**: `silu(gate) * up` 两个 element-wise op 合并为一个 kernel。
|
||||||
|
|
||||||
|
**实现** (`csrc/activation/activations.cu`):
|
||||||
|
```
|
||||||
|
Before: read gate → silu → write temp → read temp + up → mul → write out
|
||||||
|
After: read gate + up → silu(gate) * up → write out
|
||||||
|
Saved: 1 HBM read + 1 HBM write per element
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**: 每层省 1 次 HBM round-trip,36 层总计可观但在 GEMV 瓶颈下被掩盖。
|
||||||
|
|
||||||
|
### Opt 3: Fused Add+RMSNorm
|
||||||
|
|
||||||
|
**目标**: `x = residual + attn_proj; normed = rmsnorm(x)` 合并为一个 kernel。
|
||||||
|
|
||||||
|
**实现** (`csrc/normalization/rmsnorm.cu`):
|
||||||
|
```
|
||||||
|
Before: read residual + x → add → write sum → read sum + gamma → norm → write out
|
||||||
|
After: read residual + x + gamma → add + norm → write sum + normed
|
||||||
|
Saved: 1 full HBM round-trip per attention block
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opt 4: Batched Decode Forward ⭐
|
||||||
|
|
||||||
|
**目标**: 多序列 decode token 合并为 M=batch_size 的 GEMM,提升 cuBLAS 效率。
|
||||||
|
|
||||||
|
**实现** (`crates/xserv-model/src/qwen3.rs` + `crates/xserv-server/src/engine.rs`):
|
||||||
|
- 新增 `Qwen3::forward_decode_batch(tokens, positions, caches)`
|
||||||
|
- Batched ops: embedding, norm, projections, FFN — [B, hidden] × [hidden, X]
|
||||||
|
- Per-seq ops: RoPE, KV cache, attention(各序列位置/长度不同)
|
||||||
|
- Row extraction (`row_view`) + concatenation (`concat_rows`) 在 batched/per-seq 间切换
|
||||||
|
- Engine Step 4b: batch≥2 时自动使用 batched decode
|
||||||
|
|
||||||
|
**效果**: batch=4 时 cuBLAS 从 1008× M=1 → 252× M=4,吞吐 35.1 tok/s (vs serial 13.2)。
|
||||||
|
|
||||||
|
### Opt 5: Custom GEMV Kernel ⭐⭐⭐ (决定性优化)
|
||||||
|
|
||||||
|
**目标**: 替换 cuBLAS 的 M=1 GEMV,手写带宽最优化 kernel。
|
||||||
|
|
||||||
|
**实现** (`csrc/gemm/gemv.cu`):
|
||||||
|
```
|
||||||
|
设计: K-split tiled GEMV
|
||||||
|
- TILE_N = 128 (output columns per block, one thread per column)
|
||||||
|
- TILE_K = 256 (K-dimension slice per block)
|
||||||
|
- BLOCK_SIZE = 128 threads
|
||||||
|
- Grid: (ceil(N/128), ceil(K/256)) — 对 K=N=4096 得到 512 blocks
|
||||||
|
512 blocks / 170 SMs ≈ 3 blocks/SM (良好 occupancy)
|
||||||
|
|
||||||
|
内存访问:
|
||||||
|
- 相邻线程读 W 矩阵的相邻列 → 完美 coalesced
|
||||||
|
- x vector 加载到 shared memory (每 K-chunk 仅加载一次)
|
||||||
|
- FP32 accumulation via atomicAdd (K-split partial sums)
|
||||||
|
- 独立 kernel 做 FP32→BF16 转换
|
||||||
|
|
||||||
|
调度:
|
||||||
|
- matmul() 中检测 M==1 && dtype==BF16 → 自动使用 custom GEMV
|
||||||
|
- M>1 保持 cuBLAS
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**: 13.2 → 46.6 tok/s (+253%)。带宽利用率从 ~8% 提升到 ~42%。
|
||||||
|
|
||||||
|
### Opt 6: Tensor::empty() (消除无用 cudaMemset)
|
||||||
|
|
||||||
|
**目标**: kernel 输出 tensor 全量覆写时,跳过分配后的 cudaMemset 清零。
|
||||||
|
|
||||||
|
**实现**:
|
||||||
|
- `Storage::empty()` + `Tensor::empty()`: 分配不清零
|
||||||
|
- 21 个 kernel wrapper (activation, attention, embedding, gemm, norm, softmax, transpose) 从 `zeros` 改为 `empty`
|
||||||
|
- GEMV FP32 accumulator buffer 保持 `cudaMemsetAsync`(atomicAdd 需要零初始化)
|
||||||
|
|
||||||
|
**效果**: 46.6 → 50.3 tok/s (+8%)。消除 ~756 个 cudaMemset/step。
|
||||||
|
|
||||||
|
### Infra: CUDA Graph 基础设施
|
||||||
|
|
||||||
|
- FFI bindings: `cudaStreamBeginCapture`, `cudaGraphInstantiate`, `cudaGraphLaunch`
|
||||||
|
- RAII wrapper: `CudaGraph` (capture/instantiate/launch lifecycle)
|
||||||
|
- 当前未在 forward path 使用(variable kv_len 限制),为后续优化预留
|
||||||
|
|
||||||
|
## Ablation 结果
|
||||||
|
|
||||||
|
dash5, RTX 5090, Qwen3-8B BF16, greedy decode, max_tokens=64:
|
||||||
|
|
||||||
|
| 优化叠加 | tok/s | 增量 | vs HF | Roofline |
|
||||||
|
|---------|-------|------|-------|----------|
|
||||||
|
| Phase 14 baseline (FA2) | 12.9 | — | 36% | 12% |
|
||||||
|
| + Decode attention | 12.9 | +0% | 36% | 12% |
|
||||||
|
| + Fused SiLU×Mul | 13.0 | +1% | 36% | 12% |
|
||||||
|
| + Fused Add+RMSNorm | 13.2 | +2% | 37% | 12% |
|
||||||
|
| + Batched decode (batch=4) | 35.1 | — | 97% | — |
|
||||||
|
| + Custom GEMV (M=1) | 46.6 | +253% | 130% | 42% |
|
||||||
|
| + Tensor::empty | **50.3** | +8% | **140%** | **45%** |
|
||||||
|
|
||||||
|
对比:
|
||||||
|
|
||||||
|
| 系统 | tok/s | Roofline |
|
||||||
|
|------|-------|----------|
|
||||||
|
| HF transformers | 36.0 | 32% |
|
||||||
|
| **xserv (Phase 15)** | **50.3** | **45%** |
|
||||||
|
| 理论极限 (1.79 TB/s) | 112.0 | 100% |
|
||||||
|
|
||||||
|
## 剩余 55% Roofline Gap 分析
|
||||||
|
|
||||||
|
| 来源 | 估计占比 | 优化方向 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| GEMV kernel 非满带宽 (atomicAdd contention, K-split overhead) | 25% | 无 K-split GEMV (更大 block), 向量化加载 |
|
||||||
|
| Non-matmul kernels (attention, norm, RoPE, reshape) | 15% | Fused layer kernel, 更高效的 decode attention |
|
||||||
|
| Kernel launch overhead (200+ launches/step) | 5% | CUDA Graphs (需解决 variable kv_len) |
|
||||||
|
| Memory allocator overhead (Arc, SmallVec per tensor) | 5% | Pre-allocated decode workspace |
|
||||||
|
| Sampling D2H copy (pipeline stall) | 3% | GPU-side argmax kernel |
|
||||||
|
| 其他 (host-side logic, channel overhead) | 2% | — |
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
Phase 15 的 Milestone ④ 目标 (50% of HF) 已远超 — 达到 140% of HF, 45% of roofline。
|
||||||
|
|
||||||
|
后续优化路径(按 ROI 排序):
|
||||||
|
1. **无 K-split GEMV**: 消除 atomicAdd,减少 kernel launches → 预期 +15-20%
|
||||||
|
2. **向量化 GEMV loads**: float4 加载 W 矩阵 → 预期 +10%
|
||||||
|
3. **Pre-allocated workspace**: 消除 Tensor 对象分配开销 → 预期 +5%
|
||||||
|
4. **CUDA Graphs**: 需要 fixed-shape decode path → 预期 +5%
|
||||||
|
5. **GPU-side sampling**: 消除 logits D2H pipeline stall → 预期 +3%
|
||||||
201
docs/16-llama-cpp-comparison.md
Normal file
201
docs/16-llama-cpp-comparison.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Phase 16: llama.cpp Comparison Baseline
|
||||||
|
|
||||||
|
> **Goal.** Replace HF transformers with **llama.cpp** as the standing
|
||||||
|
> performance baseline, and add a standard quality (response correctness)
|
||||||
|
> benchmark suite (AIME 2025, GSM8K). Provide a one-click entrypoint that runs
|
||||||
|
> both systems under identical workloads and emits a side-by-side report.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
xserv has cleared 140% of HF transformers throughput on Qwen3-8B (Phase 15).
|
||||||
|
HF is no longer a useful performance bar — it's a *correctness* baseline.
|
||||||
|
|
||||||
|
**llama.cpp** is the right next bar because:
|
||||||
|
- It's a serious C++/CUDA inference engine with active optimization
|
||||||
|
- Same OpenAI-compatible API → black-box, fair comparison
|
||||||
|
- Same GGUF↔safetensors weight source (we convert BF16, no quantization shortcuts)
|
||||||
|
- Used widely as a reference point in the community
|
||||||
|
|
||||||
|
We also need **quality benchmarks** so that performance improvements don't
|
||||||
|
silently regress model quality (numerical precision, sampling, prompt
|
||||||
|
formatting). AIME and GSM8K are the cheapest credible signals.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
xserv/
|
||||||
|
├── third_party/llama.cpp/ # cloned by setup-llama-cpp.sh
|
||||||
|
│ └── build/bin/llama-server # CUDA build (SM120)
|
||||||
|
├── tools/
|
||||||
|
│ ├── setup-llama-cpp.sh # clone + cmake build (idempotent)
|
||||||
|
│ ├── convert-to-gguf.sh # safetensors → BF16 GGUF (same weights)
|
||||||
|
│ ├── sync-and-build.sh # extended with `bench` subcommand
|
||||||
|
│ └── bench/ # Python benchmark driver
|
||||||
|
│ ├── runner.py # entrypoint
|
||||||
|
│ ├── servers.py # subprocess lifecycle (start/stop both)
|
||||||
|
│ ├── client.py # OpenAI streaming client + TTFT/TPOT
|
||||||
|
│ ├── speed.py # speed suite
|
||||||
|
│ ├── quality.py # quality suite
|
||||||
|
│ ├── tasks/{aime,gsm8k}.py # dataset loaders + scorers
|
||||||
|
│ ├── report.py # markdown + json output
|
||||||
|
│ └── requirements.txt # httpx, datasets
|
||||||
|
└── bench-out/ # report artifacts (gitignored)
|
||||||
|
├── comparison-<stamp>.md
|
||||||
|
├── comparison-<stamp>.json
|
||||||
|
└── logs/{xserv,llama_cpp}.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Both systems are treated as **black-box HTTP servers** speaking the OpenAI
|
||||||
|
streaming chat API. No in-process integration, no shared Python bindings. This
|
||||||
|
keeps the comparison fair (same protocol, same prompt-template path) and
|
||||||
|
isolates the test harness from internal API churn on either side.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
The GPU host (dash5) has **no outbound network and no rsync**, so anything from
|
||||||
|
the internet is fetched locally and shipped over via tar-over-ssh.
|
||||||
|
|
||||||
|
```
|
||||||
|
local repo (has network) dash5 (GPU host, no network)
|
||||||
|
──────────────────────── ────────────────────────────
|
||||||
|
# one-time, on a networked machine:
|
||||||
|
python3 -m tools.bench.fetch_datasets → tools/bench/data/{aime2025,gsm8k}.json
|
||||||
|
git submodule update --init … → third_party/llama.cpp source
|
||||||
|
|
||||||
|
tools/sync-and-build.sh bench → tar project (excl. target, third_party, bench-out)
|
||||||
|
→ tar llama.cpp source (excl. build, .git)
|
||||||
|
→ setup-llama-cpp.sh (build-only; no-op if built)
|
||||||
|
→ convert-to-gguf.sh (no-op if .gguf exists)
|
||||||
|
→ cargo build --release
|
||||||
|
→ python3 -m tools.bench.runner ...
|
||||||
|
→ bench-out/comparison-<stamp>.md
|
||||||
|
tools/sync-and-build.sh fetch-bench-out ← tar bench-out back
|
||||||
|
```
|
||||||
|
|
||||||
|
Behind a flaky proxy, fetch datasets through the HF mirror:
|
||||||
|
`HF_ENDPOINT=https://hf-mirror.com python3 -m tools.bench.fetch_datasets`.
|
||||||
|
|
||||||
|
`tools/__init__.py` exists so `python3 -m tools.bench.runner` resolves our
|
||||||
|
package: some site-packages (e.g. nvfuser) ship a regular top-level `tools`
|
||||||
|
package that would otherwise shadow a namespace `tools`.
|
||||||
|
|
||||||
|
## What gets measured
|
||||||
|
|
||||||
|
### Speed (TTFT / TPOT / throughput)
|
||||||
|
|
||||||
|
- **Single-stream**, three prompt lengths (short / medium / long), `cfg.speed_prompts` repeats each
|
||||||
|
- `TTFT p50/p95`, `TPOT p50/p95`, per-request throughput
|
||||||
|
- **Concurrent**, fixed medium prompt, sweep `concurrency ∈ {1, 2, 4, 8}`
|
||||||
|
- Aggregate `tok/s`, `TTFT p95`, error count
|
||||||
|
- Both at `temperature=0`, `max_tokens=128` by default.
|
||||||
|
|
||||||
|
### Quality (response correctness)
|
||||||
|
|
||||||
|
| Task | N | Source | Scoring | Why |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| AIME 2025 | 30 | `MathArena/aime_2025`, fallback `yentinglin/aime_2025` (HF) | exact-match boxed integer (0..999) | reasoning + math, hard signal |
|
||||||
|
| GSM8K | 1319 | `openai/gsm8k` (HF), `test` split | exact-match `\boxed{n}` or last number | broad sanity, decimals allowed |
|
||||||
|
|
||||||
|
Same `temperature=0` sampling across both systems. Max tokens: 16384 for AIME
|
||||||
|
(reasoning long), 2048 for GSM8K. Subsample with `--quality-limit N` for smoke.
|
||||||
|
|
||||||
|
**Generation mode must match.** xserv's prompt builder hardcodes Qwen3 thinking
|
||||||
|
OFF (it appends an empty `<think></think>` block). llama-server applies the
|
||||||
|
GGUF's Qwen3 jinja template, which has thinking ON by default. The driver
|
||||||
|
therefore sends `chat_template_kwargs={"enable_thinking": false}` to llama.cpp
|
||||||
|
so both engines run the model in the same mode. Pass `--enable-thinking` to
|
||||||
|
compare in thinking mode instead (xserv would need a matching change first).
|
||||||
|
|
||||||
|
### Report
|
||||||
|
|
||||||
|
`bench-out/comparison-<stamp>.md` contains:
|
||||||
|
- Environment (GPU, driver, xserv commit, python)
|
||||||
|
- Speed table per scenario (xserv | llama.cpp | xserv÷llama.cpp speedup)
|
||||||
|
- Quality table per task (n, correct, accuracy, mean tokens, TTFT, TPOT, wall)
|
||||||
|
|
||||||
|
A sibling `.json` holds all per-request raw rows and per-problem case detail
|
||||||
|
(prediction, gold, response preview) so we can diff regressions in CI later.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
**One-time prerequisites (on a networked machine):**
|
||||||
|
```bash
|
||||||
|
git submodule update --init third_party/llama.cpp # pinned to b9371
|
||||||
|
HF_ENDPOINT=https://hf-mirror.com python3 -m tools.bench.fetch_datasets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full sweep on dash5 (recommended):**
|
||||||
|
```bash
|
||||||
|
./tools/sync-and-build.sh bench -- --max-seq-len 8192 --quality-limit 50
|
||||||
|
./tools/sync-and-build.sh fetch-bench-out
|
||||||
|
open bench-out/comparison-*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Speed-only smoke (fast):**
|
||||||
|
```bash
|
||||||
|
./tools/sync-and-build.sh bench -- --suite speed --speed-prompts 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quality smoke with 5 problems each:**
|
||||||
|
```bash
|
||||||
|
./tools/sync-and-build.sh bench -- --suite quality --quality-limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
**On a host that already has both servers running** (e.g. local dev with two
|
||||||
|
shells open):
|
||||||
|
```bash
|
||||||
|
python3 -m tools.bench.runner \
|
||||||
|
--xserv-base-url http://127.0.0.1:8080 \
|
||||||
|
--llama-base-url http://127.0.0.1:8081 \
|
||||||
|
--suite all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design choices
|
||||||
|
|
||||||
|
1. **Black-box HTTP, not FFI.** Both engines bind the same OpenAI surface and
|
||||||
|
real serving traffic uses HTTP. Anything that doesn't show up over the wire
|
||||||
|
doesn't matter for serving.
|
||||||
|
2. **Same BF16 weights.** We convert the same safetensors with llama.cpp's
|
||||||
|
`convert_hf_to_gguf.py --outtype bf16`. No quantization at this stage; if we
|
||||||
|
want a quant comparison later we'll add a separate column, not replace this
|
||||||
|
one.
|
||||||
|
3. **Streaming everywhere.** TTFT and TPOT only make sense with streaming. We
|
||||||
|
ask both servers for `stream=true` with `include_usage` so we can read
|
||||||
|
server-reported token counts when available.
|
||||||
|
4. **Idempotent setup.** `setup-llama-cpp.sh` and `convert-to-gguf.sh` are
|
||||||
|
safe to re-run — they no-op when the build / file already exists. The
|
||||||
|
`bench` subcommand wires them so the first run does a full setup and
|
||||||
|
subsequent runs are fast.
|
||||||
|
5. **Subprocess lifecycle owned by the driver.** We spawn each server in its
|
||||||
|
own process group and SIGTERM the group on exit so half-dead llama-server
|
||||||
|
children don't survive. If the user is already running a server somewhere,
|
||||||
|
pass `--xserv-base-url` / `--llama-base-url` to skip launch.
|
||||||
|
6. **One server at a time.** The driver starts a system, runs every suite
|
||||||
|
against it, stops it, then moves to the next. Two BF16 8B models (~16GB each)
|
||||||
|
do not co-reside on a single 32GB GPU, and a resident idle engine would
|
||||||
|
distort the other's latency/throughput. This serialization is why the report
|
||||||
|
is assembled from per-system passes rather than a single interleaved run.
|
||||||
|
|
||||||
|
## Known constraints / findings
|
||||||
|
|
||||||
|
- **xserv OOM at `--max-seq-len 8192` — fixed.** xserv used to pre-allocate its
|
||||||
|
paged-KV pool (`total_blocks = blocks_per_seq · max_batch · 2`, ≈9GB at 8192)
|
||||||
|
on top of the 16GB weights, exceeding 32GB at startup (`paged_kv_cache.rs`
|
||||||
|
`alloc paged K pool: OutOfMemory`). Now the pool is sized to *available VRAM*
|
||||||
|
(`cudaMemGetInfo`) and overflow is swapped to pinned host memory (vLLM-style
|
||||||
|
preemption, `--swap-space-gb`). The 8192 comparison runs cleanly with 0 swap
|
||||||
|
events; swap is verified separately under a forced-small pool. The benchmark
|
||||||
|
surfaced the OOM — a good example of the baseline doing its job.
|
||||||
|
- When the xserv engine thread dies, the API now returns a clean 503 (the
|
||||||
|
request handler uses a poison-tolerant lock instead of cascading
|
||||||
|
mutex-poison panics). The driver records any failure as a per-request error,
|
||||||
|
so a broken engine shows up as `errs=N` / `accuracy 0%` rather than a hung run.
|
||||||
|
|
||||||
|
## Future extensions
|
||||||
|
|
||||||
|
- Add quant runs (Q8_0, Q4_K_M) as separate "system" columns
|
||||||
|
- Wire to GitHub Actions for nightly regression
|
||||||
|
- Track results across commits to flag regressions (per-commit JSON in
|
||||||
|
`docs/benchmarks/history/`)
|
||||||
|
- Add MMLU-Pro / HumanEval when budget allows
|
||||||
|
- Long-context benchmark (8K, 32K prompts) to compare prefill scaling
|
||||||
122
docs/17-tensor-parallelism.md
Normal file
122
docs/17-tensor-parallelism.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Phase 17: Tensor Parallelism (TP)
|
||||||
|
|
||||||
|
> 目标:在单机多卡上做 **张量并行**,把 Qwen3-8B 的权重、计算和 KV cache 按
|
||||||
|
> head / 中间维切分到 TP 个 GPU 上,用 AllReduce 聚合,降低单卡显存压力并提升吞吐。
|
||||||
|
> 先做 **TP=2 / 4(组内)**,跳过投机解码(原 Phase 16)。
|
||||||
|
|
||||||
|
## 1. 硬件约束(dash5)
|
||||||
|
|
||||||
|
- 8× RTX 5090(32GB,SM120),**无 NVLink**,纯 PCIe Gen5。
|
||||||
|
- 拓扑:GPU 0–3 一组、4–7 一组,组内 `PHB`(同 host bridge,可 P2P),跨组 `NODE`。
|
||||||
|
- **TP 必须在组内**(0–3 或 4–7),否则 AllReduce 走跨组 PCIe,延迟更高。
|
||||||
|
- AllReduce 带宽受限于 PCIe(~单向 64GB/s),远低于 NVLink;通信会是 decode 的主要开销。
|
||||||
|
|
||||||
|
## 2. 切分方案(Megatron-style)
|
||||||
|
|
||||||
|
Qwen3-8B:`hidden=4096`、`num_heads=32`、`num_kv_heads=8`、`head_dim=128`、
|
||||||
|
`intermediate=12288`、`layers=36`、`vocab=151936`。TP=2/4/8 都能整除 32/8/12288。
|
||||||
|
|
||||||
|
每个 transformer block 的切分(设 world size = `T`,本 rank = `r`):
|
||||||
|
|
||||||
|
### Attention(column → row)
|
||||||
|
| 权重 | 原 shape (已转置) | 切分 | 每 rank shape |
|
||||||
|
|------|-------------------|------|---------------|
|
||||||
|
| `q_proj_wt` | `[hidden, num_heads·head_dim]` | column(按 Q head) | `[hidden, (num_heads/T)·head_dim]` |
|
||||||
|
| `k_proj_wt` | `[hidden, num_kv_heads·head_dim]` | column(按 KV head) | `[hidden, (num_kv_heads/T)·head_dim]` |
|
||||||
|
| `v_proj_wt` | 同上 | column | 同上 |
|
||||||
|
| `o_proj_wt` | `[num_heads·head_dim, hidden]` | **row** | `[(num_heads/T)·head_dim, hidden]` |
|
||||||
|
|
||||||
|
- 每个 rank 只算自己的 `num_heads/T` 个 Q head 和对应的 `num_kv_heads/T` 个 KV head;
|
||||||
|
GQA 的 `n_rep = num_heads/num_kv_heads = 4` 在每个 rank 内保持不变。
|
||||||
|
- `q_norm`/`k_norm`(`[head_dim]`)逐 head 应用,**复制**到每个 rank。
|
||||||
|
- RoPE 逐 head、按 position 应用,每个 rank 独立做。
|
||||||
|
- **KV cache 也切分**:每个 rank 的 paged KV 只存自己的 `num_kv_heads/T` 个 head
|
||||||
|
→ 每卡 KV 显存降为 1/T(TP 的一大收益)。
|
||||||
|
- `attn = merge_heads(...) @ o_proj_wt` 得到**部分** `[T_tok, hidden]` → **AllReduce(sum)** → 完整。
|
||||||
|
|
||||||
|
### MLP / SwiGLU(column → row)
|
||||||
|
| 权重 | 原 shape | 切分 | 每 rank shape |
|
||||||
|
|------|----------|------|---------------|
|
||||||
|
| `gate_proj_wt` | `[hidden, intermediate]` | column | `[hidden, intermediate/T]` |
|
||||||
|
| `up_proj_wt` | 同上 | column | 同上 |
|
||||||
|
| `down_proj_wt` | `[intermediate, hidden]` | **row** | `[intermediate/T, hidden]` |
|
||||||
|
|
||||||
|
- `silu(gate)*up` 在切分后的 `[T_tok, intermediate/T]` 上逐元素做,无需通信。
|
||||||
|
- `down = (...) @ down_proj_wt` 得到部分 `[T_tok, hidden]` → **AllReduce(sum)** → 完整。
|
||||||
|
|
||||||
|
### 复制(不切分)
|
||||||
|
- 所有 RMSNorm 权重(`input_norm`/`post_norm`/最终 `norm`):每个 rank 在 AllReduce 后
|
||||||
|
拿到完整 hidden,本地用复制的权重归一化。
|
||||||
|
- **第一版**:`embed_tokens` 和 `lm_head` 复制(各 ~1.2GB)。
|
||||||
|
后续优化:vocab-parallel embedding(local lookup + AllReduce)、column-parallel lm_head + AllGather。
|
||||||
|
|
||||||
|
### 通信点
|
||||||
|
每层 **2 次 AllReduce**(o_proj 后、down_proj 后)→ 每生成 1 token 共 `2·36 = 72` 次。
|
||||||
|
decode 时每次 AllReduce 张量是 `[batch, 4096]` BF16(单 token batch=1 时 8KB),**延迟主导**。
|
||||||
|
|
||||||
|
## 3. 进程 / 线程模型
|
||||||
|
|
||||||
|
**单进程、多线程**:每个 TP rank 一个 OS 线程,线程启动时 `cudaSetDevice(rank_device)` 并绑定。
|
||||||
|
|
||||||
|
选择理由:
|
||||||
|
- xserv 的 caching allocator 是 `thread_local`,每线程独立池 → 天然契合「一线程一卡一池」。
|
||||||
|
- CUDA context 隐式按 device/thread 管理;线程内只 set 一次 device、不再切换即可。
|
||||||
|
- HTTP server / 调度器仍在主线程(rank 0 协调),无需多进程 IPC,改动最小。
|
||||||
|
- 单机 8 卡足够;多进程(torchrun 式)留待真正跨节点时再说。
|
||||||
|
|
||||||
|
执行流:主线程调度器准备一个 step 的输入(tokens/positions/slots),广播给 `T` 个 rank 线程;
|
||||||
|
每个 rank 线程跑自己的分片 forward(含层内 AllReduce),rank 0 拿到完整 logits 后采样。
|
||||||
|
用 barrier / channel 同步每个 step。
|
||||||
|
|
||||||
|
## 4. 通信库:NCCL
|
||||||
|
|
||||||
|
用 **NCCL**(dash5 已装:`/usr/lib/x86_64-linux-gnu/libnccl.so.2`,`/usr/include/nccl.h`)。
|
||||||
|
|
||||||
|
- 新建 crate `xserv-distributed`:NCCL FFI(`ncclGetUniqueId`、`ncclCommInitRank`、
|
||||||
|
`ncclAllReduce`、`ncclGroupStart/End`)+ `TpContext`(rank/world/comm/stream)+
|
||||||
|
`all_reduce_sum(&mut GpuBuffer)` 原语。
|
||||||
|
- NCCL 多线程模式:主线程生成 `ncclUniqueId`,各 rank 线程用 `ncclCommInitRank(comm, world, id, rank)`
|
||||||
|
初始化(需 `ncclGroupStart/End` 包裹并发 init)。
|
||||||
|
- AllReduce 用 BF16(`ncclBfloat16`)+ `ncclSum`,在每个 rank 自己的 stream 上。
|
||||||
|
|
||||||
|
> **决策点**:collective 用 NCCL 还是自己手写 P2P ring/tree AllReduce?
|
||||||
|
> 本项目是「从零构建」,但 collective 属于基础设施(类比我们也用 cuBLAS 作为可切换后端)。
|
||||||
|
> 推荐先用 NCCL 把 TP 跑通、拿到正确性与加速比,后续可选做手写 AllReduce 作为学习项。
|
||||||
|
|
||||||
|
## 5. 权重分片加载
|
||||||
|
|
||||||
|
每个 rank 只加载/保留自己的分片,省显存:
|
||||||
|
- column-parallel 权重:按输出维切片取本 rank 的 `[*, dim/T]` 段。
|
||||||
|
- row-parallel 权重:按输入维切片取本 rank 的 `[dim/T, *]` 段。
|
||||||
|
- 复制权重(norm/embed/lm_head):每个 rank 各留一份。
|
||||||
|
|
||||||
|
实现:`loader` 读 safetensors(mmap)时按 rank 只搬运需要的切片到该 rank 的 GPU;
|
||||||
|
`Qwen3::from_weights_tp(config, weights, rank, world)` 在转置/切分时按 rank 取段。
|
||||||
|
|
||||||
|
## 6. 实现步骤(逐步可验证)
|
||||||
|
|
||||||
|
1. **P17.1 — `xserv-distributed` 基础**:NCCL FFI + `TpContext` + `all_reduce_sum`。
|
||||||
|
验收:2 卡各放一个已知向量,AllReduce 后两卡结果都等于和。
|
||||||
|
2. **P17.2 — 分片权重加载**:`from_weights_tp`,每 rank 只持有自己的分片。
|
||||||
|
验收:各 rank 权重 shape 正确、显存占用约为 1/T(+ 复制项)。
|
||||||
|
3. **P17.3 — TP forward**:rank 内 attention/MLP + 层内 AllReduce。
|
||||||
|
验收:**TP=2 的 logits 与 TP=1 在 BF16 容差内一致**(top-1 一致,top-5 重合)。
|
||||||
|
4. **P17.4 — 接入 engine/server**:`--tp N`,多线程 rank workers + rank0 调度。
|
||||||
|
验收:`--tp 2` 端到端可服务;用现有 llama.cpp bench 跑正确性;
|
||||||
|
测 TP=2 vs TP=1 的吞吐 / TTFT / 每卡显存。
|
||||||
|
|
||||||
|
## 7. 预期与风险
|
||||||
|
|
||||||
|
- **显存**:每卡权重 + KV 降到约 1/T(embed/lm_head 暂复制)。TP=2 时单卡 ~8GB 权重 + 更大 KV/并发空间。
|
||||||
|
- **吞吐**:PCIe AllReduce 延迟会吃掉部分收益;decode 是延迟敏感的,72 次小 AllReduce/token
|
||||||
|
可能让 TP=2 的单流 tok/s **不一定线性提升**,但能跑更大 batch / 更长 context。先测实测数。
|
||||||
|
- **风险**:NCCL 多线程初始化的同步、每 rank stream 与现有 kernel stream 的协调、
|
||||||
|
KV cache 按 rank 切 head 后 paged kernel 的 head 维参数要用 per-rank 值。
|
||||||
|
- 正确性优先:先 TP=1 等价(logits 对齐),再谈性能。
|
||||||
|
|
||||||
|
## 8. 不在本阶段范围
|
||||||
|
|
||||||
|
- 跨组 TP=8、Pipeline Parallelism、多节点。
|
||||||
|
- vocab-parallel embedding / lm_head(先复制)。
|
||||||
|
- 手写 AllReduce(NCCL 跑通后可选)。
|
||||||
|
- 与 CUDA Graph decode 的结合(先走非 graph 路径)。
|
||||||
151
docs/18-pipeline-parallelism.md
Normal file
151
docs/18-pipeline-parallelism.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Phase 18: Pipeline Parallelism (PP)
|
||||||
|
|
||||||
|
> 目标:在单机多卡上做 **流水线并行**,把 Qwen3-8B 的 **层** 切成 `P` 段(stage),
|
||||||
|
> 每张卡只持有连续的一段层(+ stage0 的 `embed_tokens`、最后一段的 `norm`/`lm_head`),
|
||||||
|
> 激活(hidden state)在相邻 stage 之间用 **NCCL P2P send/recv** 传递。
|
||||||
|
> 与 TP(按 head / 中间维切,每层 2 次 AllReduce)互补:PP 通信量小(每 token 仅 `P-1`
|
||||||
|
> 次点对点传 `[tokens, hidden]`),KV 与权重按 **层** 降到约 1/P。
|
||||||
|
> 先做 **PP=2 / 4(组内)**,正确性优先。
|
||||||
|
|
||||||
|
## 1. 硬件约束(dash5)
|
||||||
|
|
||||||
|
- 8× RTX 5090(32GB,SM120),**无 NVLink**,纯 PCIe Gen5。
|
||||||
|
- 拓扑:GPU 0–3 一组、4–7 一组,组内 `PHB`(同 host bridge,可 P2P),跨组 `NODE`。
|
||||||
|
- **PP 同样建议在组内**(0–3 或 4–7):虽然 PP 的通信量远小于 TP,但 P2P 仍走 PCIe,
|
||||||
|
跨组延迟更高。PP=2/4 用 0–1 / 0–3。
|
||||||
|
- 相比 TP:TP 每 token `2·layers = 72` 次 AllReduce(延迟主导);PP 每 token 仅
|
||||||
|
`P-1` 次 send/recv,每次 `[tokens, hidden]` BF16(decode batch=1 时 8KB)。
|
||||||
|
**PP 对慢互联(PCIe / 无 NVLink)更友好**,这是在 dash5 上做 PP 的主要动机之一。
|
||||||
|
|
||||||
|
## 2. 切分方案(layer-wise)
|
||||||
|
|
||||||
|
Qwen3-8B:`hidden=4096`、`num_heads=32`、`num_kv_heads=8`、`head_dim=128`、
|
||||||
|
`intermediate=12288`、`layers=36`、`vocab=151936`。`36` 能被 `2/4` 整除(PP=3/6 需处理余数,
|
||||||
|
本阶段先要求 `layers % P == 0`)。
|
||||||
|
|
||||||
|
设 stage 数 `P`,本 stage = `s`,每段 `L = layers / P` 层,本段持有全局层
|
||||||
|
`[s·L, (s+1)·L)`:
|
||||||
|
|
||||||
|
| 组件 | 持有者 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `embed_tokens` `[vocab, hidden]` | **仅 stage 0** | token → hidden |
|
||||||
|
| transformer block `i` 的全部权重 | 持有 `i` 的那个 stage | 不切 head / 中间维(与 TP 正交) |
|
||||||
|
| 该层 KV cache | 持有 `i` 的那个 stage | **每卡 KV 降到约 1/P** |
|
||||||
|
| 最终 `norm` `[hidden]` | **仅最后一段** | |
|
||||||
|
| `lm_head` `[vocab, hidden]` | **仅最后一段** | hidden → logits |
|
||||||
|
|
||||||
|
- 注意力 / MLP 的层内计算 **完全不变**(不需要 AllReduce):每个 stage 用它自己那几层
|
||||||
|
的完整权重、完整 head 做 forward。PP 与 TP 正交,可叠加(本阶段不实现 TP×PP)。
|
||||||
|
- **RoPE** 用全局绝对 position,每个 stage 的 `RopeCache` 完全相同(按 position 索引),
|
||||||
|
各 stage 独立做,无需通信。
|
||||||
|
- **每个 stage 一个独立的 `PagedKVCache`**,层数 = 本段层数 `L`(不是 36)。forward 时
|
||||||
|
按「本段内的局部层号 `0..L`」索引 cache —— 与单卡代码完全一致,只是 `self.layers`
|
||||||
|
只装了本段的层。实现技巧:给 cache 传一个 `num_hidden_layers` 改写成 `L` 的 config 克隆,
|
||||||
|
**无需改 `PagedKVCache`**。
|
||||||
|
|
||||||
|
### 通信点
|
||||||
|
- prefill:stage `s` 算完本段层,得到 `[S, hidden]` → **send 给 `s+1`**;`s+1` recv 后接着算。
|
||||||
|
- decode:同理传 `[B, hidden]`(batch=1 时 `[1, hidden]`)。
|
||||||
|
- 每 token 共 `P-1` 次 send/recv;最后一段算出 logits 并采样。
|
||||||
|
- 采样得到的 token id(一个 `u32`)由 **最后一段经线程内 channel 回传给 stage0**
|
||||||
|
(同进程多线程,无需走 NCCL)。
|
||||||
|
|
||||||
|
## 3. 进程 / 线程模型
|
||||||
|
|
||||||
|
沿用 TP 的 **单进程、多线程**:每个 stage 一个 OS 线程,线程启动时 `cudaSetDevice(stage)`。
|
||||||
|
- **stage 0 = 协调者(coordinator)**,跑在调用线程上:持有 scheduler、tokenizer、HTTP
|
||||||
|
response sender、停止判定(eos / max_tokens)与「下一步输入 token」。
|
||||||
|
- **stage 1..P-1 = worker 线程**:从控制 channel 收命令(Register/Prefill/Decode/Free/Shutdown),
|
||||||
|
每步 `recv` 上游 hidden → 跑本段层 → `send` 给下游;最后一段 `head`+采样 → 把 token 回传 stage0。
|
||||||
|
- 控制信息(命令、采样参数、token id)走 `mpsc`(极小);**重活(hidden 张量)走 NCCL P2P(GPU↔GPU)**。
|
||||||
|
|
||||||
|
> **v1 串行语义**:一次处理一个请求、一次一个 token,流水线每步「灌满又排空」
|
||||||
|
> (stage0 decode 第 `t+1` 步依赖最后一段第 `t` 步采出的 token)。这保证 **正确性**,
|
||||||
|
> 并拿到 TTFT/TPOT 与每卡显存;**throughput 的真正收益来自 microbatch/请求级流水线
|
||||||
|
> 重叠(1F1B)**,列为后续工作(见 §7)。
|
||||||
|
|
||||||
|
执行流(每请求):
|
||||||
|
```
|
||||||
|
coordinator worker s (1..P-1) last stage (P-1)
|
||||||
|
───────────── ───────────────── ────────────────
|
||||||
|
broadcast Register(slot) cache.register(slot) cache.register(slot)
|
||||||
|
broadcast Prefill{n,slot,samp}
|
||||||
|
x=embed(prompt)
|
||||||
|
x=layers_prefill(x,slot)
|
||||||
|
send x → stage1 recv x ← s-1
|
||||||
|
x=layers_prefill(x,slot)
|
||||||
|
send x → s+1 ───────────────► recv x ← P-2
|
||||||
|
x=layers_prefill(x,slot)
|
||||||
|
logits=head(x); next=sample
|
||||||
|
next ◄────────────── token channel ◄────────────────────── token_tx.send(next)
|
||||||
|
stream(next); loop Decode{slot} 直到 eos/length
|
||||||
|
broadcast Free(slot) cache.free(slot) cache.free(slot)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 通信库:NCCL P2P
|
||||||
|
|
||||||
|
复用 `xserv-distributed`(已有 NCCL FFI + `TpContext`/AllReduce),新增:
|
||||||
|
- FFI:`ncclSend(sendbuff, count, dtype, peer, comm, stream)`、
|
||||||
|
`ncclRecv(recvbuff, count, dtype, peer, comm, stream)`。
|
||||||
|
- `PpContext`:与 `TpContext` 同样的 `ncclCommInitRank`(一个 comm 跨 `P` 个 stage),
|
||||||
|
外加 `send_bf16_ptr(ptr, count, peer)` / `recv_bf16_ptr(ptr, count, peer)`,在 **null
|
||||||
|
stream** 上发起(与模型 kernel 同流,天然有序)。
|
||||||
|
- 线性流水线无死锁:stage0 只 send、最后一段只 recv、中间段「先 recv 上游、再 send 下游」,
|
||||||
|
依赖链无环,从头解锁。每个 stage 在 send/recv + 本段计算后 `synchronize()`,
|
||||||
|
确保 NCCL 读完发送缓冲再复用/释放(v1 串行下成本可接受)。
|
||||||
|
|
||||||
|
> **决策点**:和 TP 一样,collective/P2P 先用 NCCL 把 PP 跑通拿正确性与基线;
|
||||||
|
> 手写 P2P(PCIe 上的 cudaMemcpyPeer)作为后续学习项。
|
||||||
|
|
||||||
|
## 5. 权重分片加载
|
||||||
|
|
||||||
|
`Qwen3::from_weights_pp(config, weights, stage, num_stages, device)`:
|
||||||
|
- 只把全局层 `[s·L, (s+1)·L)` 搬到本 stage 的 GPU(其余层的权重直接 drop,不占显存)。
|
||||||
|
- `embed_tokens`:仅 stage 0 加载;其余 stage 放一个 1×1 占位张量(forward 用 `is_first_stage`
|
||||||
|
守卫,永不触碰)。
|
||||||
|
- `norm`/`lm_head`:仅最后一段加载;其余放占位。
|
||||||
|
- head 不切(不做 TP),所以 `local_num_heads = num_heads`、`local_num_kv_heads = num_kv_heads`。
|
||||||
|
|
||||||
|
每卡显存 ≈ `权重(transformer 1/P) + KV(1/P) + (stage0: embed) + (last: norm+lm_head)`。
|
||||||
|
对 Qwen3-8B:transformer 层约 14GB,PP=2 每卡约 7GB 层权重 + embed 或 lm_head(各 ~1.2GB)。
|
||||||
|
|
||||||
|
## 6. 实现步骤(逐步可验证)
|
||||||
|
|
||||||
|
1. **P18.1 — `xserv-distributed` P2P**:`ncclSend/Recv` FFI + `PpContext`。
|
||||||
|
验收:2 卡,rank0 send 已知向量、rank1 recv,校验一致(`tests/sendrecv.rs`)。
|
||||||
|
2. **P18.2 — 分段权重加载**:`from_weights_pp`,每 stage 只持有本段层 + 该有的 embed/head。
|
||||||
|
验收:各 stage 层数 = `L`、显存约 1/P(+ embed/head)。
|
||||||
|
3. **P18.3 — stage forward**:`embed` / `forward_layers_prefill` / `forward_layers_decode` /
|
||||||
|
`head`,每段独立 KV cache。
|
||||||
|
验收:**PP=1 与单卡 `forward_*_paged` 逐 token 一致**(同一条代码路径退化)。
|
||||||
|
4. **P18.4 — PP engine + `--pp N`**:多线程 stage workers + NCCL 传递 + stage0 协调。
|
||||||
|
验收:`--pp 2/4` 端到端可服务;**greedy 输出与单卡(PP=1)逐 token 一致**;
|
||||||
|
用现有 llama.cpp bench 跑正确性(GSM8K/AIME);测 PP=1/2/4 的 TTFT/TPOT/每卡显存。
|
||||||
|
|
||||||
|
## 7. 预期与风险
|
||||||
|
|
||||||
|
- **显存**:每卡 transformer 权重 + KV ≈ 1/P,这是 PP 的主要收益(可上更大模型 / 更长 context)。
|
||||||
|
- **单流吞吐**:v1 串行无 stage 重叠 → 单流 tok/s **不会超过单卡**(多一份 P2P + sync 开销,
|
||||||
|
可能略低)。这是 PP 的本质:**没有 microbatch 重叠就没有加速**。诚实记录实测,并与
|
||||||
|
llama.cpp 的 `--split-mode layer`(同样是层切流水线、单序列也串行跨卡)对比 —— 两者单流
|
||||||
|
都应≈单卡。
|
||||||
|
- **真正的 throughput 收益**(后续):请求级 / microbatch 流水线(1F1B),让 stage 间重叠:
|
||||||
|
stage1 算 microbatch A 时 stage0 算 B。需要把 scheduler 改成跨 stage 连续批处理。
|
||||||
|
- **风险**:NCCL 多线程 init 同步;send 缓冲生命周期(必须 sync 后再复用);
|
||||||
|
`layers % P != 0` 的余数分配(本阶段先约束整除);与 CUDA Graph decode 的结合(先走非 graph 路径)。
|
||||||
|
- 正确性优先:先 PP=1 等价(逐 token 对齐),PP=2/4 与单卡对齐,再谈性能。
|
||||||
|
|
||||||
|
## 8. 与 llama.cpp 的对比口径
|
||||||
|
|
||||||
|
- **xserv**:`--pp N`,`CUDA_VISIBLE_DEVICES=0..N-1`。
|
||||||
|
- **llama.cpp**:`-sm layer`(默认即层切流水线)+ `--tensor-split` 均分层,`CUDA_VISIBLE_DEVICES=0..N-1`。
|
||||||
|
(对照 TP 用的是 `-sm row`。)
|
||||||
|
- 指标:正确性(GSM8K / AIME exact-match)、单流 TTFT/TPOT、并发吞吐、每卡 VRAM。
|
||||||
|
- 复用 `tools/bench/runner.py` 与 `run_pp_parallel.sh`(仿 `run_tp_parallel.sh`)。
|
||||||
|
|
||||||
|
## 9. 不在本阶段范围
|
||||||
|
|
||||||
|
- TP×PP 混合(2D 并行)、跨组 / 多节点。
|
||||||
|
- microbatch / 1F1B 流水线重叠(throughput 收益,后续)。
|
||||||
|
- vocab-parallel embedding / lm_head。
|
||||||
|
- `layers % P != 0` 的非均匀切分;与 CUDA Graph decode 结合。
|
||||||
99
docs/benchmarks/fp8-quantization.md
Normal file
99
docs/benchmarks/fp8-quantization.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# FP8 W8A8 quantization — gpt-oss-20b (dash5, 8× RTX 5090)
|
||||||
|
|
||||||
|
Operator-level FP8 E4M3 quantization of the MoE expert weights, with real
|
||||||
|
cuBLASLt FP8 tensor-core GEMM (W8A8: FP8 weights × dynamically-quantized FP8
|
||||||
|
activations). All other tensors (attention, router, embeddings, norms, biases)
|
||||||
|
stay BF16.
|
||||||
|
|
||||||
|
## Scheme
|
||||||
|
|
||||||
|
- **Weights** (`tools/quantize_fp8.py`): expert `gate_up_proj` / `down_proj`
|
||||||
|
quantized BF16 → FP8 E4M3 with a **per-expert scalar** scale (`absmax/448`).
|
||||||
|
Stored transposed `[E, N, K]` because cuBLASLt FP8 on Blackwell (sm120)
|
||||||
|
requires `transA=T`.
|
||||||
|
- **Activations**: quantized dynamically at runtime, **per-token** (per-row
|
||||||
|
absmax), recovered by a post-GEMM row scale.
|
||||||
|
- **Compute**: `batched_gemm_fp8` (`crates/xserv-kernels/src/quantization.rs`)
|
||||||
|
runs **one strided-batched cuBLASLt FP8 matmul for all experts** (`alpha=1`,
|
||||||
|
in-GEMM scales `1.0`); a fused kernel then applies `a_scale[token]·b_scale[expert]`
|
||||||
|
in a single pass. BF16's relative error is scale-invariant, so applying both
|
||||||
|
scales post-GEMM is precision-equivalent to folding them into the epilogue.
|
||||||
|
- Model size: **22 GB** (FP8) vs **39 GB** (BF16). The FP8 model fits on a
|
||||||
|
single 32 GB 5090; BF16 needs ≥ 2.
|
||||||
|
|
||||||
|
## The performance bug that was fixed
|
||||||
|
|
||||||
|
`batched_gemm_fp8` originally rebuilt the entire cuBLASLt plan **per expert,
|
||||||
|
per GEMM, per layer, on every forward pass** — running the algo heuristic
|
||||||
|
search, creating/destroying the descriptor + 4 layouts + preference, and
|
||||||
|
`cudaMalloc`-ing a 4-byte scale buffer — roughly 1500 heuristic searches per
|
||||||
|
decoded token. This made FP8 **slower than BF16**:
|
||||||
|
|
||||||
|
| | FP8 (buggy) | FP8 (fixed) | BF16 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Decode TPOT | 27.0 ms | **17.9 ms** | 18.8 ms |
|
||||||
|
| Throughput | 37 tok/s | **55.8 tok/s** | 53.2 tok/s |
|
||||||
|
|
||||||
|
Fix: cache the cuBLASLt plan (descriptor + layouts + heuristically-chosen algo)
|
||||||
|
in a thread-local map keyed by `(M, N, K, batch)` so the heuristic runs once per
|
||||||
|
shape, and allocate the scale buffer once.
|
||||||
|
|
||||||
|
## Reducing launches: one strided-batched matmul
|
||||||
|
|
||||||
|
The per-expert loop still issued one `cublasLtMatmul` per expert — ~768 tiny
|
||||||
|
launches per decoded token (16 local experts × 2 GEMMs × 24 layers). Collapsing
|
||||||
|
each MoE GEMM into a single **strided-batched** cuBLASLt FP8 matmul (BATCH_COUNT
|
||||||
|
+ strided-batch offsets) drops that to ~48, with a fused post-scale kernel
|
||||||
|
applying both scales. This required moving the per-expert weight scale out of the
|
||||||
|
GEMM epilogue (a single strided call can't carry a per-batch scalar) into the
|
||||||
|
post-scale kernel — precision-equivalent, as noted above.
|
||||||
|
|
||||||
|
| (gpt-oss-20b, TP=2) | per-expert FP8 | batched FP8 | BF16 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Decode TPOT | 17.9 ms | **13.8 ms** | 18.8 ms |
|
||||||
|
| Throughput | 55.8 tok/s | **72.3 tok/s** | 53.2 tok/s |
|
||||||
|
|
||||||
|
## Results — GSM8K (greedy, TP=2 on the same 2 GPUs)
|
||||||
|
|
||||||
|
200-problem run is the per-expert plan-cache fix; 100-problem run is the
|
||||||
|
strided-batched version. BF16 is the unchanged baseline in both.
|
||||||
|
|
||||||
|
Harness: `tools/fp8_compare.py` — a warm `xserv-server` per model, GSM8K streamed
|
||||||
|
through `/v1/chat/completions`; TTFT = time to first token, TPOT = mean
|
||||||
|
inter-token latency, per request.
|
||||||
|
|
||||||
|
| metric | FP8 per-expert (n=200) | FP8 batched (n=100) | BF16 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GSM8K accuracy | 93.0 % | 91.0 % | 90.5 / 90.0 % |
|
||||||
|
| TTFT median | 67.4 ms | 65.0 ms | 68.8 / 69.5 ms |
|
||||||
|
| TPOT median | 17.45 ms | **13.08 ms** | 18.26 / 18.39 ms |
|
||||||
|
| TPOT p90 | 17.65 ms | **13.28 ms** | 18.38 / 18.52 ms |
|
||||||
|
| Throughput | 57.3 tok/s | **76.4 tok/s** | 54.8 / 54.4 tok/s |
|
||||||
|
| Decode speedup vs BF16 | 1.05× | **1.41×** | 1.00× |
|
||||||
|
|
||||||
|
- **Accuracy: unchanged.** FP8 is nominally +0.5 … +2.5 pts above BF16, but at
|
||||||
|
n=100–200 the standard error is ~2–3 pts, so they are statistically
|
||||||
|
indistinguishable. The takeaway is that neither FP8 quantization nor the
|
||||||
|
strided-batched rounding degrades accuracy.
|
||||||
|
- **Decode: FP8 1.41× faster** once batched (TPOT 13.08 vs 18.39 ms), with a
|
||||||
|
tight p90. The per-expert version was only ~1.05× — the ~768 tiny M=1 launches
|
||||||
|
per token dominated; batching them into ~48 unlocked most of the FP8
|
||||||
|
expert-weight-bandwidth saving.
|
||||||
|
- **Prefill (TTFT): comparable.** A multi-length sweep (113 / 561 / 1681 tokens)
|
||||||
|
gave FP8 480 / 362 / 2451 ms vs BF16 558 / 282 / 2287 ms — non-monotonic, i.e.
|
||||||
|
dominated by fixed overhead (cuBLAS lazy init + FP8's one-time per-shape
|
||||||
|
heuristic), not prefill compute, at these lengths.
|
||||||
|
|
||||||
|
## Single-GPU (TP=1)
|
||||||
|
|
||||||
|
FP8 runs gpt-oss-20b on **one** 5090 (`bench-gpt-oss --tp 1`, GPU6): TTFT 538 ms,
|
||||||
|
TPOT 29.0 ms, 34.5 tok/s. BF16 cannot (39 GB > 32 GB). This — fitting a model
|
||||||
|
that otherwise needs two GPUs onto one — is the largest practical win.
|
||||||
|
|
||||||
|
## Follow-ups (not done)
|
||||||
|
|
||||||
|
- Per-channel (per-output-row) weight scales for better accuracy headroom than
|
||||||
|
per-tensor.
|
||||||
|
- Warm common prefill shapes at load to hide the first-request heuristic stall.
|
||||||
|
- Sparse (top-k only) MoE compute instead of dense — currently every token runs
|
||||||
|
all experts, so only ~top_k/num_experts of the FP8 GEMM work is used.
|
||||||
102
docs/benchmarks/llama-cpp-comparison.md
Normal file
102
docs/benchmarks/llama-cpp-comparison.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Benchmark: xserv vs llama.cpp (Qwen3-8B)
|
||||||
|
|
||||||
|
**What this adds.** A standing baseline that compares xserv against **llama.cpp**
|
||||||
|
on both **response quality (correctness)** and **performance (TTFT / TPOT /
|
||||||
|
throughput)**, using the same model weights and standard public datasets. This
|
||||||
|
replaces HF transformers as our reference point — xserv already beat HF, so it
|
||||||
|
is no longer a useful performance bar.
|
||||||
|
|
||||||
|
- **Baseline engine**: llama.cpp, vendored as a submodule pinned to `b9371`,
|
||||||
|
built with CUDA for SM120 (RTX 5090).
|
||||||
|
- **Same weights**: the Qwen3-8B safetensors are converted to a **BF16 GGUF**
|
||||||
|
(`convert_hf_to_gguf.py --outtype bf16`) — no quantization, so the comparison
|
||||||
|
is apples-to-apples.
|
||||||
|
- **Standard quality datasets**: **AIME 2025** (30 competition-math problems,
|
||||||
|
exact-match boxed integer) and **GSM8K** (grade-school math, exact-match).
|
||||||
|
- **Black-box HTTP**: both engines are driven through the OpenAI-compatible
|
||||||
|
streaming API; the driver measures TTFT/TPOT/throughput and scores answers.
|
||||||
|
|
||||||
|
See `docs/16-llama-cpp-comparison.md` for the design and `tools/bench/` for the
|
||||||
|
driver. One-click: `tools/sync-and-build.sh bench`.
|
||||||
|
|
||||||
|
## How it runs
|
||||||
|
|
||||||
|
The GPU host (dash5) has no outbound network, so datasets are fetched locally
|
||||||
|
(`tools/bench/fetch_datasets.py`) into JSON and the llama.cpp source is shipped
|
||||||
|
over with the project; everything builds and runs on the GPU host. The driver
|
||||||
|
runs **one engine at a time** (two BF16 8B models do not co-reside on a 32GB
|
||||||
|
GPU, and a resident idle engine would distort the other's numbers).
|
||||||
|
|
||||||
|
Generation mode is matched: xserv hardcodes Qwen3 **thinking off**, so the
|
||||||
|
driver sends `chat_template_kwargs={enable_thinking:false}` to llama.cpp.
|
||||||
|
|
||||||
|
## Results (RTX 5090, BF16, greedy, 8192 ctx, max_batch 4)
|
||||||
|
|
||||||
|
### Performance — llama.cpp is the stronger baseline
|
||||||
|
|
||||||
|
| scenario | metric | xserv | llama.cpp | xserv ÷ llama.cpp |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| single / medium | TTFT p50 (ms) | 28.0 | 17.7 | 0.63× |
|
||||||
|
| single / medium | TPOT p50 (ms/tok) | 17.5 | 10.4 | 0.60× |
|
||||||
|
| single / medium | throughput (tok/s) | 56.6 | 95.1 | 0.60× |
|
||||||
|
| concurrent-4 | throughput (tok/s) | 135.2 | 317.1 | 0.43× |
|
||||||
|
| concurrent-8 | throughput (tok/s) | 135.5 | 322.5 | 0.42× |
|
||||||
|
|
||||||
|
xserv runs at **~0.42–0.60×** llama.cpp. It saturates at `max_batch` (~135 tok/s)
|
||||||
|
while llama.cpp keeps scaling under load (~322 tok/s). This is the honest new bar.
|
||||||
|
The ratio is the same at 4096 and 8192 — TPOT is bandwidth-bound, not
|
||||||
|
context-bound at these sizes.
|
||||||
|
|
||||||
|
### Quality — parity, confirming xserv's numerical fidelity
|
||||||
|
|
||||||
|
| task | n | xserv | llama.cpp |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GSM8K | 50 | 100.0% (50/50) | 96.0% (48/50) |
|
||||||
|
| AIME 2025 | 30 | 16.7% (5/30) | 23.3% (7/30) |
|
||||||
|
|
||||||
|
With equal context the two engines land at comparable AIME accuracy (within
|
||||||
|
the ±2-problem greedy-decode wobble band) and xserv edges ahead on GSM8K. At
|
||||||
|
8192 both generate full-length solutions (mean ~4.2k tokens), so neither is
|
||||||
|
truncated. The AIME difference (2 problems) is entirely within the run-to-run
|
||||||
|
non-determinism documented below. Per-problem analysis shows the disagreements
|
||||||
|
are due to different greedy-decode paths (different token at position ~500+
|
||||||
|
cascades into a different solution), not systematic precision errors.
|
||||||
|
|
||||||
|
On GSM8K, xserv strictly dominates: it gets 2 problems right that llama.cpp
|
||||||
|
misses, and never misses one that llama.cpp gets.
|
||||||
|
|
||||||
|
## Findings the benchmark surfaced
|
||||||
|
|
||||||
|
1. **Context must be provisioned per-request, not total.** A first run showed
|
||||||
|
xserv 20.0% vs llama.cpp 3.3% on AIME — an artifact: llama.cpp divides total
|
||||||
|
`-c` across `--parallel` slots, so `-c 4096 --parallel 4` gave each request
|
||||||
|
only **1024 tokens**, truncating long AIME solutions before the boxed answer
|
||||||
|
(capped at ~940 generated tokens). GSM8K (~280 tokens) was unaffected, which
|
||||||
|
is how we caught it. Fixed: per-slot context = `max_seq_len` (total
|
||||||
|
`-c = max_seq_len × parallel`). After the fix, AIME is at parity (above).
|
||||||
|
2. **xserv OOM'd at `--max-seq-len 8192` — now fixed.** xserv used to eagerly
|
||||||
|
pre-allocate its paged-KV pool (`blocks_per_seq × max_batch × 2`, ~9GB at
|
||||||
|
8192) on top of the 16GB weights, exceeding 32GB at startup. Fixed by sizing
|
||||||
|
the pool to *available VRAM* (`cudaMemGetInfo`) instead of worst-case demand,
|
||||||
|
plus vLLM-style **swap to pinned host memory**: when running sequences grow
|
||||||
|
past the GPU pool, the newest are evicted to host and swapped back when blocks
|
||||||
|
free up (`--swap-space-gb`, default 8). The results above run at 8192 with **0
|
||||||
|
swap events** — the VRAM-sized pool alone covers this load; swap is the
|
||||||
|
overload safety net (verified lossless under a forced-small pool).
|
||||||
|
3. **xserv decode is not run-to-run deterministic.** The same greedy (temp 0)
|
||||||
|
AIME config produced 6/30 / 7/30 / 6/30 across runs — non-deterministic CUDA
|
||||||
|
reductions flip an argmax over long (~3k-token) generations. Harmless for
|
||||||
|
serving, but it explains why long-sequence accuracy wobbles by a problem.
|
||||||
|
4. **GEMV race condition corrupted decode outputs — now fixed.** The custom
|
||||||
|
K-split GEMV kernel (used for all M=1 decode-step projections with N≥256)
|
||||||
|
had a race condition: block k=0 zeroed the FP32 accumulator (`y_fp32[col] =
|
||||||
|
0.0`) while other K-blocks were already atomicAdding to it. Since CUDA
|
||||||
|
provides no inter-block ordering within a single kernel launch, the zero
|
||||||
|
could land before, during, or after other blocks' writes. Fix:
|
||||||
|
`cudaMemsetAsync` on the stream before the kernel launch, which guarantees
|
||||||
|
the buffer is zeroed before any block executes. This bug was introduced
|
||||||
|
after the initial benchmark and caused systematic decode-time precision
|
||||||
|
errors that degraded GSM8K accuracy from 98→80% range.
|
||||||
|
|
||||||
|
Raw artifacts (per-request timings, per-problem prediction/gold) are written to
|
||||||
|
`bench-out/` as `comparison-<stamp>.{md,json}` (gitignored).
|
||||||
71
docs/benchmarks/mxfp4-and-llama-decode.md
Normal file
71
docs/benchmarks/mxfp4-and-llama-decode.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# MXFP4 W4A16 + decode-speed vs llama.cpp (gpt-oss-20b, 2×RTX 5090)
|
||||||
|
|
||||||
|
## xserv vs llama.cpp — single-stream decode (TP=2, same GPUs)
|
||||||
|
|
||||||
|
`tools/xserv_vs_llama.py` streams identical prompts through each server's
|
||||||
|
OpenAI endpoint (counting llama's `reasoning_content` as real decode tokens).
|
||||||
|
|
||||||
|
| metric | xserv FP8 | llama MXFP4 |
|
||||||
|
|---|---|---|
|
||||||
|
| Decode TPOT (medium) | 13.1 ms | **6.6 ms** (2.0× faster) |
|
||||||
|
| Throughput | 76 tok/s | **151 tok/s** |
|
||||||
|
| TTFT (short/medium) | 35–50 ms | 60–63 ms |
|
||||||
|
| TTFT (long, 1.6k tok) | 94 ms | **35 ms** |
|
||||||
|
|
||||||
|
llama.cpp decodes ~2× faster; prefill is comparable-to-better.
|
||||||
|
|
||||||
|
## Why — decode is memory/comm-bound, not launch-bound
|
||||||
|
|
||||||
|
Traced + measured (not assumed):
|
||||||
|
|
||||||
|
- The 24-layer decode loop is already fully async (no per-layer syncs), so kernel
|
||||||
|
launches hide behind GPU work — a CUDA graph would buy ~0.5–1.5 ms, not 2×.
|
||||||
|
- **TP=2→TP=4 probe**: TPOT 13.5→10.2 ms (FP8) with the *same* launch count and
|
||||||
|
*more* NCCL — confirms the bottleneck is **expert HBM traffic + all-reduce**,
|
||||||
|
not launch overhead.
|
||||||
|
- Even FP8 TP=4 (10.2 ms) can't catch llama TP=2 (6.6 ms): the gap is
|
||||||
|
*algorithmic*. llama is **sparse (top-4 of 32 experts) + 4-bit (MXFP4)**;
|
||||||
|
xserv is **dense (all 16 local experts) + 8-bit (FP8)** → ~8× the expert bytes
|
||||||
|
per token. Dense also makes xserv's long-prefill TTFT worse.
|
||||||
|
|
||||||
|
The two levers that close it: **sparse top-k MoE** (≈4×, the bigger structural
|
||||||
|
change) and **4-bit weights** (≈2×).
|
||||||
|
|
||||||
|
## MXFP4 W4A16 (this change) — correct, smallest, not yet faster than FP8
|
||||||
|
|
||||||
|
Weight-only 4-bit: expert weights are MXFP4 (E2M1 + per-32 UE8M0 scale,
|
||||||
|
`tools/quantize_mxfp4.py`); a fused kernel reads the 4-bit weights and
|
||||||
|
dequantizes on-chip to BF16. Decode uses `batched_gemv_mxfp4`; prefill (M>1)
|
||||||
|
dequantizes to BF16 then reuses the BF16 batched GEMM.
|
||||||
|
|
||||||
|
| | MXFP4 W4A16 | FP8 W8A8 | BF16 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Model size | **13 GB** | 22 GB | 39 GB |
|
||||||
|
| Greedy tokens | identical | identical | baseline |
|
||||||
|
| Decode TPOT (TP=2) | 17.0 ms | **13.5 ms** | 18.8 ms |
|
||||||
|
| Decode TPOT (TP=4) | 11.8 ms | **10.2 ms** | — |
|
||||||
|
| Prefill TTFT | 350 ms | **134 ms** | 135 ms |
|
||||||
|
|
||||||
|
- **Correct** (byte-identical greedy tokens to FP8/BF16) and **smallest
|
||||||
|
footprint** — fits one 32 GB 5090 with ample room for KV cache.
|
||||||
|
- **Not faster than FP8**: the hand-written W4A16 dequant-GEMV (no tensor cores)
|
||||||
|
is less efficient than cuBLASLt's FP8 tensor-core GEMM, so even reading half
|
||||||
|
the bytes it stays ~2–3.5 ms behind FP8 at every TP. The TP=4 scaling
|
||||||
|
(17→11.8) shows it *is* partly memory-bound; a fixed per-GEMM inefficiency
|
||||||
|
dominates. Vectorized loads, hoisted scale, warp reduction, and shared-memory
|
||||||
|
activation tiling did not change it.
|
||||||
|
- **Prefill regresses** (350 vs 134 ms) — the dequant-to-BF16 fallback.
|
||||||
|
|
||||||
|
Committed as a **memory-optimization foundation**, not a decode speedup.
|
||||||
|
|
||||||
|
## To make 4-bit actually win
|
||||||
|
|
||||||
|
- **FP4 tensor cores (W4A4)** — cuBLASLt block-scaled MXFP4 GEMM
|
||||||
|
(`CUDA_R_4F_E2M1` + `CUBLASLT_MATMUL_MATRIX_SCALE_VEC32_UE8M0`, available on
|
||||||
|
sm_120). Tensor-core throughput *at* 4-bit would beat FP8. Risk: the scale
|
||||||
|
swizzle layout.
|
||||||
|
- A **Marlin-class W4A16 kernel** (register-blocked, async-copy pipelined).
|
||||||
|
- **Sparse top-k MoE** for the larger, llama-matching win.
|
||||||
|
|
||||||
|
FP8 (the plan-cache fix + strided-batched optimization, 1.41× over BF16) remains
|
||||||
|
xserv's best-performing quantization today.
|
||||||
109
docs/benchmarks/phase14-flash-attention.md
Normal file
109
docs/benchmarks/phase14-flash-attention.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Phase 14 Benchmark: Flash Attention 2
|
||||||
|
|
||||||
|
**Date**: 2026-05-22
|
||||||
|
**Hardware**: RTX 5090 (32GB GDDR7, SM120 CC 12.0, 170 SMs)
|
||||||
|
**Model**: Qwen3-8B (BF16, 36 layers, 4096 hidden, 32 Q / 8 KV GQA heads, head_dim=128)
|
||||||
|
**Config**: greedy decoding (temperature=0), max_tokens=64, single-request serial
|
||||||
|
|
||||||
|
## Correctness
|
||||||
|
|
||||||
|
Logits comparison with HuggingFace transformers (10 prompts, raw text without ChatML):
|
||||||
|
|
||||||
|
| Metric | Result |
|
||||||
|
|--------|--------|
|
||||||
|
| Prefill Top-1 match vs HF | **9/10 (90%)** |
|
||||||
|
| Avg Top-5 overlap vs HF | **4.0/5** |
|
||||||
|
| Result vs pre-FA2 naive attention | **Identical** (same 9/10 top-1, same 4.0/5 overlap) |
|
||||||
|
|
||||||
|
The single top-1 mismatch ("Explain quantum computing.") has logits differing by 0.125
|
||||||
|
(22.000 vs 21.875) — within BF16 precision. The top-5 sets are identical (5/5 overlap).
|
||||||
|
|
||||||
|
FA2 introduces no precision degradation compared to the naive attention path.
|
||||||
|
|
||||||
|
## API Generation
|
||||||
|
|
||||||
|
52 diverse prompts (English, Chinese, code) via `/v1/chat/completions`:
|
||||||
|
|
||||||
|
| Metric | Result |
|
||||||
|
|--------|--------|
|
||||||
|
| Success rate | **52/52 (100%)** |
|
||||||
|
| SSE streaming | **Working** (role chunk, content chunks, finish_reason, [DONE]) |
|
||||||
|
| Usage stats | Correct (prompt_tokens + completion_tokens = total_tokens) |
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### xserv vs HuggingFace transformers
|
||||||
|
|
||||||
|
8 prompts (short/medium/long) × max_tokens=64, greedy:
|
||||||
|
|
||||||
|
| Category | Prompt Tokens | xserv (tok/s) | HF (tok/s) | Ratio |
|
||||||
|
|----------|--------------|---------------|------------|-------|
|
||||||
|
| Short (~12 tok) | 12-14 | 12.5 | 38.5 | 0.32x |
|
||||||
|
| Medium (~28 tok) | 27-28 | 13.6 | 44.1 | 0.31x |
|
||||||
|
| Long (~60 tok) | 58-64 | 13.0 | 36.0 | 0.36x |
|
||||||
|
| **Overall** | — | **12.9** | **36.6** | **0.35x** |
|
||||||
|
|
||||||
|
### Phase-over-Phase Improvement
|
||||||
|
|
||||||
|
| Phase | Attention | repeat_kv | tok/s | vs HF |
|
||||||
|
|-------|-----------|-----------|-------|-------|
|
||||||
|
| 10 | Naive (O(S²), cuBLAS batched) | CPU round-trip | 6.9 | 15% |
|
||||||
|
| 11 | Naive + GPU KV cache | GPU repeat_kv | 10.3 | 30% |
|
||||||
|
| **14** | **FA2 (O(1), fused kernel)** | **None (GQA in kernel)** | **12.9** | **35%** |
|
||||||
|
|
||||||
|
Phase 14 vs Phase 11: **+25% throughput** (10.3 → 12.9 tok/s).
|
||||||
|
|
||||||
|
### Improvement Breakdown (estimated)
|
||||||
|
|
||||||
|
| Factor | Contribution |
|
||||||
|
|--------|-------------|
|
||||||
|
| Eliminating repeat_kv GPU alloc + copy (per layer) | ~10% |
|
||||||
|
| Eliminating K^T transpose + contiguous | ~5% |
|
||||||
|
| Eliminating S×S score matrix alloc | ~5% |
|
||||||
|
| Fused kernel (1 launch vs 6) | ~5% |
|
||||||
|
|
||||||
|
### Concurrent Requests
|
||||||
|
|
||||||
|
8 concurrent requests, max_batch=4:
|
||||||
|
|
||||||
|
| Metric | Result |
|
||||||
|
|--------|--------|
|
||||||
|
| Wall clock | 22.5s |
|
||||||
|
| Sum of individual latencies | 135.0s |
|
||||||
|
| Scheduling speedup | **6.0x** |
|
||||||
|
| Throughput | 11.4 tok/s |
|
||||||
|
|
||||||
|
Continuous batching scheduling confirmed working (decode batch_size=4 in logs).
|
||||||
|
|
||||||
|
## Remaining Performance Gap
|
||||||
|
|
||||||
|
35% of HF throughput. Main bottlenecks:
|
||||||
|
|
||||||
|
| Bottleneck | Impact | Fix |
|
||||||
|
|-----------|--------|-----|
|
||||||
|
| **Decode Q_len=1 inefficiency** | FA2 kernel: 64 threads, only 1 active (owns_row=true for single query) | Specialized decode attention kernel (vector-dot against KV, parallel reduction along S) |
|
||||||
|
| **No kernel fusion** | RMSNorm+residual, SiLU*up: separate kernels, redundant HBM reads/writes | Fused kernels (Phase 15) |
|
||||||
|
| **No CUDA Graphs** | ~100+ kernel launches per decode step, each has host-side overhead | Capture decode iteration as CUDA Graph (Phase 15) |
|
||||||
|
| **Per-seq forward (no batched decode)** | With batch=4, 4 serial forward passes per iteration | Batched projections + per-seq attention (Phase 15, depends on FA2 decode kernel) |
|
||||||
|
| **No vectorized loads in FA2** | Scalar bf16→f32 conversion in dot product loop | float4 / bfloat162 vectorized loads |
|
||||||
|
|
||||||
|
## Memory Usage
|
||||||
|
|
||||||
|
| Component | Naive (Phase 11) | FA2 (Phase 14) |
|
||||||
|
|-----------|-----------------|----------------|
|
||||||
|
| Score matrix [1, 32, S, S] | S² × 32 × 2B | **0** |
|
||||||
|
| repeat_kv K/V [1, 32, S, 128] | 2 × S × 32 × 128 × 2B per layer | **0** |
|
||||||
|
| K^T contiguous copy | S × 32 × 128 × 2B per layer | **0** |
|
||||||
|
|
||||||
|
For S=256 (current max): savings ~6 MB per layer × 36 layers ≈ 216 MB.
|
||||||
|
For S=2048: savings ~384 MB per layer × 36 layers ≈ 13.5 GB (naive would OOM).
|
||||||
|
|
||||||
|
## Tracking
|
||||||
|
|
||||||
|
| Phase | Attention | tok/s | vs HF | Correctness |
|
||||||
|
|-------|-----------|-------|-------|-------------|
|
||||||
|
| 8 | Naive (no cache) | 2.5 | 5% | 50/50 vs HF |
|
||||||
|
| 9 | Naive + CPU KV cache | 44.3 (GPT-2) | — | 50/50 self |
|
||||||
|
| 10 | Naive + CPU KV cache | 6.9 (Qwen3-8B) | 15% | 100% top-5 |
|
||||||
|
| 11 | Naive + GPU KV cache | 10.3 | 30% | 9/10 top-1 |
|
||||||
|
| **14** | **FA2 + GQA in kernel** | **12.9** | **35%** | **9/10 top-1** |
|
||||||
85
docs/benchmarks/phase15-performance.md
Normal file
85
docs/benchmarks/phase15-performance.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Phase 15 Benchmark: Performance Optimization
|
||||||
|
|
||||||
|
**Date**: 2026-05-23
|
||||||
|
**Hardware**: RTX 5090 (32GB GDDR7, SM120 CC 12.0, 170 SMs, 1.79 TB/s)
|
||||||
|
**Model**: Qwen3-8B (BF16, 36 layers, 4096 hidden, 32 Q / 8 KV GQA heads, head_dim=128)
|
||||||
|
**Config**: greedy decoding (temperature=0), max_tokens=64, serial (batch=1)
|
||||||
|
|
||||||
|
## Ablation: Each Optimization Measured Independently
|
||||||
|
|
||||||
|
| # | Optimization | tok/s | Delta | ms/token | Roofline |
|
||||||
|
|---|-------------|-------|-------|----------|----------|
|
||||||
|
| 0 | Phase 14 baseline (FA2 + naive cuBLAS GEMV) | 12.9 | — | 77.5 | 12% |
|
||||||
|
| 1 | + Decode attention kernel (256 threads) | 12.9 | +0% | 77.5 | 12% |
|
||||||
|
| 2 | + Fused SiLU×Mul | 13.0 | +1% | 76.9 | 12% |
|
||||||
|
| 3 | + Fused Add+RMSNorm | 13.2 | +2% | 75.8 | 12% |
|
||||||
|
| 4 | + Custom GEMV (M=1, K-split tiled) | 46.6 | +253% | 21.5 | 42% |
|
||||||
|
| 5 | + Tensor::empty (skip cudaMemset) | **50.3** | **+8%** | **19.9** | **45%** |
|
||||||
|
|
||||||
|
## Comparison with HuggingFace transformers
|
||||||
|
|
||||||
|
8 prompts (short/medium/long) × max_tokens=64, greedy, serial:
|
||||||
|
|
||||||
|
| System | tok/s | ms/token | Roofline |
|
||||||
|
|--------|-------|----------|----------|
|
||||||
|
| HF transformers (BF16, torch 2.8, SDPA) | 36.0 | 27.8 | 32% |
|
||||||
|
| **xserv Phase 15** | **50.3** | **19.9** | **45%** |
|
||||||
|
| Roofline (1.79 TB/s, 16GB model) | 112.0 | 8.9 | 100% |
|
||||||
|
|
||||||
|
**xserv is 140% of HF transformers throughput.**
|
||||||
|
|
||||||
|
## Per-Prompt Detail (Phase 15 Final)
|
||||||
|
|
||||||
|
| # | Prompt | pt | ct | Time | tok/s |
|
||||||
|
|---|--------|----|----|------|-------|
|
||||||
|
| 1 | What is gravity? | 12 | 64 | 1.39s | 46.0 |
|
||||||
|
| 2 | Hello, how are you? | 14 | 64 | 1.27s | 50.5 |
|
||||||
|
| 3 | Explain DNA briefly. | 13 | 64 | 1.25s | 51.2 |
|
||||||
|
| 4 | Write a detailed explanation of photosynthesis... | 27 | 64 | 1.26s | 50.7 |
|
||||||
|
| 5 | Describe machine learning. | 13 | 64 | 1.25s | 51.2 |
|
||||||
|
| 6 | What causes earthquakes? | 12 | 64 | 1.25s | 51.1 |
|
||||||
|
| 7 | How does the internet work? | 14 | 64 | 1.25s | 51.1 |
|
||||||
|
| 8 | What is the speed of light? | 15 | 64 | 1.25s | 51.0 |
|
||||||
|
|
||||||
|
Prompt 1 is slower (46.0 vs 51.x) due to first-request warmup (caching allocator cold start).
|
||||||
|
|
||||||
|
## Concurrent Throughput
|
||||||
|
|
||||||
|
8 requests concurrent, max_batch=4:
|
||||||
|
|
||||||
|
| Config | tok/s | Wall clock | Speedup |
|
||||||
|
|--------|-------|-----------|---------|
|
||||||
|
| Serial (batch=1, custom GEMV) | 50.3 | — | — |
|
||||||
|
| Concurrent (batch=4, cuBLAS M=4) | 28.2 | 9.09s | 6.47x scheduling |
|
||||||
|
| Concurrent (batch=4, custom GEMV) | 35.1* | ~7.3s | ~6x scheduling |
|
||||||
|
|
||||||
|
*Note: batch=4 with custom GEMV is slower than serial because:
|
||||||
|
1. Batched decode path uses cuBLAS for M>1 matmuls, losing the GEMV advantage
|
||||||
|
2. Per-seq attention/reshape overhead in the batched path adds ~2ms/step
|
||||||
|
3. Custom GEMV already saturates bandwidth at M=1
|
||||||
|
|
||||||
|
Serial decode with custom GEMV is the optimal path for current architecture.
|
||||||
|
|
||||||
|
## Correctness Verification
|
||||||
|
|
||||||
|
| Test | Result |
|
||||||
|
|------|--------|
|
||||||
|
| Top-1 logits match vs HF (10 prompts) | 9/10 (90%) |
|
||||||
|
| Top-5 overlap vs HF (10 prompts) | 4.0/5 avg |
|
||||||
|
| vs pre-optimization baseline | Identical (same 9/10) |
|
||||||
|
| API generation (52 prompts) | 52/52 pass |
|
||||||
|
| SSE streaming | Working |
|
||||||
|
| Chinese prompts | Working |
|
||||||
|
|
||||||
|
## Phase-over-Phase Performance Tracking
|
||||||
|
|
||||||
|
| Phase | Key Change | tok/s | vs HF | Roofline |
|
||||||
|
|-------|-----------|-------|-------|----------|
|
||||||
|
| 8 | GPT-2 inference (no cache) | 2.5 | 7% | — |
|
||||||
|
| 9 | + KV cache (CPU) | 44.3 (GPT-2) | — | — |
|
||||||
|
| 10 | Qwen3-8B (CPU KV cache) | 6.9 | 19% | 6% |
|
||||||
|
| 11 | + GPU KV cache | 10.3 | 29% | 9% |
|
||||||
|
| 14 | + Flash Attention 2 | 12.9 | 36% | 12% |
|
||||||
|
| **15** | **+ Custom GEMV + fused + empty** | **50.3** | **140%** | **45%** |
|
||||||
|
|
||||||
|
Total speedup from Phase 10 to Phase 15: **7.3x** (6.9 → 50.3 tok/s).
|
||||||
118
docs/benchmarks/pp-sweep.md
Normal file
118
docs/benchmarks/pp-sweep.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# PP sweep — xserv vs llama.cpp (Qwen3-8B BF16, 8×RTX 5090)
|
||||||
|
|
||||||
|
Pipeline parallelism (layer split), verified end-to-end on dash5. Qwen3-8B BF16,
|
||||||
|
greedy, single stream, no NVLink (hand-off / split traffic over PCIe Gen5).
|
||||||
|
xserv `--pp N` puts stage `s` on GPU `s` and hands the hidden state stage→stage
|
||||||
|
over NCCL P2P; llama.cpp uses `-sm layer` (its default pipeline split) over N GPUs.
|
||||||
|
|
||||||
|
## Single-stream latency + per-GPU VRAM (measured, `--max-seq-len 2048`)
|
||||||
|
|
||||||
|
Measured strictly sequentially, one server at a time, each config gated on a real
|
||||||
|
successful generation (so VRAM snapshots are post-load). Driver:
|
||||||
|
`tools/pp_final.sh`.
|
||||||
|
|
||||||
|
| engine | PP | TTFT_ms | TPOT_ms | tok/s | per-GPU VRAM (MiB) |
|
||||||
|
|--------|----|---------|---------|-------|--------------------|
|
||||||
|
| xserv | 1 | 33.2 | 17.39 | 57.5 | 24010 |
|
||||||
|
| xserv | 2 | 35.9 | 18.07 | 55.3 | 11580, 13632 |
|
||||||
|
| xserv | 4 | 36.1 | 17.91 | 55.8 | 7298, 5250, 5250, 9350 |
|
||||||
|
| llama | 1 | 133.3 | 9.38 | 106.7 | 15604 |
|
||||||
|
| llama | 2 | 131.4 | 9.10 | 109.9 | 7862, 8494 |
|
||||||
|
| llama | 4 | 161.2 | 8.88 | 112.6 | 4476, 4090, 4090, 5108 |
|
||||||
|
|
||||||
|
(xserv VRAM with `XSERV_MAX_KV_BLOCKS=160` so the number is weights + a minimal
|
||||||
|
KV pool. `tok/s = 1000 / TPOT`. This latency probe's TTFT differs from the
|
||||||
|
quality-suite TTFT below because the suite includes scheduler/HTTP overhead.)
|
||||||
|
|
||||||
|
## Correctness — PP is numerically exact
|
||||||
|
|
||||||
|
The hidden-state hand-off between stages is a bit-exact BF16 P2P copy and each
|
||||||
|
stage runs the same kernels over its layers, so PP must reproduce the single-GPU
|
||||||
|
result. Verified by byte-comparing generated text (greedy, temp 0), running each
|
||||||
|
config **twice** to separate PP effects from run-to-run GEMM noise:
|
||||||
|
|
||||||
|
| comparison | result |
|
||||||
|
|------------|--------|
|
||||||
|
| single run A == single run B | **DIFFER** (cuBLAS GEMM is not bit-reproducible run-to-run) |
|
||||||
|
| pp4 run A == pp4 run B | **IDENTICAL** |
|
||||||
|
| single run A == pp4 run A | **IDENTICAL** |
|
||||||
|
| single == pp2 (single run each) | **IDENTICAL** |
|
||||||
|
|
||||||
|
Takeaway: **single-GPU itself is non-deterministic** under greedy (a 1-ULP logit
|
||||||
|
difference flips a late argmax and the suffix changes), so a one-shot single-vs-PP
|
||||||
|
byte compare can spuriously "DIFFER". The 2×2 control shows PP=4 is *more*
|
||||||
|
reproducible than re-running single-GPU, and it lands exactly on a single-GPU
|
||||||
|
trajectory. NCCL P2P (`tests/sendrecv.rs`) and AllReduce (`tests/allreduce.rs`)
|
||||||
|
unit tests pass.
|
||||||
|
|
||||||
|
## Quality matrix — AIME 2025 (30) + GSM8K (30), greedy, both engines × PP=1/2/4
|
||||||
|
|
||||||
|
Full measured matrix (`tools/bench/summarize_fullq.py`; raw in
|
||||||
|
`bench-out/FULLQ_SUMMARY.txt`). Qwen3-8B BF16, thinking OFF, `max_seq_len 4096`.
|
||||||
|
xserv on GPUs 0-3, llama.cpp on GPUs 4-7 (disjoint groups, run in parallel).
|
||||||
|
|
||||||
|
| engine | PP | AIME 2025 | GSM8K | AIME mean_tok | TTFT_ms | TPOT_ms |
|
||||||
|
|--------|----|-----------|-------|---------------|---------|---------|
|
||||||
|
| xserv | 1 | 8/30 (26.7%) | 29/30 (96.7%) | 2383 | 485 | 22.42 |
|
||||||
|
| xserv | 2 | 7/30 (23.3%) | 29/30 (96.7%) | 2367 | 457 | 22.55 |
|
||||||
|
| xserv | 4 | 7/30 (23.3%) | 29/30 (96.7%) | 2652 | 494 | 23.31 |
|
||||||
|
| llama | 1 | 7/30 (23.3%) | 29/30 (96.7%) | 2651 | 119 | 10.37 |
|
||||||
|
| llama | 2 | 7/30 (23.3%) | 29/30 (96.7%) | 2651 | 118 | 10.41 |
|
||||||
|
| llama | 4 | 7/30 (23.3%) | 29/30 (96.7%) | 2651 | 119 | 10.39 |
|
||||||
|
|
||||||
|
Reading the matrix:
|
||||||
|
|
||||||
|
- **GSM8K = 29/30 (96.7%) in every cell** — identical across both engines and all
|
||||||
|
PP levels. xserv's accuracy matches llama.cpp exactly on the same weights.
|
||||||
|
- **AIME = 7/30 (23.3%) everywhere except xserv PP=1 (8/30)**. That single +1 is
|
||||||
|
the run-to-run greedy nondeterminism documented above (an AIME solution is
|
||||||
|
~2400 tokens; one late argmax flip changes one problem's outcome) — not a PP or
|
||||||
|
engine effect. AIME accuracy is low because this is an 8B model with thinking
|
||||||
|
disabled; the point here is the *cross-engine / cross-PP agreement*, which holds.
|
||||||
|
- **TPOT is flat across PP** for both engines (xserv 22.4→23.3 ms, llama
|
||||||
|
10.3→10.4 ms), reconfirming PP doesn't slow single-stream decode. The ~2.2×
|
||||||
|
TPOT gap to llama.cpp is the single-GPU gap (`llama-cpp-comparison.md`),
|
||||||
|
orthogonal to PP.
|
||||||
|
|
||||||
|
## Takeaways
|
||||||
|
|
||||||
|
- **Memory is the win.** Per-GPU weights+KV scale ~1/P: xserv 24.0 GB (1 GPU) →
|
||||||
|
~11–14 GB (PP=2) → ~5–9 GB (PP=4); llama 15.6 → ~8 → ~4–5 GB. The two end
|
||||||
|
stages sit higher (stage 0 holds `embed_tokens`, the last stage `norm`+`lm_head`,
|
||||||
|
~1.1 GB each). This is what PP buys: a model / context that does not fit on one
|
||||||
|
card fits across P.
|
||||||
|
- **Single-stream latency is flat, not faster.** v1 PP is serial across stages
|
||||||
|
(no microbatch overlap): per-token latency = sum of all stages' compute +
|
||||||
|
(P-1) P2P hops + a blocking sync per stage. The `[1, hidden]` BF16 hop (8 KB)
|
||||||
|
over PCIe is cheap relative to per-token compute, so TPOT is ~constant across P.
|
||||||
|
PP does **not** speed up single-stream decode; it trades (almost no) latency for
|
||||||
|
large memory headroom.
|
||||||
|
- **Quality is preserved and matches llama.cpp.** GSM8K 96.7% in all 12 cells;
|
||||||
|
AIME within the greedy noise band. PP=1/2/4 agree, and xserv tracks llama.cpp.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/sync-and-build.sh build
|
||||||
|
# latency + VRAM + byte-exact correctness (writes bench-out/PP_FINAL.md):
|
||||||
|
ssh <host> 'cd <repo> && bash tools/pp_final.sh'
|
||||||
|
# determinism control (single×2 vs pp4×2):
|
||||||
|
ssh <host> 'cd <repo> && bash tools/pp_diag.sh'
|
||||||
|
# NCCL P2P + AllReduce unit tests:
|
||||||
|
ssh <host> 'cd <repo> && cargo test -p xserv-distributed --release'
|
||||||
|
# full quality matrix AIME-30 + GSM8K-30 (xserv 0-3 serial; or parallel w/ llama 4-7):
|
||||||
|
ssh <host> 'cd <repo> && bash tools/pp_quality_full.sh' # xserv+llama serial, GPU 0-3
|
||||||
|
ssh <host> 'cd <repo> && bash tools/pp_llama_47.sh' # llama on GPU 4-7 (parallel)
|
||||||
|
python3 tools/bench/summarize_fullq.py bench-out
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next (where PP actually raises throughput)
|
||||||
|
|
||||||
|
- **Microbatch / 1F1B overlap**: while stage 1 runs microbatch A, stage 0 runs B.
|
||||||
|
This is the only thing that turns PP into a *throughput* win; v1 is serial, so
|
||||||
|
P GPUs give 1 GPU's single-stream rate (but P× the memory headroom / batch room).
|
||||||
|
- Persistent per-stage recv buffers (drop the per-token CPU alloc + H2D) and
|
||||||
|
event-based ordering instead of a full device sync per hop.
|
||||||
|
- 2D TP×PP, and `layers % P != 0` non-uniform splits.
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
73
docs/benchmarks/tensor-parallelism.md
Normal file
73
docs/benchmarks/tensor-parallelism.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Benchmark: Tensor Parallelism (TP=1/2/4) — xserv vs llama.cpp
|
||||||
|
|
||||||
|
**Setup.** Qwen3-8B BF16 on 8× RTX 5090 (PCIe Gen5, **no NVLink**; GPUs grouped
|
||||||
|
0-3 / 4-7 by PHB). Both engines driven over the same OpenAI HTTP harness, same
|
||||||
|
scorers, thinking-off, greedy (temp 0), `max_tokens` 2048. Datasets: **AIME
|
||||||
|
2025** (30) + **GSM8K** (30). The two engines run **concurrently on disjoint
|
||||||
|
groups** — xserv on GPU 0..N-1, llama.cpp (`--split-mode row`) on GPU 4..4+N-1
|
||||||
|
(`tools/bench/run_tp_parallel.sh`).
|
||||||
|
|
||||||
|
## Correctness — on par across engines and TP
|
||||||
|
|
||||||
|
| TP | task | xserv | llama.cpp |
|
||||||
|
|----|------|-------|-----------|
|
||||||
|
| 1 | AIME 2025 | 16.7% (5/30) | 13.3% (4/30) |
|
||||||
|
| 1 | GSM8K | 96.7% (29/30) | 96.7% (29/30) |
|
||||||
|
| 2 | AIME 2025 | 13.3% (4/30) | 13.3% (4/30) |
|
||||||
|
| 2 | GSM8K | 93.3% (28/30) | 96.7% (29/30) |
|
||||||
|
| 4 | AIME 2025 | 16.7% (5/30) | 13.3% (4/30) |
|
||||||
|
| 4 | GSM8K | 96.7% (29/30) | 96.7% (29/30) |
|
||||||
|
|
||||||
|
Within ±1 problem everywhere — TP changes nothing about quality on either
|
||||||
|
engine, and the two engines agree. (AIME is low for both: Qwen3-8B thinking-off,
|
||||||
|
capped at 2048 tokens.)
|
||||||
|
|
||||||
|
## Performance — TPOT (ms/token, lower is better)
|
||||||
|
|
||||||
|
| TP | xserv AIME / GSM8K | llama.cpp AIME / GSM8K |
|
||||||
|
|----|--------------------|------------------------|
|
||||||
|
| 1 | 21.0 / 17.8 | **10.4 / 10.3** |
|
||||||
|
| 2 | 17.2 / 13.9 | 19.0 / 18.9 |
|
||||||
|
| 4 | **15.2 / 12.1** | 20.2 / 20.2 |
|
||||||
|
|
||||||
|
**Opposite TP scaling, with a crossover:**
|
||||||
|
|
||||||
|
- **xserv TP scales positively**: TPOT 21.0 → 17.2 → 15.2 ms (AIME),
|
||||||
|
17.8 → 13.9 → 12.1 ms (GSM8K) — TP=4 is ~1.4–1.5× faster than TP=1. GPU 0-3
|
||||||
|
all ~82% utilized. (Sublinear because of the 72 PCIe AllReduces/token.)
|
||||||
|
- **llama.cpp row-split regresses**: TPOT 10.4 → 19.0 → 20.2 ms — TP=1 is its
|
||||||
|
best; TP=2/4 nearly double the latency. GPU 4-7 only ~24% utilized
|
||||||
|
(communication-bound). Row-split's per-layer cross-GPU traffic over PCIe
|
||||||
|
without NVLink dominates.
|
||||||
|
- **Crossover**: llama.cpp is ~2× faster at TP=1, still ahead at TP=2, and xserv
|
||||||
|
is ~1.3× faster at TP=4 (15.2 vs 20.2 ms AIME). AIME-30 wall clock: xserv
|
||||||
|
1046 → 846 → 730 s (falling), llama.cpp 520 → 952 → 1012 s (rising).
|
||||||
|
|
||||||
|
On a NVLink-less PCIe box, **xserv's TP is a genuine win and llama.cpp's
|
||||||
|
tensor-split is counterproductive** — exactly what the topology predicts.
|
||||||
|
|
||||||
|
### Clean same-path xserv scaling (bench-tp)
|
||||||
|
|
||||||
|
The HTTP numbers above mix engines (xserv TP=1 uses the production continuous-
|
||||||
|
batching engine; TP≥2 uses the serial TP coordinator). The single-stream,
|
||||||
|
same-code-path scaling from `bench-tp` (greedy, 8 prompts × 64 tokens):
|
||||||
|
|
||||||
|
| TP | xserv decode tok/s | speedup | TTFT |
|
||||||
|
|----|--------------------|---------|------|
|
||||||
|
| 1 | 58.5 | 1.00× | 18.0 ms |
|
||||||
|
| 2 | 75.7 | 1.29× | 13.4 ms |
|
||||||
|
| 4 | 86.1 | 1.47× | 11.5 ms |
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- xserv **TP=1 uses the production `Engine`**, TP≥2 the serial `tp_engine`
|
||||||
|
coordinator — different per-token paths, so the HTTP TP=1→2 step has an engine
|
||||||
|
confound. The clean same-path scaling (bench-tp, above) confirms the trend.
|
||||||
|
- xserv **TTFT is weaker** on long AIME prompts (~460–500 ms vs llama ~100–190 ms)
|
||||||
|
— prefill is a known optimization target.
|
||||||
|
- llama.cpp uses `--split-mode row` (its tensor-parallel mode); the default
|
||||||
|
`layer` split only memory-splits, without parallel compute.
|
||||||
|
- The TP HTTP server processes requests **serially** (sufficient for this serial
|
||||||
|
quality benchmark); continuous-batching TP is future work.
|
||||||
|
|
||||||
|
Raw artifacts: `bench-out/tp{1,2,4}-{xserv,llama}/comparison-*.{md,json}`.
|
||||||
1
third_party/llama.cpp
vendored
Submodule
1
third_party/llama.cpp
vendored
Submodule
Submodule third_party/llama.cpp added at f12cc6d0fa
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user