Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions catalog/models/glm-4.7-flash.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ variants:
default_config:
quantization: int4
n_gpu_layers: 999
ctx_size: 8192
# Full trained context (202752). Deploy auto-clamps it down to fit hardware
# memory on smaller machines (weights ~18GB + KV cache), so this large default
# is safe everywhere.
ctx_size: 202752
expected_performance:
startup_time_s: 20
cold_start_time_s: 40
tokens_per_second: [48, 60]
latency_first_token_ms: [30, 200]
notes: "Verified 2026-06-08 on AMD Strix Halo Radeon 8060S iGPU, llama.cpp b9180 HIP, Q4_K_M (deepseek2 arch, 30B-A3B MoE) all 999 layers offloaded: 58.9 tok/s decode, 83 tok/s prompt."
notes: "Verified 2026-06-08 on AMD Strix Halo Radeon 8060S iGPU, llama.cpp b9180 HIP, Q4_K_M (deepseek2 arch, 30B-A3B MoE) all 999 layers offloaded: 58.9 tok/s decode, 83 tok/s prompt. Context verified 2026-06-16: llama-server loads & serves n_ctx=202752 (full trained context) on the iGPU."
# --- GB10 (Blackwell, 128GB unified, K3S) ---
# BF16 ~59GB, 128GB unified memory 可容纳
# Glm4MoeLiteForCausalLM 架构 (glm4_moe_lite model_type)
Expand Down
13 changes: 11 additions & 2 deletions catalog/models/qwen2.5-vl-3b-instruct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,22 @@ variants:
hardware:
gpu_arch: "*"
vram_min_mib: 0
ram_min_mib: 4096
ram_min_mib: 8192
engine: llamacpp
format: gguf
default_config:
quantization: int4
n_gpu_layers: 999
ctx_size: 8192
# Context window = the model's full trained context, 128000 (128K) — verified
# on Strix Halo: llama-server loads & serves n_ctx=128000 (KV cache ~36 KB/token
# → ~5 GB at 128K; model Q4 ~2 GB; so ~8 GB total, hence ram_min 8192). 8192 was
# too small for agent clients (OpenClaw injects all MCP tool schemas → base prompt
# ~9.3K tokens > 8192 → "context overflow", model never replies). A large n_ctx
# only reserves KV memory up front; it does NOT slow short conversations (llama
# processes only the actual tokens). Do NOT exceed 128000 (the trained limit) or
# llama-server warns of training-context overflow. Override per-deploy with
# `--config ctx_size=N` on memory-constrained hardware.
ctx_size: 128000
expected_performance:
startup_time_s: 10
cold_start_time_s: 20
Expand Down
51 changes: 51 additions & 0 deletions catalog/models/qwen3-embedding-4b.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
kind: model_asset
metadata:
name: qwen3-embedding-4b
type: embedding
family: qwen
parameter_count: "4B"
released_at: "2025-06"
# Scan-name aliases: on-disk GGUF names so the local scanner matches this asset.
aliases:
- Qwen3-Embedding-4B
- Qwen3-Embedding-4B-Q4_K_M
- Qwen3-Embedding-4B-Q8_0
- qwen3-embedding-4b-q4_k_m
- qwen3-embedding-4b-q8_0
storage:
formats: [safetensors, gguf]
default_path_pattern: "{{.DataDir}}/models/{{.Name}}"
sources:
- type: huggingface
repo: Qwen/Qwen3-Embedding-4B
format: safetensors
- type: huggingface
repo: Qwen/Qwen3-Embedding-4B-GGUF
format: gguf
- type: modelscope
repo: Qwen/Qwen3-Embedding-4B
format: safetensors
- type: local_path
path: ""
variants:
# --- universal llamacpp GGUF (verified on AMD Strix Halo Radeon 8060S iGPU) ---
# Text-embedding model: deploy llama-server in --embedding mode (config embedding=true
# → --embedding) and serve OpenAI /v1/embeddings. 2560-dim output. Not a chat model,
# so it is NOT written into OpenClaw config by `aima openclaw sync` (embedding models
# have no chat provider mapping); use the /v1/embeddings endpoint directly.
- name: qwen3-embedding-4b-universal-llamacpp
hardware:
gpu_arch: "*"
vram_min_mib: 0
ram_min_mib: 6144
engine: llamacpp
format: gguf
default_config:
n_gpu_layers: 999
embedding: true
# Native context (40960). Deploy auto-clamps to fit hardware memory if needed.
ctx_size: 40960
expected_performance:
startup_time_s: 8
cold_start_time_s: 15
notes: "Verified 2026-06-16 on AMD Strix Halo Radeon 8060S iGPU, llama.cpp b9180 HIP, Q8_0 all layers offloaded: deploys in --embedding mode (config embedding=true), /v1/embeddings returns 2560-dim vectors. ctx_size 40960."
28 changes: 27 additions & 1 deletion catalog/models/qwen3.6-35b-a3b.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,42 @@ metadata:
released_at: "2026-04"
aliases:
- Qwen3.6-35B-A3B
- Qwen3.6-35B-A3B-UD-Q4_K_M
- qwen3.6-35b-a3b-q4_k_m
storage:
formats: [safetensors]
formats: [safetensors, gguf]
default_path_pattern: "{{.DataDir}}/models/{{.Name}}"
sources:
- type: huggingface
repo: Qwen/Qwen3.6-35B-A3B
format: safetensors
- type: huggingface
repo: unsloth/Qwen3.6-35B-A3B-GGUF
format: gguf
- type: local_path
path: ""
variants:
# --- universal llamacpp GGUF (verified on AMD Strix Halo Radeon 8060S iGPU) ---
# Qwen3.6-35B-A3B MoE, UD-Q4_K_M ~21GB, all layers offloaded. Native context 262144.
- name: qwen3.6-35b-a3b-universal-llamacpp-q4
hardware:
gpu_arch: "*"
vram_min_mib: 0
ram_min_mib: 24576
engine: llamacpp
format: gguf
default_config:
quantization: int4
n_gpu_layers: 999
# Full trained context (262144 / 256K). Deploy auto-clamps it down to fit
# hardware memory on smaller machines, so this large default is safe.
ctx_size: 262144
expected_performance:
startup_time_s: 30
cold_start_time_s: 60
tokens_per_second: [55, 70]
latency_first_token_ms: [30, 250]
notes: "Context verified 2026-06-16 on AMD Strix Halo Radeon 8060S iGPU, llama.cpp b9180 HIP, UD-Q4_K_M all 999 layers offloaded: llama-server loads & serves n_ctx=262144 (full trained context)."
# --- GB10 (Blackwell, 128GB unified, Docker/K3S) ---
# 实测稳定路径是 text-only vLLM:
# - language_model_only=true
Expand Down
105 changes: 105 additions & 0 deletions cmd/aima/ctxfit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package main

import (
"testing"

"github.com/jguan/aima/internal/knowledge"
)

// Qwen2.5-VL-3B text backbone: 36 layers, 2 KV heads, head_dim 128 → 36864 B/token.
const qwen25vl3bKVPerTok = int64(2 * 36 * 2 * 128 * 2)

func TestClampContextForMemory(t *testing.T) {
tests := []struct {
name string
reqCtx int
nCtxTrain int
kvPerTok int64
usableMiB int
nonKVMiB int
want int
clamped bool // want < reqCtx
}{
{
name: "huge memory, capped only by trained context",
reqCtx: 128000, nCtxTrain: 128000, kvPerTok: qwen25vl3bKVPerTok,
usableMiB: 112640, nonKVMiB: 4300, // ~110GB Strix Halo
want: 128000, clamped: false,
},
{
name: "request above trained context is capped down",
reqCtx: 200000, nCtxTrain: 128000, kvPerTok: qwen25vl3bKVPerTok,
usableMiB: 112640, nonKVMiB: 4300,
want: 128000, clamped: true,
},
{
name: "16GB unified clamps below 128k",
reqCtx: 128000, nCtxTrain: 128000, kvPerTok: qwen25vl3bKVPerTok,
usableMiB: 12288, nonKVMiB: 4300, // 16GB - 4GB reserve
// budget = 12288*0.9 - 4300 - 1024 = 5734 MiB → /36864 B = ~163k → but capped at train 128000? 163k>128000 so 128000
want: 128000, clamped: false,
},
{
name: "8GB discrete GPU clamps below 128k",
reqCtx: 128000, nCtxTrain: 128000, kvPerTok: qwen25vl3bKVPerTok,
usableMiB: 8192, nonKVMiB: 4300,
// budget = int(8192*0.9) - 4300 - 1024 = 7372 - 5324 = 2048 MiB
// maxCtx = 2048*1024*1024/36864 = 58254 → round down to 58112
want: 58112, clamped: true,
},
{
name: "tiny memory floors at minCtx",
reqCtx: 128000, nCtxTrain: 128000, kvPerTok: qwen25vl3bKVPerTok,
usableMiB: 6144, nonKVMiB: 4300,
// budget = 6144*0.9 - 4300 - 1024 = ~205 MiB → ~5832 → round 5632
want: 5632, clamped: true,
},
{
name: "unknown memory: only trained-context cap applies",
reqCtx: 65536, nCtxTrain: 128000, kvPerTok: qwen25vl3bKVPerTok,
usableMiB: 0, nonKVMiB: 0,
want: 65536, clamped: false,
},
{
name: "unknown arch (kvPerTok 0): no memory clamp",
reqCtx: 65536, nCtxTrain: 0, kvPerTok: 0,
usableMiB: 8192, nonKVMiB: 4300,
want: 65536, clamped: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, _ := clampContextForMemory(tt.reqCtx, tt.nCtxTrain, tt.kvPerTok, tt.usableMiB, tt.nonKVMiB)
if got != tt.want {
t.Errorf("clampContextForMemory = %d, want %d", got, tt.want)
}
if (got < tt.reqCtx) != tt.clamped {
t.Errorf("clamped = %v, want %v (got=%d req=%d)", got < tt.reqCtx, tt.clamped, got, tt.reqCtx)
}
})
}
}

func TestUsableMemoryMiB(t *testing.T) {
tests := []struct {
name string
hw knowledge.HardwareInfo
want int
}{
{"unified 128GB reserves 16GB cap", knowledge.HardwareInfo{UnifiedMemory: true, RAMTotalMiB: 131072}, 131072 - 16384},
{"unified 16GB reserves 1/4", knowledge.HardwareInfo{UnifiedMemory: true, RAMTotalMiB: 16384}, 16384 - 4096},
{"unified 8GB reserve floored at 2GB", knowledge.HardwareInfo{UnifiedMemory: true, RAMTotalMiB: 8192}, 8192 - 2048},
{"unified APU prefers iGPU pool over under-detected OS RAM", knowledge.HardwareInfo{UnifiedMemory: true, RAMTotalMiB: 32768, GPUVRAMMiB: 110456}, 110456},
{"discrete prefers free VRAM", knowledge.HardwareInfo{GPUVRAMMiB: 8192, GPUMemFreeMiB: 7000}, 7000},
{"discrete falls back to total VRAM", knowledge.HardwareInfo{GPUVRAMMiB: 8192}, 8192},
{"cpu-only uses system RAM", knowledge.HardwareInfo{RAMTotalMiB: 32768}, 32768 - 8192},
{"unknown memory returns 0", knowledge.HardwareInfo{}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := usableMemoryMiB(tt.hw); got != tt.want {
t.Errorf("usableMemoryMiB = %d, want %d", got, tt.want)
}
})
}
}
120 changes: 120 additions & 0 deletions cmd/aima/tooldeps_deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/jguan/aima/internal/engine"
"github.com/jguan/aima/internal/knowledge"
"github.com/jguan/aima/internal/mcp"
"github.com/jguan/aima/internal/model"
"github.com/jguan/aima/internal/proxy"
"github.com/jguan/aima/internal/runtime"

Expand Down Expand Up @@ -98,6 +99,15 @@ func buildDeployDeps(ac *appContext, deps *mcp.ToolDeps,
slog.Info("auto-wired multimodal projector for vision", "model", modelName, "mmproj", mm)
}
}

// Hardware-aware context sizing: a high catalog/user ctx_size can exceed
// what the detected memory holds — llama-server would OOM at load. Clamp
// ctx_size down to fit weights + projector + KV cache (and cap at the
// model's trained context), degrading gracefully instead of failing.
if clamped, oldCtx, newCtx, reason := fitContextToMemory(modelPath, resolved.Config, hwInfo); clamped {
slog.Warn("clamped context window to fit hardware memory",
"model", modelName, "ctx_size_requested", oldCtx, "ctx_size_applied", newCtx, "detail", reason)
}
}

req := &runtime.DeployRequest{
Expand Down Expand Up @@ -776,6 +786,116 @@ func deploymentOverviewFromStatus(status *runtime.DeploymentStatus, cat *knowled
}
}

// fitContextToMemory shrinks config["ctx_size"] so the llama.cpp KV cache plus
// model weights and the multimodal projector fit the detected memory, and caps it
// at the model's trained context. It only ever lowers ctx_size — never raises it.
// Returns (clamped, requestedCtx, appliedCtx, reason). It is a no-op (clamped=false)
// when ctx_size is unset, the GGUF architecture can't be read, or memory is unknown,
// so unsupported models/hardware degrade gracefully instead of erroring.
func fitContextToMemory(modelPath string, config map[string]any, hw knowledge.HardwareInfo) (bool, int, int, string) {
reqCtx := contextWindowFromResolvedConfig(config)
if reqCtx <= 0 || modelPath == "" || config == nil {
return false, 0, 0, ""
}
arch, ok := model.ReadKVArch(modelPath)
if !ok {
return false, 0, 0, "" // can't estimate KV → leave ctx_size untouched
}

nonKVMiB := fileSizeMiB(modelPath)
if mm, _ := config["mmproj"].(string); mm != "" {
nonKVMiB += fileSizeMiB(mm)
}

target, reasons := clampContextForMemory(reqCtx, arch.NCtxTrain, arch.KVBytesPerToken(), usableMemoryMiB(hw), nonKVMiB)
if target >= reqCtx {
return false, reqCtx, reqCtx, ""
}
config["ctx_size"] = target
return true, reqCtx, target, strings.Join(reasons, "; ")
}

// clampContextForMemory computes the largest context window ≤ reqCtx that fits:
// (a) the model's trained context (nCtxTrain, 0 = unknown/skip) and (b) the KV
// budget left after weights+projector in usableMiB (0 = unknown/skip). kvPerTok
// is the f16 KV bytes per token. It floors at a minimally useful context. Pure
// (no I/O) for testability.
func clampContextForMemory(reqCtx, nCtxTrain int, kvPerTok int64, usableMiB, nonKVMiB int) (int, []string) {
const (
computeReserveMiB = 1024 // llama.cpp compute buffers
minCtx = 2048 // floor — keep a usable context even on tiny memory
)
target := reqCtx
var reasons []string

if nCtxTrain > 0 && target > nCtxTrain {
target = nCtxTrain
reasons = append(reasons, fmt.Sprintf("capped at trained context %d", nCtxTrain))
}

if usableMiB > 0 && kvPerTok > 0 {
kvBudgetMiB := int(float64(usableMiB)*0.90) - nonKVMiB - computeReserveMiB
if kvBudgetMiB < 0 {
kvBudgetMiB = 0
}
maxCtx := int(int64(kvBudgetMiB) * 1024 * 1024 / kvPerTok)
maxCtx -= maxCtx % 256 // clean multiple
if maxCtx < target {
target = maxCtx
reasons = append(reasons, fmt.Sprintf("%d MiB usable, weights+projector %d MiB, KV %d B/token",
usableMiB, nonKVMiB, kvPerTok))
}
}

if target < minCtx {
target = minCtx
}
return target, reasons
}

// usableMemoryMiB returns the memory budget an all-layers-offloaded llama.cpp
// model can use: GPU VRAM for discrete GPUs, or system RAM minus an OS reserve
// for unified-memory / CPU hosts. Returns 0 when memory is unknown.
func usableMemoryMiB(hw knowledge.HardwareInfo) int {
ramReserve := func(total int) int {
reserve := total / 4
if reserve < 2048 {
reserve = 2048
}
if reserve > 16384 {
reserve = 16384
}
return reserve
}
// An all-layers-offloaded llama.cpp model is bounded by GPU memory. For a
// unified-memory APU this is the carved iGPU pool (read via ROCm), which is the
// correct budget — NOT the OS-visible system RAM, which Win32 under-reports on
// such APUs (e.g. Strix Halo shows ~32 GB OS RAM but ~110 GB iGPU VRAM). Prefer
// GPU memory whenever it's known; fall back to system RAM only for CPU-only hosts.
if hw.GPUMemFreeMiB > 0 {
return hw.GPUMemFreeMiB
}
if hw.GPUVRAMMiB > 0 {
return hw.GPUVRAMMiB
}
if hw.RAMTotalMiB > 0 {
return hw.RAMTotalMiB - ramReserve(hw.RAMTotalMiB)
}
return 0
}

// fileSizeMiB returns the file's size in MiB, or 0 if it can't be stat'd.
func fileSizeMiB(path string) int {
if path == "" {
return 0
}
fi, err := os.Stat(path)
if err != nil {
return 0
}
return int(fi.Size() / (1024 * 1024))
}

func contextWindowFromResolvedConfig(config map[string]any) int {
if len(config) == 0 {
return 0
Expand Down
Loading
Loading