feat: 开源本机 GPU 加速工具箱(诊断/热驻留/资源调度)
Browse files- .gitignore +2 -0
- Cargo.toml +17 -0
- LICENSE +21 -0
- README.md +161 -0
- examples/gpu_status.rs +59 -0
- src/lib.rs +59 -0
- src/scheduler.rs +156 -0
- src/status.rs +164 -0
- src/warmup.rs +89 -0
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/target
|
| 2 |
+
Cargo.lock
|
Cargo.toml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "perch-gpu"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
edition = "2021"
|
| 5 |
+
description = "Local GPU acceleration toolkit: Ollama VRAM diagnostics, warm-residency preloading, and local/cloud resource scheduling."
|
| 6 |
+
license = "MIT"
|
| 7 |
+
keywords = ["gpu", "ollama", "metal", "llm", "local-inference"]
|
| 8 |
+
categories = ["api-bindings", "asynchronous"]
|
| 9 |
+
|
| 10 |
+
[dependencies]
|
| 11 |
+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
| 12 |
+
serde = { version = "1", features = ["derive"] }
|
| 13 |
+
serde_json = "1"
|
| 14 |
+
tokio = { version = "1", features = ["rt", "time", "sync", "macros"] }
|
| 15 |
+
|
| 16 |
+
[dev-dependencies]
|
| 17 |
+
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 栖木 Perch contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- zh
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- gpu
|
| 7 |
+
- ollama
|
| 8 |
+
- metal
|
| 9 |
+
- local-inference
|
| 10 |
+
- llm
|
| 11 |
+
- rust
|
| 12 |
+
license: mit
|
| 13 |
+
pretty_name: perch-gpu — Local GPU Acceleration Toolkit
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
# 🖥️ perch-gpu
|
| 17 |
+
|
| 18 |
+
### 让本机大模型真的用上 GPU——而且用得对。
|
| 19 |
+
|
| 20 |
+
> 你装了个 7B 模型,满心期待地点发送。
|
| 21 |
+
> 然后**等了 5 秒**才出第一个字。
|
| 22 |
+
>
|
| 23 |
+
> 你查了半天,以为是模型太大、显卡太弱。
|
| 24 |
+
> 其实——**是 Ollama 把它从显存里卸载了**。
|
| 25 |
+
|
| 26 |
+
**perch-gpu** 是一个 Rust 工具箱,解决本机大模型 GPU 加速的三个坑。
|
| 27 |
+
|
| 28 |
+
从 [栖木 Perch](https://huggingface.co/datasets/hongcoding/perch) 生产环境提取 · MIT · `cargo test` 全绿
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## 🕳️ 三个坑(大多数人都踩了)
|
| 33 |
+
|
| 34 |
+
### 坑 1 · 强行指定 GPU 层数
|
| 35 |
+
|
| 36 |
+
```json
|
| 37 |
+
{ "num_gpu": 40 } // ❌ 错
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
很多人以为这样能"控制 GPU 加速"。实际后果:
|
| 41 |
+
**Ollama 触发 runner 重载 → 反而更慢。**
|
| 42 |
+
|
| 43 |
+
**正确做法**:让 Ollama 自己按模型结构和显存选择后端与层数。
|
| 44 |
+
你只需要**如实报告**它分配了多少——这就是本库的 `status` 模块。
|
| 45 |
+
|
| 46 |
+
### 坑 2 · 模型被悄悄卸载
|
| 47 |
+
|
| 48 |
+
Ollama 默认在空闲后释放显存。下次请求**冷加载 3~5 秒**——
|
| 49 |
+
比云端还慢,你的"本机秒回"名存实亡。
|
| 50 |
+
|
| 51 |
+
**正确做法**:预热一次 + `keep_alive` 保活。
|
| 52 |
+
本库的 `warmup` 模块,首 token 从 3s 降到 **0.04~0.18s**。
|
| 53 |
+
|
| 54 |
+
### 坑 3 · 本机并发抢显存
|
| 55 |
+
|
| 56 |
+
同时跑两个模型?一块 GPU 会互相拖慢,甚至 OOM。
|
| 57 |
+
|
| 58 |
+
**正确做法**:本机并发压到 1(排队比抢显存快),云端可控并发。
|
| 59 |
+
本库的 `scheduler` 模块。
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## 🚀 快速开始
|
| 64 |
+
|
| 65 |
+
```toml
|
| 66 |
+
[dependencies]
|
| 67 |
+
perch-gpu = { git = "https://huggingface.co/hongcoding/perch-gpu" }
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
```rust
|
| 71 |
+
use perch_gpu::{status, warmup, scheduler};
|
| 72 |
+
|
| 73 |
+
// 1) 预热 + 保活 30 分钟(首 token 从 3s 降到 0.08s)
|
| 74 |
+
warmup::warmup_model("qwen3-06b:latest", "30m", None).await?;
|
| 75 |
+
|
| 76 |
+
// 2) 查真实 GPU 驻留(读 Ollama /api/ps,不是猜)
|
| 77 |
+
let st = status::query_status(None).await?;
|
| 78 |
+
for m in &st.models {
|
| 79 |
+
println!("{} → GPU 驻留 {}%", m.name, m.gpu_residency_percent);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// 3) 本机推理排队(并发 1,防止抢显存)
|
| 83 |
+
let _permit = scheduler::acquire(false).await?;
|
| 84 |
+
// ... 跑推理 ...
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
跑 demo 看效果:
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
cargo run --example gpu_status
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
真实输出:
|
| 94 |
+
|
| 95 |
+
```
|
| 96 |
+
🔍 查询本机 GPU 状态(http://127.0.0.1:11434)…
|
| 97 |
+
|
| 98 |
+
📦 qwen3-06b:latest
|
| 99 |
+
GPU 驻留: 100%(0.37GB / 0.37GB)
|
| 100 |
+
上下文: 4096
|
| 101 |
+
过期: 2026-11-05T22:30:00+08:00
|
| 102 |
+
|
| 103 |
+
✅ GPU 加速正在生效(Ollama 已自动选择 Metal/CUDA 后端)
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## 🧩 三个模块
|
| 109 |
+
|
| 110 |
+
| 模块 | 作用 | 关键点 |
|
| 111 |
+
|---|---|---|
|
| 112 |
+
| **`status`** | 读 Ollama `/api/ps` 真实 VRAM 分配 | 驻留百分比来自真实字节,不是估算 |
|
| 113 |
+
| **`warmup`** | 预热 + `keep_alive` 保活 | **绝不传 `num_gpu`**(会触发重载)|
|
| 114 |
+
| **`scheduler`** | 本机/云端资源调度 + 元数据缓存 | 只控并发度,不改语义 |
|
| 115 |
+
|
| 116 |
+
## 📊 实测效果(栖木 Perch,Apple Silicon + Metal)
|
| 117 |
+
|
| 118 |
+
| 状态 | 首 token 延迟 |
|
| 119 |
+
|---|---|
|
| 120 |
+
| 冷加载(模型被卸载) | **3 ~ 5 秒** |
|
| 121 |
+
| 热驻留(预热 + 保活) | **0.04 ~ 0.18 秒** |
|
| 122 |
+
|
| 123 |
+
**快 20~70 倍**,代价只是让 0.6B 模型占着 ~0.4GB 显存。
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## 🛡️ 设计红线
|
| 128 |
+
|
| 129 |
+
| 红线 | 原因 |
|
| 130 |
+
|---|---|
|
| 131 |
+
| **绝不传 `num_gpu`** | 让 Ollama 自己选,强行指定会重载 |
|
| 132 |
+
| **诊断不猜** | 驻留百分比 = 真实 `/api/ps` 字节数 |
|
| 133 |
+
| **调度不改语义** | 只控并发,不碰模型/prompt/思考档/回复长度 |
|
| 134 |
+
| **预热不出本机** | 预热提示只发 loopback,不进对话历史 |
|
| 135 |
+
|
| 136 |
+
## 🔬 验证
|
| 137 |
+
|
| 138 |
+
```bash
|
| 139 |
+
cargo test # 7 个单元测试 + 1 个文档测试
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
覆盖:真实驻留计算 / CPU 回退 / 无模型 / 预热体不含 num_gpu / 并发序列化 / 缓存失效。
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## 🔗 相关项目
|
| 147 |
+
|
| 148 |
+
| 项目 | 说明 | 链接 |
|
| 149 |
+
|---|---|---|
|
| 150 |
+
| **栖木 Perch**(应用) | macOS 本地优先 AI 工作台 | https://huggingface.co/datasets/hongcoding/perch |
|
| 151 |
+
| **perch-ultrafast**(框架) | 混合流 + Ultrafast 推理框架:首字 1s → 80ms | https://huggingface.co/hongcoding/perch-ultrafast |
|
| 152 |
+
| **perch-gpu**(本库) | 本机 GPU 加速工具箱 | https://huggingface.co/hongcoding/perch-gpu |
|
| 153 |
+
| **HF Collection** | 全部归在一起 | https://huggingface.co/collections/hongcoding/perch-ai-workbench-and-ultrafast-framework |
|
| 154 |
+
|
| 155 |
+
## 📜 许可
|
| 156 |
+
|
| 157 |
+
MIT —— 随便用,注明出处即可。
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
**🖥️ 别让你的 GPU 闲着。也别让 Ollama 偷偷把它卸载。**
|
examples/gpu_status.rs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! 跑一下:查询本机 GPU 状态 + 演示热驻留。
|
| 2 |
+
//!
|
| 3 |
+
//! ```bash
|
| 4 |
+
//! cargo run --example gpu_status
|
| 5 |
+
//! ```
|
| 6 |
+
//!
|
| 7 |
+
//! 需要本机有 Ollama 在跑(`ollama serve`)。
|
| 8 |
+
|
| 9 |
+
use perch_gpu::status::{query_status, DEFAULT_OLLAMA_BASE_URL};
|
| 10 |
+
use perch_gpu::warmup::warmup_model;
|
| 11 |
+
|
| 12 |
+
#[tokio::main]
|
| 13 |
+
async fn main() {
|
| 14 |
+
println!("🔍 查询本机 GPU 状态({DEFAULT_OLLAMA_BASE_URL})…\n");
|
| 15 |
+
|
| 16 |
+
match query_status(None).await {
|
| 17 |
+
Ok(st) => {
|
| 18 |
+
if st.models.is_empty() {
|
| 19 |
+
println!(" (当前没有加载任何模型——闲置后 Ollama 卸载了)");
|
| 20 |
+
println!("\n💡 热驻留可以解决:预热一次 + keep_alive 保活");
|
| 21 |
+
} else {
|
| 22 |
+
for m in &st.models {
|
| 23 |
+
let gb = |n: u64| n as f64 / 1024.0 / 1024.0 / 1024.0;
|
| 24 |
+
println!(
|
| 25 |
+
" 📦 {}\n GPU 驻留: {}%({:.2}GB / {:.2}GB)",
|
| 26 |
+
m.name,
|
| 27 |
+
m.gpu_residency_percent,
|
| 28 |
+
gb(m.gpu_memory_bytes),
|
| 29 |
+
gb(m.model_bytes)
|
| 30 |
+
);
|
| 31 |
+
if let Some(ctx) = m.context_length {
|
| 32 |
+
println!(" 上下文: {ctx}");
|
| 33 |
+
}
|
| 34 |
+
if let Some(exp) = &m.expires_at {
|
| 35 |
+
println!(" 过期: {exp}");
|
| 36 |
+
}
|
| 37 |
+
println!();
|
| 38 |
+
}
|
| 39 |
+
println!("✅ GPU 加速正在生效(Ollama 已自动选择 Metal/CUDA 后端)");
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
Err(e) => {
|
| 43 |
+
println!(" ✗ 查询失败: {e}");
|
| 44 |
+
println!("\n 检查 Ollama 是否在跑:curl {DEFAULT_OLLAMA_BASE_URL}/api/ps");
|
| 45 |
+
return;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// 演示:预热第一个模型(如果有的话)
|
| 50 |
+
if let Ok(st) = query_status(None).await {
|
| 51 |
+
if let Some(first) = st.models.first() {
|
| 52 |
+
println!("\n🔥 预热 {}(keep_alive 30m)…", first.name);
|
| 53 |
+
match warmup_model(&first.name, "30m", None).await {
|
| 54 |
+
Ok(()) => println!(" ✅ 已保活 30 分钟——下次请求首 token 只要 0.04~0.18s"),
|
| 55 |
+
Err(e) => println!(" ✗ 预热失败: {e}"),
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
src/lib.rs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! # perch-gpu
|
| 2 |
+
//!
|
| 3 |
+
//! **本机 GPU 加速工具箱**——让本地大模型真的用上 GPU,而且用得对。
|
| 4 |
+
//!
|
| 5 |
+
//! 从 [栖木 Perch](https://huggingface.co/datasets/hongcoding/perch) 提取的 Rust 模块。
|
| 6 |
+
//!
|
| 7 |
+
//! ## 解决什么问题
|
| 8 |
+
//!
|
| 9 |
+
//! 很多人以为"GPU 加速"就是传个 `num_gpu: 40`。**这是错的**:
|
| 10 |
+
//!
|
| 11 |
+
//! - 强行指定 GPU 层数 → Ollama 触发 runner 重载 → 反而更慢
|
| 12 |
+
//! - 模型闲置后被卸载 → 下次请求冷加载 3~5 秒 → 比云端还慢
|
| 13 |
+
//! - 本机并发跑多个模型 → 抢显存 → 互相拖慢甚至 OOM
|
| 14 |
+
//!
|
| 15 |
+
//! 本模块做三件对的事:
|
| 16 |
+
//!
|
| 17 |
+
//! 1. **如实诊断**:读 Ollama `/api/ps` 的真实 VRAM 分配,算出驻留百分比
|
| 18 |
+
//! 2. **热驻留**:预热一次 + `keep_alive` 保活,让模型常驻显存(首 token 0.04~0.18s)
|
| 19 |
+
//! 3. **资源调度**:本机并发压到 1、云端可控并发,请求排队而不是互相踩踏
|
| 20 |
+
//!
|
| 21 |
+
//! ## 快速开始
|
| 22 |
+
//!
|
| 23 |
+
//! ```rust,no_run
|
| 24 |
+
//! # async fn demo() -> Result<(), String> {
|
| 25 |
+
//! use perch_gpu::{status, warmup, scheduler};
|
| 26 |
+
//!
|
| 27 |
+
//! // 1) 预热 + 保活 30 分钟(首 token 从 3s 降到 0.08s)
|
| 28 |
+
//! warmup::warmup_model("qwen3-06b:latest", "30m", None).await?;
|
| 29 |
+
//!
|
| 30 |
+
//! // 2) 查真实 GPU 驻留
|
| 31 |
+
//! let st = status::query_status(None).await?;
|
| 32 |
+
//! for m in &st.models {
|
| 33 |
+
//! println!("{} → GPU 驻留 {}%({}/{} 字节)",
|
| 34 |
+
//! m.name, m.gpu_residency_percent, m.gpu_memory_bytes, m.model_bytes);
|
| 35 |
+
//! }
|
| 36 |
+
//!
|
| 37 |
+
//! // 3) 本机推理排队(并发 1)
|
| 38 |
+
//! let _permit = scheduler::acquire(false).await?;
|
| 39 |
+
//! // ... 在这里跑本机推理 ...
|
| 40 |
+
//! # Ok(())
|
| 41 |
+
//! # }
|
| 42 |
+
//! ```
|
| 43 |
+
//!
|
| 44 |
+
//! ## 设计红线
|
| 45 |
+
//!
|
| 46 |
+
//! - **绝不传 `num_gpu`**:让 Ollama 自己选后端与层数
|
| 47 |
+
//! - **诊断不猜**:驻留百分比来自 `/api/ps` 真实字节数,不是估算
|
| 48 |
+
//! - **调度不改语义**:只控制并发度,不碰模型/prompt/思考档/回复长度
|
| 49 |
+
|
| 50 |
+
pub mod scheduler;
|
| 51 |
+
pub mod status;
|
| 52 |
+
pub mod warmup;
|
| 53 |
+
|
| 54 |
+
pub use scheduler::{acquire, cached_health, cached_models, invalidate, revision};
|
| 55 |
+
pub use status::{
|
| 56 |
+
from_ps_json, query_status, residency_percent, GpuAccelerationStatus, GpuModelStatus,
|
| 57 |
+
DEFAULT_OLLAMA_BASE_URL,
|
| 58 |
+
};
|
| 59 |
+
pub use warmup::{warmup_body, warmup_model};
|
src/scheduler.rs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! UltraSpeed 资源调度:本机/云端分别限流 + 元数据缓存。
|
| 2 |
+
//!
|
| 3 |
+
//! # 问题
|
| 4 |
+
//!
|
| 5 |
+
//! 本机 GPU 推理的并发度很低(一块 GPU 同时跑两个大模型会互相抢显存,
|
| 6 |
+
//! 甚至触发 Ollama 卸载重载)。云端 API 则有 RPM/TPM 限流。
|
| 7 |
+
//!
|
| 8 |
+
//! # 解法
|
| 9 |
+
//!
|
| 10 |
+
//! 用信号量把**本机并发压到 1**、云端控制在可控并发(默认 2),
|
| 11 |
+
//! 请求排队而不是互相踩踏。同时缓存 provider 的 health / models 元数据,
|
| 12 |
+
//! 避免每次请求都探活(探活本身也要几十毫秒)。
|
| 13 |
+
//!
|
| 14 |
+
//! 注意:调度**只影响并发度**,绝不改变模型 / prompt / 思考档 / 回复长度。
|
| 15 |
+
|
| 16 |
+
use std::collections::HashMap;
|
| 17 |
+
use std::sync::atomic::{AtomicU64, Ordering};
|
| 18 |
+
use std::sync::{Arc, LazyLock, Mutex};
|
| 19 |
+
use std::time::{Duration, Instant};
|
| 20 |
+
|
| 21 |
+
use tokio::sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore};
|
| 22 |
+
|
| 23 |
+
/// 本机推理并发上限(GPU 单卡最优 = 1,排队比抢显存快)。
|
| 24 |
+
pub const LOCAL_CONCURRENCY: usize = 1;
|
| 25 |
+
/// 云端并发上限(按你的 API 限额调整)。
|
| 26 |
+
pub const CLOUD_CONCURRENCY: usize = 2;
|
| 27 |
+
/// 元数据缓存有效期。
|
| 28 |
+
pub const METADATA_TTL: Duration = Duration::from_secs(30);
|
| 29 |
+
|
| 30 |
+
static LOCAL_PERMITS: LazyLock<Arc<Semaphore>> =
|
| 31 |
+
LazyLock::new(|| Arc::new(Semaphore::new(LOCAL_CONCURRENCY)));
|
| 32 |
+
static CLOUD_PERMITS: LazyLock<Arc<Semaphore>> =
|
| 33 |
+
LazyLock::new(|| Arc::new(Semaphore::new(CLOUD_CONCURRENCY)));
|
| 34 |
+
|
| 35 |
+
/// 申请一个执行许可:`cloud = true` 走云端池,否则走本机池。
|
| 36 |
+
///
|
| 37 |
+
/// 返回的 permit 释放时自动归还名额。调用方应尽量短持有。
|
| 38 |
+
pub async fn acquire(cloud: bool) -> Result<OwnedSemaphorePermit, String> {
|
| 39 |
+
let pool = if cloud {
|
| 40 |
+
CLOUD_PERMITS.clone()
|
| 41 |
+
} else {
|
| 42 |
+
LOCAL_PERMITS.clone()
|
| 43 |
+
};
|
| 44 |
+
pool.acquire_owned()
|
| 45 |
+
.await
|
| 46 |
+
.map_err(|_| "资源池已关闭".to_string())
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// ── 凭据版本号:凭据变更后让所有缓存失效 ────────────────────────────
|
| 50 |
+
|
| 51 |
+
static REVISION: AtomicU64 = AtomicU64::new(0);
|
| 52 |
+
|
| 53 |
+
/// 当前凭据版本(缓存 key 的一部分)。
|
| 54 |
+
pub fn revision() -> u64 {
|
| 55 |
+
REVISION.load(Ordering::Relaxed)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/// 凭据/配置变更后调用:让所有 provider 的元数据缓存失效。
|
| 59 |
+
pub fn invalidate() {
|
| 60 |
+
REVISION.fetch_add(1, Ordering::Relaxed);
|
| 61 |
+
if let Ok(mut cache) = CACHE.lock() {
|
| 62 |
+
cache.clear();
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// ── 带 TTL 的元数据缓存 ────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
struct Metadata {
|
| 69 |
+
health: AsyncMutex<Option<(Instant, bool)>>,
|
| 70 |
+
models: AsyncMutex<Option<(Instant, Result<Vec<String>, String>)>>,
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
static CACHE: LazyLock<Mutex<HashMap<String, Arc<Metadata>>>> =
|
| 74 |
+
LazyLock::new(|| Mutex::new(HashMap::new()));
|
| 75 |
+
|
| 76 |
+
fn entry(key: &str) -> Arc<Metadata> {
|
| 77 |
+
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
| 78 |
+
cache
|
| 79 |
+
.entry(key.to_string())
|
| 80 |
+
.or_insert_with(|| {
|
| 81 |
+
Arc::new(Metadata {
|
| 82 |
+
health: AsyncMutex::new(None),
|
| 83 |
+
models: AsyncMutex::new(None),
|
| 84 |
+
})
|
| 85 |
+
})
|
| 86 |
+
.clone()
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/// 读取健康状态缓存(未命中/过期返回 None)。
|
| 90 |
+
pub async fn cached_health(key: &str) -> Option<bool> {
|
| 91 |
+
let meta = entry(key);
|
| 92 |
+
let guard = meta.health.lock().await;
|
| 93 |
+
match guard.as_ref() {
|
| 94 |
+
Some((at, value)) if at.elapsed() < METADATA_TTL => Some(*value),
|
| 95 |
+
_ => None,
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/// 写入健康状态缓存。
|
| 100 |
+
pub async fn store_health(key: &str, healthy: bool) {
|
| 101 |
+
let meta = entry(key);
|
| 102 |
+
*meta.health.lock().await = Some((Instant::now(), healthy));
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/// 读取模型列表缓存(未命中/过期返回 None)。
|
| 106 |
+
pub async fn cached_models(key: &str) -> Option<Result<Vec<String>, String>> {
|
| 107 |
+
let meta = entry(key);
|
| 108 |
+
let guard = meta.models.lock().await;
|
| 109 |
+
match guard.as_ref() {
|
| 110 |
+
Some((at, value)) if at.elapsed() < METADATA_TTL => Some(value.clone()),
|
| 111 |
+
_ => None,
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/// 写入模型列表缓存。
|
| 116 |
+
pub async fn store_models(key: &str, models: Result<Vec<String>, String>) {
|
| 117 |
+
let meta = entry(key);
|
| 118 |
+
*meta.models.lock().await = Some((Instant::now(), models));
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
#[cfg(test)]
|
| 122 |
+
mod tests {
|
| 123 |
+
use super::*;
|
| 124 |
+
|
| 125 |
+
#[tokio::test]
|
| 126 |
+
async fn local_pool_serializes_to_one() {
|
| 127 |
+
let first = acquire(false).await.unwrap();
|
| 128 |
+
// 第二个申请应排队:用 try_acquire 验证名额已满
|
| 129 |
+
assert!(
|
| 130 |
+
LOCAL_PERMITS.clone().try_acquire_owned().is_err(),
|
| 131 |
+
"本机并发必须压到 1(GPU 排队比抢显存快)"
|
| 132 |
+
);
|
| 133 |
+
drop(first);
|
| 134 |
+
assert!(LOCAL_PERMITS.clone().try_acquire_owned().is_ok());
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#[tokio::test]
|
| 138 |
+
async fn metadata_cache_hits_within_ttl_and_invalidates() {
|
| 139 |
+
let key = "test-provider";
|
| 140 |
+
store_health(key, true).await;
|
| 141 |
+
assert_eq!(cached_health(key).await, Some(true));
|
| 142 |
+
store_models(key, Ok(vec!["m1".into()])).await;
|
| 143 |
+
assert!(cached_models(key).await.is_some());
|
| 144 |
+
invalidate();
|
| 145 |
+
// invalidate 清空缓存
|
| 146 |
+
assert_eq!(cached_health(key).await, None);
|
| 147 |
+
assert!(cached_models(key).await.is_none());
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
#[test]
|
| 151 |
+
fn revision_monotonically_increases() {
|
| 152 |
+
let before = revision();
|
| 153 |
+
invalidate();
|
| 154 |
+
assert!(revision() > before);
|
| 155 |
+
}
|
| 156 |
+
}
|
src/status.rs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! 本机 GPU 状态诊断:读 Ollama `/api/ps` 的真实 VRAM 分配。
|
| 2 |
+
//!
|
| 3 |
+
//! # 为什么要读 `/api/ps` 而不是猜
|
| 4 |
+
//!
|
| 5 |
+
//! 大多数应用会通过 `num_gpu` 参数"控制"GPU 层数——**这是错的**。
|
| 6 |
+
//! Ollama 自己会按模型结构和显存选择 Metal/CUDA 后端与层数;
|
| 7 |
+
//! 强行传 `num_gpu` 会触发 runner 重载,反而更慢。
|
| 8 |
+
//!
|
| 9 |
+
//! 正确的做法:**让 Ollama 自己决定,然后如实报告它实际分配了多少显存。**
|
| 10 |
+
|
| 11 |
+
use serde::{Deserialize, Serialize};
|
| 12 |
+
|
| 13 |
+
/// 本机 Ollama 的默认地址。
|
| 14 |
+
pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://127.0.0.1:11434";
|
| 15 |
+
|
| 16 |
+
/// 单个模型的 GPU 驻留状态。
|
| 17 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
| 18 |
+
#[serde(rename_all = "camelCase")]
|
| 19 |
+
pub struct GpuModelStatus {
|
| 20 |
+
/// 模型名(如 `qwen3-06b:latest`)。
|
| 21 |
+
pub name: String,
|
| 22 |
+
/// 模型总大小(字节)。
|
| 23 |
+
pub model_bytes: u64,
|
| 24 |
+
/// 实际分配在 VRAM 的字节数(`size_vram`)。
|
| 25 |
+
pub gpu_memory_bytes: u64,
|
| 26 |
+
/// 上下文长度(Ollama 新版才返回)。
|
| 27 |
+
pub context_length: Option<u64>,
|
| 28 |
+
/// 驻留过期时间(keep_alive 到期)。
|
| 29 |
+
pub expires_at: Option<String>,
|
| 30 |
+
/// GPU 驻留百分比(0~100,近似值)。
|
| 31 |
+
/// 这是**驻留估算**,不是 GPU 利用率,也不是 tokens/s。
|
| 32 |
+
pub gpu_residency_percent: u8,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/// 本机 GPU 加速总状态。
|
| 36 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
| 37 |
+
#[serde(rename_all = "camelCase")]
|
| 38 |
+
pub struct GpuAccelerationStatus {
|
| 39 |
+
/// Ollama 是否在线。
|
| 40 |
+
pub online: bool,
|
| 41 |
+
/// 查询时间(毫秒时间戳)。
|
| 42 |
+
pub checked_at_ms: i64,
|
| 43 |
+
/// 已加载的模型列表。
|
| 44 |
+
pub models: Vec<GpuModelStatus>,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
#[derive(Debug, Default, Deserialize)]
|
| 48 |
+
struct PsResponse {
|
| 49 |
+
#[serde(default)]
|
| 50 |
+
models: Vec<PsModel>,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
#[derive(Debug, Default, Deserialize)]
|
| 54 |
+
struct PsModel {
|
| 55 |
+
#[serde(default)]
|
| 56 |
+
name: String,
|
| 57 |
+
#[serde(default)]
|
| 58 |
+
size: u64,
|
| 59 |
+
#[serde(default, rename = "size_vram")]
|
| 60 |
+
size_vram: u64,
|
| 61 |
+
#[serde(default, rename = "context_length")]
|
| 62 |
+
context_length: Option<u64>,
|
| 63 |
+
#[serde(default, rename = "expires_at")]
|
| 64 |
+
expires_at: Option<String>,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/// 计算 GPU 驻留百分比(纯函数,可单测)。
|
| 68 |
+
pub fn residency_percent(size: u64, gpu: u64) -> u8 {
|
| 69 |
+
if size == 0 {
|
| 70 |
+
return 0;
|
| 71 |
+
}
|
| 72 |
+
((gpu.min(size) as u128 * 100 / size as u128).min(100)) as u8
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/// 把 `/api/ps` 的原始响应转成结构化状态(纯函数,可单测)。
|
| 76 |
+
pub fn from_ps_json(value: &str, now_ms: i64) -> Result<GpuAccelerationStatus, String> {
|
| 77 |
+
let parsed: PsResponse =
|
| 78 |
+
serde_json::from_str(value).map_err(|e| format!("无法解析 /api/ps 响应:{e}"))?;
|
| 79 |
+
let models = parsed
|
| 80 |
+
.models
|
| 81 |
+
.into_iter()
|
| 82 |
+
.map(|model| GpuModelStatus {
|
| 83 |
+
gpu_residency_percent: residency_percent(model.size, model.size_vram),
|
| 84 |
+
name: model.name,
|
| 85 |
+
model_bytes: model.size,
|
| 86 |
+
gpu_memory_bytes: model.size_vram,
|
| 87 |
+
context_length: model.context_length,
|
| 88 |
+
expires_at: model.expires_at,
|
| 89 |
+
})
|
| 90 |
+
.collect();
|
| 91 |
+
Ok(GpuAccelerationStatus {
|
| 92 |
+
online: true,
|
| 93 |
+
checked_at_ms: now_ms,
|
| 94 |
+
models,
|
| 95 |
+
})
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/// 查询本机 Ollama 的 GPU 状态(HTTP 请求)。
|
| 99 |
+
pub async fn query_status(base_url: Option<&str>) -> Result<GpuAccelerationStatus, String> {
|
| 100 |
+
let base = base_url
|
| 101 |
+
.unwrap_or(DEFAULT_OLLAMA_BASE_URL)
|
| 102 |
+
.trim_end_matches('/');
|
| 103 |
+
let client = reqwest::Client::builder()
|
| 104 |
+
.connect_timeout(std::time::Duration::from_secs(1))
|
| 105 |
+
.build()
|
| 106 |
+
.map_err(|e| format!("初始化状态查询失败:{e}"))?;
|
| 107 |
+
let response = client
|
| 108 |
+
.get(format!("{base}/api/ps"))
|
| 109 |
+
.timeout(std::time::Duration::from_secs(3))
|
| 110 |
+
.send()
|
| 111 |
+
.await
|
| 112 |
+
.map_err(|e| format!("无法读取本机 Ollama GPU 状态:{e}"))?;
|
| 113 |
+
if !response.status().is_success() {
|
| 114 |
+
return Err(format!("本机 Ollama 状态接口返回 {}", response.status()));
|
| 115 |
+
}
|
| 116 |
+
let text = response
|
| 117 |
+
.text()
|
| 118 |
+
.await
|
| 119 |
+
.map_err(|e| format!("无法读取响应体:{e}"))?;
|
| 120 |
+
from_ps_json(&text, now_ms())
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
fn now_ms() -> i64 {
|
| 124 |
+
std::time::SystemTime::now()
|
| 125 |
+
.duration_since(std::time::UNIX_EPOCH)
|
| 126 |
+
.map(|d| d.as_millis() as i64)
|
| 127 |
+
.unwrap_or(0)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
#[cfg(test)]
|
| 131 |
+
mod tests {
|
| 132 |
+
use super::*;
|
| 133 |
+
|
| 134 |
+
#[test]
|
| 135 |
+
fn reports_real_residency_and_handles_cpu_fallback() {
|
| 136 |
+
let status = from_ps_json(
|
| 137 |
+
r#"{"models":[
|
| 138 |
+
{"name":"gpu-model","size":1000,"size_vram":1000,"context_length":4096,"expires_at":"soon"},
|
| 139 |
+
{"name":"partial-model","size":1000,"size_vram":500},
|
| 140 |
+
{"name":"cpu-model","size":1000,"size_vram":0},
|
| 141 |
+
{"name":"unknown-size","size":0,"size_vram":0}
|
| 142 |
+
]}"#,
|
| 143 |
+
0,
|
| 144 |
+
)
|
| 145 |
+
.unwrap();
|
| 146 |
+
assert!(status.online);
|
| 147 |
+
assert_eq!(status.models[0].gpu_residency_percent, 100);
|
| 148 |
+
assert_eq!(status.models[1].gpu_residency_percent, 50);
|
| 149 |
+
assert_eq!(status.models[2].gpu_residency_percent, 0);
|
| 150 |
+
assert_eq!(status.models[3].gpu_residency_percent, 0);
|
| 151 |
+
assert_eq!(status.models[0].context_length, Some(4096));
|
| 152 |
+
// 序列化字段名(camelCase)
|
| 153 |
+
let wire = serde_json::to_value(&status).unwrap();
|
| 154 |
+
assert_eq!(wire["models"][0]["gpuResidencyPercent"], 100);
|
| 155 |
+
assert_eq!(wire["models"][0]["gpuMemoryBytes"], 1000);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
#[test]
|
| 159 |
+
fn accepts_no_loaded_models() {
|
| 160 |
+
let status = from_ps_json("{}", 0).unwrap();
|
| 161 |
+
assert!(status.online);
|
| 162 |
+
assert!(status.models.is_empty());
|
| 163 |
+
}
|
| 164 |
+
}
|
src/warmup.rs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! 热驻留(warm residency):把模型常驻在显存里,避免每次请求冷加载。
|
| 2 |
+
//!
|
| 3 |
+
//! # 为什么重要
|
| 4 |
+
//!
|
| 5 |
+
//! Ollama 默认会在空闲后卸载模型(free VRAM)。下次请求需要**冷加载 3~5 秒**——
|
| 6 |
+
//! 比云端还慢,混合流的"本机秒回"就名存实亡。
|
| 7 |
+
//!
|
| 8 |
+
//! 解法:一次轻量预热 + `keep_alive` 保活。之后每次请求首 token 只要 0.04~0.18 秒。
|
| 9 |
+
//!
|
| 10 |
+
//! # 关键:**不要**传 `num_gpu`
|
| 11 |
+
//!
|
| 12 |
+
//! 强行指定 GPU 层数会触发 runner 重载,反而破坏热驻留。
|
| 13 |
+
//! 让 Ollama 自己选择后端与层数,我们只负责"保持它醒着"。
|
| 14 |
+
|
| 15 |
+
use crate::status::DEFAULT_OLLAMA_BASE_URL;
|
| 16 |
+
use serde_json::json;
|
| 17 |
+
|
| 18 |
+
/// 预热请求体(纯函数,可单测)。
|
| 19 |
+
///
|
| 20 |
+
/// - `prompt: "hi"` + `num_predict: 1`——只生成 1 个 token,几乎零开销
|
| 21 |
+
/// - `keep_alive`——保活时长(Ollama 语法:`"30m"` / `"-1"` 永久 / 数字秒)
|
| 22 |
+
/// - **不含 `num_gpu`**——见模块文档
|
| 23 |
+
pub fn warmup_body(model: &str, keep_alive: &str) -> serde_json::Value {
|
| 24 |
+
json!({
|
| 25 |
+
"model": model,
|
| 26 |
+
"prompt": "hi",
|
| 27 |
+
"stream": false,
|
| 28 |
+
"keep_alive": keep_alive,
|
| 29 |
+
"options": {
|
| 30 |
+
"num_predict": 1
|
| 31 |
+
}
|
| 32 |
+
})
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/// 预热一个模型(把请求发到本机 Ollama)。
|
| 36 |
+
///
|
| 37 |
+
/// 这个提示**不会**进入对话历史,也不出本机 loopback。
|
| 38 |
+
pub async fn warmup_model(
|
| 39 |
+
model: &str,
|
| 40 |
+
keep_alive: &str,
|
| 41 |
+
base_url: Option<&str>,
|
| 42 |
+
) -> Result<(), String> {
|
| 43 |
+
let model = model.trim();
|
| 44 |
+
if model.is_empty() {
|
| 45 |
+
return Err("模型名不能为空".to_string());
|
| 46 |
+
}
|
| 47 |
+
if model.len() > 256 {
|
| 48 |
+
return Err("模型名过长".to_string());
|
| 49 |
+
}
|
| 50 |
+
let base = base_url
|
| 51 |
+
.unwrap_or(DEFAULT_OLLAMA_BASE_URL)
|
| 52 |
+
.trim_end_matches('/');
|
| 53 |
+
let client = reqwest::Client::builder()
|
| 54 |
+
.connect_timeout(std::time::Duration::from_secs(3))
|
| 55 |
+
.build()
|
| 56 |
+
.map_err(|e| format!("初始化预热请求失败:{e}"))?;
|
| 57 |
+
let response = client
|
| 58 |
+
.post(format!("{base}/api/generate"))
|
| 59 |
+
.json(&warmup_body(model, keep_alive))
|
| 60 |
+
.timeout(std::time::Duration::from_secs(120))
|
| 61 |
+
.send()
|
| 62 |
+
.await
|
| 63 |
+
.map_err(|e| format!("预热请求失败:{e}"))?;
|
| 64 |
+
if !response.status().is_success() {
|
| 65 |
+
return Err(format!("预热返回 {}", response.status()));
|
| 66 |
+
}
|
| 67 |
+
Ok(())
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
#[cfg(test)]
|
| 71 |
+
mod tests {
|
| 72 |
+
use super::*;
|
| 73 |
+
|
| 74 |
+
#[test]
|
| 75 |
+
fn warmup_body_is_local_bounded_and_never_forces_gpu_layers() {
|
| 76 |
+
let body = warmup_body("qwen3-06b:latest", "30m");
|
| 77 |
+
assert_eq!(body["model"], "qwen3-06b:latest");
|
| 78 |
+
assert_eq!(body["stream"], false);
|
| 79 |
+
assert_eq!(body["keep_alive"], "30m");
|
| 80 |
+
assert_eq!(body["options"]["num_predict"], 1);
|
| 81 |
+
// 红线:绝不强行指定 GPU 层数(会触发 runner 重载)
|
| 82 |
+
assert!(body["options"].get("num_gpu").is_none());
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
#[tokio::test]
|
| 86 |
+
async fn rejects_empty_model_name() {
|
| 87 |
+
assert!(warmup_model("", "30m", None).await.is_err());
|
| 88 |
+
}
|
| 89 |
+
}
|