hongcoding commited on
Commit
e85df1f
·
verified ·
1 Parent(s): deec9a7

feat: 开源本机 GPU 加速工具箱(诊断/热驻留/资源调度)

Browse files
Files changed (9) hide show
  1. .gitignore +2 -0
  2. Cargo.toml +17 -0
  3. LICENSE +21 -0
  4. README.md +161 -0
  5. examples/gpu_status.rs +59 -0
  6. src/lib.rs +59 -0
  7. src/scheduler.rs +156 -0
  8. src/status.rs +164 -0
  9. 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
+ }